Queues · Lesson 5 · the payload
This is the lesson you actually came for. SQS gives you no scheduler, no
priorities, no "deliver this in 3 hours." Conveyor needs all of those — per-job
rate limiting and 8-hour webhook retries — and builds them out of two humble
primitives: the visibility timeout and DelaySeconds. Once you see
this, the "advanced patterns" stop being advanced. They're just these two levers,
used well.
In Lesson 1 the visibility timeout was
something SQS sets when it hands you a message. But you can also set it
yourself, on a live in-flight message, with ChangeMessageVisibility.
That one call turns "when does this message come back?" into a dial you control —
and that dial is enough to build flow control.
Conveyor caps how many tasks a single job runs at once. When a worker picks up a
task for a job that's already at its ceiling, it does not fail
and it does not process — it returns a sentinel,
ErrThrottled ("not a failure; the limiter already counted the
throttle"). The dispatcher catches that and bounces the message:
// dispatcher.go — the throttle branch
case errors.Is(err, concurrency.ErrThrottled):
d.markThrottled(jobID) // 1. cool the queue down
d.bounce(ctx, q, m, adaptiveBounceVisibility(m.ReceiveCount))
// bounce = q.ChangeVisibility(m, visibility) — hide it, try later
No delay queue, no Redis, no cron. "Try this again in a bit" is expressed purely by re-hiding the message. When a permit frees up, the message reappears and some worker runs it. That is backpressure built entirely from the borrow clock.
maxReceiveCount: 20
budget. Exponential spacing means a waiting task can't be re-received fast enough
to burn through 20 and get wrongly dead-lettered. The backoff curve and the DLQ
threshold were designed together.
pickRandom skips a queue whose
local in-flight already hit the limit, so you don't even receive work you
can't run. (2) cooldown — markThrottled makes the
pool skip that queue for ~5s, so 100 goroutines don't all bounce it at once.
(3) bounce — for whatever still slipped through, re-hide it with
the exponential visibility. Avoid receiving → avoid re-picking → defer the
remainder. That layering is the craft.
Webhook delivery retries on a curve: 30s → 8h with jitter, over a 3-day budget
(webhook.go). But there's a wall: SQS caps
DelaySeconds at 15 minutes. How do you wait 8 hours with a
15-minute primitive? You hop.
NextAttemptAt = now + 2h into the message body.
now
past NextAttemptAt? Not yet (only 15m passed). So
re-defer: resend with another 15m delay. Attempt is not incremented —
this hop made no real attempt.
now ≥ NextAttemptAt,
then actually POST the webhook. A long timer, assembled from
short ones.
The full state lives in the message body (Attempt,
NextAttemptAt, LastError), so each hop is
self-contained — no timer table, no scheduler row. The queue is the timer.
Per-message visibility + DelaySeconds. A surprisingly strong primitive — you built rate limiting and an 8h retry timer with nothing else.
Has this natively: AckNak with a delay is exactly the bounce —
"redeliver this in N seconds." Conveyor hand-rolls what JetStream ships.
No per-message delay at all. To back off you pause the partition or seek — blunt instruments. Visibility-style per-message control is genuinely something Kafka can't do.
Primary sources: AWS
· Changing a message's visibility timeout and
AWS
· Delay queues (DelaySeconds). Then read, in order: the throttle branch of
processOnce and adaptiveBounceVisibility in
dispatcher.go, then deferDelivery +
scheduleRetryOrGiveUp in webhook.go.
This is the densest lesson so far — if the bounce/cooldown/gate interplay or the multi-hop timer is fuzzy, ask me to trace a single throttled task or a single 2-hour retry step by step. That's the best way to lock it in.