Case Study: Queue and Worker Pipeline
Requirements
- Accept a submission and acknowledge it immediately, without holding the connection for the duration of the work.
- Process records at a rate that keeps the oldest waiting job under an agreed age, even during a 40,000-record burst.
- Survive worker crashes and third-party API failures without losing work.
- Never let one unprocessable record stop the pipeline.
- Answer "what happened to this specific record?" for any submission in the retention window.
- Run with an identity scoped to what the pipeline actually touches.
Deliberately not requirements
Half of a design is what it refuses to do. These are the refusals.
- No ordering guarantee across records — each record is independent, which is what makes this design possible at all.
- No exactly-once delivery: the pipeline is built for at-least-once with idempotent effects.
- No sub-second processing latency; the contract is minutes, not milliseconds.
How the design got here
In order. Each stage leads with the problem that forced it.
Process inside the request
The requirement, at the volume it started with. A submission of two hundred records validated and enriched in about four seconds, well within the HTTP timeout. There was no queue, no worker and no state machine, and the whole feature was one endpoint. This is the correct starting design, and skipping it to "build it properly" would have cost weeks before anyone knew whether the feature mattered.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| Do the work inline while it fits inside a request. | A queue and a worker pool are two more deployables, a delivery-semantics problem and a status-tracking problem. None of that is justified by four seconds of work. | Build the queue immediately, which is what most teams do and what costs them a fortnight before the first customer has used the feature. | The design has a hard ceiling defined by the load balancer's idle timeout, and the failure at that ceiling is ugly: a timeout after the work has partly happened, with no record of how far it got. |
A queue and one worker
A 200,000-row file took eleven minutes. The load balancer closed the connection at sixty seconds, the customer's client treated that as a failure and retried, and two full ingests of the same file ran concurrently against the same third-party rate limit — producing duplicate records and a bill from the enrichment provider.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| The API returns 202 with a submission id and does none of the work. | It decouples the client's connection lifetime from the work's duration, which is the entire point. The submission id is what makes the work addressable afterwards. | Keep the connection open with streaming progress, which gives a nicer client experience and reintroduces the timeout ceiling plus a held worker. | The client must now poll or receive a callback, and you have introduced a state machine — accepted, processing, partially failed, complete — that has to be designed, stored and explained in the API contract. |
| One message per record, not one per file. | It makes the unit of retry a single record, so one bad row cannot poison a file of 200,000 good ones, and it lets throughput scale by adding consumers rather than by making one consumer faster. | One message per file, which is far simpler to track and makes retry mean "redo eleven minutes of work" and parallelism mean nothing. | Message volume goes up by four orders of magnitude, which costs money per request and turns the fan-out itself into a job that can fail halfway. The submission needs an expected-record-count so you can detect a partial fan-out. |
| The visibility timeout is sized to the slowest plausible record, not the average. | If the timeout expires while a worker is still processing, the queue redelivers the message and a second worker starts the same record — the classic source of mysterious duplicates. | A short timeout with a heartbeat that extends it, which is more precise and more code to get wrong. | A long timeout means a crashed worker's messages stay invisible for that long before anyone else can pick them up, so crash recovery is slower. You are trading duplicate work against recovery latency, and you must pick a side deliberately. |
Many workers, scaled by queue age
One worker cleared roughly 1,200 records an hour. A customer onboarding pushed 40,000 records on a Monday morning, the queue took most of two days to drain, and every other customer's submissions sat behind it — including a 50-record file that should have taken twenty seconds and took nineteen hours.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| Scale on oldest-message age, not on queue depth. | Age is the customer-visible property. Depth tells you how much work exists; age tells you whether anyone is waiting too long, which is what the contract actually promises. | Depth-based scaling, which is simpler and over-scales for a flood of trivial jobs while under-reacting to a slow backlog. | Age is noisier and meaningless at zero workers, so it needs a floor. It also lags: by the time age is high, the backlog already exists — no reactive policy can scale before demand arrives. |
| A separate queue for small submissions. | A single FIFO queue lets one customer's bulk load block everyone. Two queues with different priorities is the cheapest possible fairness mechanism and solves the actual complaint. | Per-tenant queues or a fair-scheduling consumer, which is genuinely fair and is a scheduler you now own and must debug. | A threshold that must be chosen and will be gamed — a customer who splits a large file into small ones jumps the queue. Fairness by heuristic is fairness until someone notices the heuristic. |
| Cap the worker pool at the third-party rate limit. | Autoscaling always terminates at a fixed dependency. Past the enrichment provider's limit, extra workers produce 429s, retries, and a larger bill for the same throughput. | Scale freely and let retries absorb the rejections, which wastes compute and can get your account throttled harder or suspended. | There is now a hard maximum throughput, so a sufficiently large burst *will* take a long time and no amount of money fixes it. That number belongs in the customer contract, not in a config file nobody reads. |
Retries, backoff and a dead-letter queue
Two failures in one week, at opposite extremes. A malformed record threw on every attempt; the queue redelivered it indefinitely, and a worker spent three days crashing and restarting on the same message while appearing perfectly healthy. Meanwhile a thirty-second outage at one enrichment provider caused 4,000 *good* records to be marked permanently failed, because the code treated every exception the same way.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| Classify failures as transient or permanent before deciding to retry. | Retrying a permanent failure burns capacity and never succeeds; failing a transient one throws away work that would have succeeded thirty seconds later. Both incidents in this stage were the same missing distinction. | Retry everything a fixed number of times, which is simple, and permanently costs you a multiple of the wasted work on every malformed input. | The classification lives in your code and drifts from reality as providers change their error semantics. It needs tests and periodic review, and it will be wrong at least once in an interesting way. |
| Exponential backoff with jitter on transient failures. | Without jitter, every worker that failed during the same provider outage retries at the same instant and recreates the overload the moment the provider recovers. | Fixed-interval retries, which are easier to reason about and synchronize your entire fleet into a thundering herd. | Worst-case latency for a record grows with the backoff schedule, so the oldest-message-age signal becomes harder to interpret — some of that age is your own deliberate waiting. |
| A dead-letter queue with alerting on depth and on age, plus a replay path. | Bounded attempts stop the poison-message loop; the alert stops the DLQ becoming a silent data-loss channel; the replay path is what makes fixing a bug meaningful for the records that already failed. | Log the failure and drop the record, which needs no new infrastructure and means "we lost your data" is the design. | Somebody must own the DLQ. An unread dead-letter queue is worse than none at all, because everyone believes it is a control. And replay must be idempotent, or fixing the bug creates duplicates. |
A worker identity of its own
The workers used the same static access key as the API, stored in the deploy repository. It could read every bucket in the account. When it appeared verbatim in a support log shared with a customer, rotating it required a coordinated restart of every service at once — a self-inflicted outage on top of a security incident.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| Separate roles for the API and the workers, scoped to prefixes and queues rather than to services. | Least privilege is measured by blast radius (§62). If a worker is compromised, the honest question is "what can it reach?" — and "one queue and two prefixes" is a much better answer than "every bucket in the account". | One shared role for the whole application, which is fewer things to maintain and makes every compromise a full-account compromise. | More policies to write and keep current, and a new failure mode: a legitimately new operation is denied at 02:00 and fails silently, exactly as in break-iam. Least privilege buys blast-radius reduction with a permanent stream of small permission problems. |
| The enrichment credential lives in the secret manager, read at startup by the worker role. | It puts a rotatable, audited boundary around a third-party credential that is also a billing instrument — a stolen enrichment key is somebody spending your money. | An environment variable set at deploy time, which is simpler and makes rotation a full redeploy and leaves the value in the deployment system's history. | The secret manager is now in the worker's startup path, so it needs a private endpoint and a cached fallback, and a secret-service blip becomes a worker that will not start. |
Per-record observability
A customer asked where their file was, and the only answer anyone could give was "the queue has 12,000 messages". There was no way to say which of their records had been processed, which had failed, or why — the logs were per-worker and interleaved across every tenant.
| Decision | Reason | Alternative | Trade-off |
|---|---|---|---|
| Per-record status in the database, not only in logs. | Support needs a query, not a log search. A status row with a reason code answers the customer's question in one lookup and is retained on your schedule rather than the log store's. | Reconstruct status from logs, which is free until the first time you need it under time pressure with a customer on the phone. | One write per record state transition, which at high volume is real database load — this is the stage where the status table can become busier than the data table it describes. |
| Alert on the oldest-message age against the contract, and on throughput-zero-while-depth-nonzero. | Age is the promise you made. The second condition catches the failure this pipeline has that nothing else detects: workers alive, healthy, and processing nothing — a stuck consumer, an exhausted credential, a wrong permission. | Alert on queue depth, which fires during every normal burst and stays silent during a total stall with an empty queue. | Two more rules to tune, and the throughput-zero rule needs a grace period or it pages during every quiet night. Every good alert has a false-positive story. |
What would break this
Every design has a load, a failure or an organization size at which it stops being the right one.
- A requirement for ordering. Independent records are what makes parallelism free; the moment record N must be processed after record N-1, you need partitioned ordering keys, and throughput becomes bounded by the slowest partition.
- A single record that takes longer than the maximum visibility timeout. Past that the queue redelivers work that is still running, and you need checkpointing or a job store rather than a message queue.
- Payload larger than the queue's message size limit. The fix is a pointer to object storage in the message, which adds a lifecycle problem: who deletes the payload after the job succeeds, and after it fails?
- A throughput target above the third-party rate limit. No infrastructure change fixes this; it is a commercial negotiation or a different provider.
- Fairness needs beyond two queues. Real per-tenant fairness means a scheduler, and a scheduler is a piece of software you now own, debug and page on.
- Results that must be queryable as a stream rather than a status row. That is a different data platform, and this pipeline becomes its producer.
Cost shape
Drivers and relative weights. Never a price.
Bars are relative weights, not currency. Real rates depend on provider, region, commitment and volume.