Queues · Lesson 2
Lesson 1 left you with a loaded fact: a slow worker or a crashed worker both cause the same job to be delivered again. SoHandleMessagein yourtask.gocan run twice for the same task. This lesson is how conveyor makes that a non-event — and it's almost entirely a tour of code you already own.
Conveyor neutralises duplicates with two complementary moves. Learn to spot both — almost every idempotent system is some mix of them.
Before doing any work, ask whether it's already done. In
internal/worker/task/task.go, the first thing the handler does after
loading the task is branch on its stored status:
switch task.Status {
case model.TaskStatusPending:
// Normal first delivery.
case model.TaskStatusProcessing:
// Redelivery — at-least-once. Result key is deterministic;
// only one terminal write can win.
case model.TaskStatusSuccessful, model.TaskStatusFailed:
// Already terminal — nothing to do.
return nil // ← settle & delete, do NOT re-scrape
}
That return nil on a terminal status is the guard. The task is
already finished, so the handler does no work and tells SQS to delete the
message. A redelivered "already done" job costs one DynamoDB read and nothing
else — no second scrape, no double proxy spend.
processing,
both scrape. The guard can't help here — so conveyor needs a second move.
Conveyor stores each result in S3 under a key derived deterministically from
(job_id, run_id, task_id) — the same task always computes the same
key. So even if workers A and B both scrape the URL, they write to the
identical S3 object. Last write wins, the bytes are the same, and
the stored result is correct either way. The handler's own comment says it:
"Result key is deterministic; only one terminal write can win."
Remember from Lesson 1 that deleting a message is a separate, explicit act. Conveyor expresses it through the handler's return value, and the choice is itself an idempotency decision:
| Situation on delivery | Handler returns | SQS outcome |
|---|---|---|
Task pending (first time) | nil after processing | delete — done |
Task processing (redelivery) | nil after re-processing | delete — converged on same key |
Task already successful/failed | nil immediately | delete — guarded, no work |
| Malformed JSON body (poison) | nil immediately | delete — don't redeliver garbage |
| Job or run deleted/terminal | nil immediately | delete — work no longer wanted |
| DynamoDB read failed (transient) | the error | redeliver — try again later |
maxReceiveCount and lands in the DLQ — which is exactly the machinery
of Lesson 4. Returning nil on a real bug
also silently drops work. This single decision is where most SQS pain lives.
Dedups producer retries within a 5-min window and orders messages. But your consumer can still crash after work, before delete — so you still need idempotent processing. Exactly-once is narrower than it sounds. (Lesson 7.)
"Exactly-once" via transactions ties consume+produce+offset into one atomic commit — but only for work that stays inside Kafka. A scrape hitting the outside world is back to at-least-once + idempotency, same as you.
Skip FIFO entirely (all queues Standard), and pay for correctness with guard + converge. Cheaper, higher throughput, and the idempotency was unavoidable anyway. Now you can defend that choice.
Primary source: AWS
· Standard queues and at-least-once delivery — short, and it states the
"design your application to be idempotent" rule in AWS's own words. Then re-read
internal/worker/task/task.go lines ~184–205 with this lesson open;
it should now read like prose.
Want to go deeper on any of it — how the deterministic key is actually built, what a DynamoDB conditional write (true CAS) would add, or why conveyor tolerates a double-scrape instead of locking? Ask me.