Event-Driven COG Ingestion with S3 Notifications
Imagery arrives on its own schedule. A satellite tasking order completes at 04:12, a drone survey uploads in a burst of three hundred frames, a partner drops a quarterly orthophoto set overnight. A polling ingestion job either wastes most of its runs finding nothing or discovers new imagery hours after it landed; an event-driven pipeline starts converting a scene to a Cloud Optimized GeoTIFF within seconds of the upload completing. The difficulty is that object-storage notifications are at-least-once, unordered, and fire on every object including the ones your own pipeline writes — so a naive wiring produces duplicate conversions and, in the worst case, an infinite loop. This guide extends Raster Pipeline Provisioning within Geospatial Resource Provisioning.
Symptom identification and triage
If you are moving from polling, the symptoms that justify the change are latency and waste: imagery visible on the map hours after upload, and a scheduled job that runs 288 times a day to do useful work on four of them. If you already have an event pipeline misbehaving, three signatures dominate.
Duplicate conversions of the same scene. Two conversion jobs for one upload. Notifications are at-least-once, so a duplicate delivery is normal and expected; a pipeline without idempotency treats it as a second scene and pays twice, or worse, produces two outputs that race to write the same key.
A recursion loop. Compute cost climbs without bound and the object count doubles repeatedly. The classic cause is a notification configured on the whole bucket, so the COG the pipeline writes triggers the pipeline again. Prefix and suffix filters are not tidiness — they are the loop break.
Silently dropped scenes. An upload appears in the bucket and nothing happens. Usually a multipart upload whose completion event type is not the one the notification subscribes to, or a scene that failed conversion and vanished because there is no dead-letter queue.
Prerequisites and environment assumptions
Terraform 1.6 or later with hashicorp/aws pinned at ~> 5.60. A bucket layout that separates source imagery from derived products by prefix, following the conventions in Object Storage for Raster and Vector Data — this guide’s loop break depends on it. A conversion runtime with GDAL available; for scenes under a few gigabytes a Lambda with a GDAL layer is sufficient, and beyond that the batch approach in Provisioning Batch Compute for Raster Mosaicking is the right target.
Decide the idempotency key before writing any code. Object key alone is insufficient because a re-upload under the same key is a legitimately new scene; object key plus version identifier, or key plus ETag, identifies a specific object generation and is what makes redelivery cheap and re-upload effective.
Step-by-step implementation
-
Filter the notification to source imagery only. Configure the event on the
raw/prefix with the source suffix, so the converted output undercog/cannot match. This is the recursion break, and it is worth stating in a comment in the code because it looks like an optimisation and is not. -
Put a queue between the notification and the work. Direct invocation couples the notification’s concurrency to your conversion capacity, and a drone upload of three hundred frames will exceed it. A queue absorbs the burst, gives you a retry policy, and provides a dead-letter destination.
-
Make the conversion idempotent. Record the object’s identity and version in a conditional write before starting work, and treat a conditional-write failure as “already handled” rather than as an error. Without this, at-least-once delivery means paying twice for a proportion of every day’s imagery.
-
Write output to an unwatched prefix, atomically. Convert to a temporary key and copy to the final key on success, so a consumer never sees a partially written COG. A half-written GeoTIFF that a tile renderer opens is a confusing failure precisely because the file format tolerates truncation until it does not.
-
Configure a dead-letter queue and alarm on its depth. A scene that fails conversion must be visible. The single most common cause of “we lost a day’s imagery” is a message that exhausted retries into a destination nobody monitors.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
}
resource "aws_sqs_queue" "ingest_dlq" {
name = "cog-ingest-dlq"
message_retention_seconds = 1209600 # 14 days: long enough to notice and replay
}
resource "aws_sqs_queue" "ingest" {
name = "cog-ingest"
# Visibility must exceed the longest conversion, or a slow scene is redelivered
# while it is still being converted and the work is duplicated.
visibility_timeout_seconds = 900
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.ingest_dlq.arn
maxReceiveCount = 3
})
}
resource "aws_s3_bucket_notification" "imagery" {
bucket = aws_s3_bucket.imagery.id
queue {
queue_arn = aws_sqs_queue.ingest.arn
# Both event types: a large scene arrives as a multipart upload, and
# subscribing only to Put silently drops every scene above the threshold.
events = ["s3:ObjectCreated:Put", "s3:ObjectCreated:CompleteMultipartUpload"]
# THE LOOP BREAK. The pipeline writes to cog/, which is not raw/, so the
# output cannot re-trigger the pipeline. Removing either filter turns this
# into an unbounded recursion.
filter_prefix = "raw/"
filter_suffix = ".tif"
}
}
# Idempotency records, keyed by object identity AND version so a genuine
# re-upload is processed while a redelivery is not.
resource "aws_dynamodb_table" "processed" {
name = "cog-ingest-processed"
billing_mode = "PAY_PER_REQUEST"
hash_key = "object_version"
attribute {
name = "object_version"
type = "S"
}
ttl {
attribute_name = "expires_at"
enabled = true
}
}
resource "aws_cloudwatch_metric_alarm" "dlq_depth" {
alarm_name = "cog-ingest-dlq-not-empty"
comparison_operator = "GreaterThanThreshold"
threshold = 0
evaluation_periods = 1
metric_name = "ApproximateNumberOfMessagesVisible"
namespace = "AWS/SQS"
period = 300
statistic = "Maximum"
dimensions = { QueueName = aws_sqs_queue.ingest_dlq.name }
alarm_actions = [var.oncall_topic_arn]
}
variable "oncall_topic_arn" { type = string }
The conversion itself is a short GDAL invocation, and the flags are worth being deliberate about because they determine whether the output is genuinely cloud-optimized:
# Convert to COG. The overviews and the internal tiling are what make range
# reads cheap for a tile renderer — a GeoTIFF without them is just a GeoTIFF
# in a bucket, and every tile request reads far more than it needs.
gdal_translate "/vsis3/${SRC_BUCKET}/${SRC_KEY}" /tmp/out.tif \
-of COG \
-co COMPRESS=DEFLATE \
-co PREDICTOR=2 \
-co BLOCKSIZE=512 \
-co OVERVIEWS=AUTO \
-co NUM_THREADS=ALL_CPUS
# Write to a temporary key, then copy to the final key, so a consumer never
# opens a partially written raster.
aws s3 cp /tmp/out.tif "s3://${DST_BUCKET}/cog/.staging/${SCENE_ID}.tif"
aws s3 mv "s3://${DST_BUCKET}/cog/.staging/${SCENE_ID}.tif" \
"s3://${DST_BUCKET}/cog/${SCENE_ID}.tif"
Verification
Upload a test scene to the raw prefix and confirm a COG appears under the output prefix within the expected latency, then confirm the loop break by checking that the output object generated no second queue message — the queue’s received-message count should increment by one, not two. Deliver the same event twice deliberately and confirm the second produces no conversion and no second output.
Then verify the output is genuinely cloud-optimized rather than merely a GeoTIFF: gdalinfo should report internal tiling and overviews, and rio cogeo validate returns a definitive answer. Finally test the failure path — upload a corrupt file, confirm it retries the configured number of times and lands in the dead-letter queue, and confirm the alarm fires. A dead-letter queue that has never received a message is untested infrastructure.
Preventing recurrence
- Assert the notification filters in policy. A plan-contract rule that fails any bucket notification without both a prefix and a suffix filter closes the recursion class permanently, using the assertion approach in Testing and Validation for Spatial IaC.
- Alarm on dead-letter depth and on ingest freshness. Depth catches failures; freshness — the age of the newest successfully converted scene — catches the case where notifications stopped arriving at all and there is therefore nothing to fail.
- Keep visibility timeout above the longest conversion. When scene sizes grow, this ceiling is the first thing to become wrong, and its symptom is duplicate work rather than an error.
- Replay from the dead-letter queue deliberately. A documented replay procedure turns a bad day’s imagery into a re-run rather than a manual reconstruction.
Frequently Asked Questions
Why not invoke the conversion directly from the notification?
Because a burst of three hundred frames arrives faster than the conversion capacity, and direct invocation gives you throttling errors instead of a backlog. A queue converts a burst into a backlog, which is the failure mode you want, and it is also where retries and the dead-letter destination live.
How do I stop the pipeline processing its own output?
Prefix and suffix filters on the notification, with the output written to a prefix the notification does not match. Nothing else is reliable — a check inside the conversion job runs after the invocation has already been billed, and one deployment that forgets it produces the loop.
Are notifications ever lost?
Delivery is at-least-once, not exactly-once, and the practical implication is that duplicates are normal and gaps are rare but possible. Treat freshness monitoring as the backstop: a periodic reconciliation that compares source objects against converted outputs catches the rare gap without polling for every upload.
Should the COG output land in the same bucket?
A separate prefix in the same bucket is sufficient for the loop break and simpler to manage. A separate bucket is preferable when the lifecycle policies genuinely differ — source imagery archived aggressively, derived COGs kept hot — because it removes the risk that a lifecycle rule intended for one applies to the other.
Related
- Raster Pipeline Provisioning — the parent topic on raster processing infrastructure
- Orchestrating COG Generation Pipelines with Step Functions — the orchestrated alternative for multi-stage conversions
- Provisioning Batch Compute for Raster Mosaicking — where scenes too large for a function-based converter go
- Object Storage for Raster and Vector Data — the prefix layout this pipeline’s loop break depends on