Queues · Lesson 6 · a real incident
You shipped the fix (COR-316) but wanted the intuition: how could conveyor freeze at all? This is the capstone of Lessons 3–5 — the concurrency limit, the bounce, and long polling all collide here. And there's a simulator below so you can watch it happen, then flip one switch and watch it stop.
amplification = receives ÷ completions. When that ratio blows up, the
job crawls and valid tasks leak to the DLQ.
One hot run-queue, 10 tasks, a pool of workers, and a per-job limit of 1 permit (only one task may process at a time). Press Run with the gate OFF and watch the Amplification and Lost to DLQ numbers. Then — even mid-run — click Pre-poll gate: OFF to flip it ON and watch the behaviour change.
With the gate off, the queue empties into the "bounced" lane,
workers keep receiving-and-bouncing, receives sprint ahead of completions, and
tasks creep toward the DLQ. With the gate on, idle workers go
🔒 gated while the permit is held — they don't even receive — so
receives track completions and nothing is lost. Same code, one decision moved
earlier.
The flaw tying them together: nothing stopped a worker from polling a
queue whose job was already maxed out. So while one worker holds the
single permit and scrapes, the other 99 keep receiving the queued tasks and
bouncing every one. Thousands of receives, near-zero completions — that ratio
is the amplification. In prod it showed up as the CloudWatch
NumberOfMessagesReceived / Deleted ratio exploding.
That one flaw radiates into three symptoms:
ApproximateReceiveCount. Enough bounces during one
long scrape and a perfectly valid, just-waiting task crosses
maxReceiveCount and gets dead-lettered (L4).
That's why COR-314 raised it to 20 and COR-315 made bounces back off
exponentially — mitigations that slow the bleed, not the cure.
pickRandom finds
nothing pickable and the whole pool sleeps. Bounced tasks reappear on
ever-growing visibility (5s→10s→20s…). The job's work trickles out in lurches
with long dead gaps. From the outside: frozen.
Short polling masked this. Receives returned instantly, the pool churned
fast, and the amplification hid inside the empty-receive cost storm. The moment
that storm was fixed by switching to long polling
(ff44a57), the dynamics shifted: a worker that picks a maxed-out
queue now blocks up to 20s, tying up pool capacity, and the stall
became plain. A classic sequence — you fix the visible problem and reveal the one
hiding beneath it.
One idea: don't fetch work you can't run. pickRandom
now checks the job's local in-flight count before polling, and skips the
queue if it's already at the limit:
// dispatcher.go — pickRandom, COR-316
if entry.maxInflight > 0 &&
int(entry.localInflight.Load()) >= entry.maxInflight {
continue // job is maxed — don't even Receive; we'd only bounce it
}
While the permit is held, the queue is gated off: zero receives, zero bounces,
zero amplification. When the task finishes and the permit frees, the gate opens
and one worker picks up the next task. Your test
TestCOR316_GateReducesReceiveAmplification nails it: gate-on ⇒
strictly fewer receives, ≥ completions, ≤ DLQ.
Limiter stays the cross-container source of truth. With 10 worker
containers and a limit of 1, each container's gate allows 1 local attempt → up to
10 globally, and the limiter throttles the rest. So the gate doesn't
eliminate amplification across containers — it collapses it from "every
worker in every container" to "one worker per container." And it's an
optimisation layer: the limiter still enforces correctness; the gate just stops
the pointless receive.
0b5675a is a merge-order build break, not a behaviour change. PR #83
(the gate) widened addLocked to 3 args; PR #88 (long-poll), branched
before #83 merged, added a 2-arg call in a test. The textual merge compiled
neither side's intent and broke main's build; #91 passes 0 (gate
disabled) to that test call to restore it. And 2358435 just reverts
DefaultMaxInflight back to 500 now the freeze loadtest (which forced
it to 1) is done.
Your own primary sources: docs/adrs/005-per-job-concurrency-limit.md,
the commit trail (ff44a57 long-poll → 2caad22 COR-314 →
731dede COR-315 → b19f72e COR-316), and the local
reproduction in cmd/conveyor-loadtest/main.go +
internal/harness/harness.go (which prints the very amplification
number this lesson visualises).
Want me to trace one specific run through the loadtest harness, or to add a second slider to the simulator (more permits, more workers) so you can feel how the amplification scales? Ask me.