Task Queues And Competing Consumers
Purpose
Decide whether work belongs on a queue consumed by a pool of interchangeable workers, and
then operate that pool so the queue's own mechanics do not corrupt the work. The queue owns
assignment through pull or broker delivery/credit. Ready workers can take more work, but
prefetch, task size, priority and dispatch policy determine actual balance.
The failure this prevents is silent double-execution. In an SQS-style visibility model, receiving
does not remove a message; it hides it for a bounded time, and when that time expires it becomes visible
again for another worker. If the first worker is still running — slow dependency, long GC
pause, a batch that grew — the message is now being processed twice, concurrently, with
nothing failing and nothing retrying. The visibility timeout is a bet on how long the work
takes, and losing the bet duplicates the work.
Workflow
Inspect broker/queue type, acknowledgement mode, client/framework and Java versions, prefetch,
retention and redelivery configuration. RabbitMQ channel acknowledgements and JMS sessions
are not SQS receipt leases. Preserve the deployed baseline. Missing evidence is unknown;
deliver the ownership/ack/recovery path, budgets and actual validation. Run crash/requeue
experiments only in isolated or already authorized environments.
- Run the decision block below: interchangeable workers and independent items, or explicit
per-key lanes with ordering/recovery semantics.
- Measure lease exposure, not just handler time: prefetch/permit wait + queue client work +
handler + acknowledgement, under degraded dependencies and pauses. Select an explicit
premature-redelivery versus crash-recovery objective; there is no universal percentile.
- Pick one of the three responses to lease expiry and write down which: size from the
tail, extend by heartbeat while working, or make the handler repeat-safe (
idempotency)
and accept the overlap. Most systems need the third regardless.
- Bound accepted backlog by age, bytes/items, retention and recovery capacity. If the
managed broker cannot reject at a depth, enforce admission upstream and specify what the
producer sees (
rate-limiting-and-load-shedding).
- Bound intake before delivery: reserve permits before pulling, or configure broker credit/
prefetch and bounded dispatch for push consumers. Handle receive failure and submission rejection
without leaking permits or deliveries. The limit is
concurrency-limiting-and-bulkheads.
- Scale from a signal set. Age is closest to a latency SLO, but combine it with depth,
arrival/drain rate, in-flight saturation and startup delay; broker age can be approximate or
reset by redelivery.
references/worker-loop-and-scaling.md gives the control model.
- Prove it by killing a worker mid-lease and asserting redelivery, one observable side
effect under the declared contract, and no unexplained missing item. Happy paths alone do
not exercise recovery; sample fault cases do not prove every failure mode.
Decision block
Use a task queue with competing consumers when:
- items commute, or ordering is explicitly enforced by a broker group/partition and the
worker preserves that lane's ownership
- the worker is stateless and any worker can take any item
- producer and consumer rates differ over time and a bounded buffer absorbs the difference
- the work is retryable and its side effect can be made repeat-safe
Avoid a task queue when:
- correctness needs an order the queue cannot express or preserve through retry/redelivery;
FIFO/message-group queues can serialize a key, but head-of-line blocking is the cost
- the same item must be consumed independently by several subscribers with their own
positions, or must be replayable after it succeeded; here an acked message is gone
Prefer a partitioned log instead when:
- per-key ordering, replay, or several independent consumer groups are required
(kafka-consumers-in-java)
Prefer fenced ownership or resource-side concurrency control when:
- stale concurrent execution would violate correctness. Neither a queue lease nor leader
election alone proves exactly one effect (leader-election)
Prefer an in-process executor instead when:
- the work need not survive the process (executors-and-task-lifecycle)
Rules
- A visibility timeout is not a lock. It bounds how long a message stays hidden; it excludes
nobody, and two workers holding one item is the model working as designed. Mutual exclusion
needs a fencing token the resource checks (
leader-election) — a lease alone does not
survive a GC pause on its holder.
- Size from the measured receive-to-ack distribution plus safety/resolution margin, against
a stated premature-redelivery error budget and maximum crash-recovery delay. Segment by task
class; censored timings from already-expired work do not reveal the unseen tail.
- A heartbeat that extends the lease can keep wedged work hidden until renewal stops or a
broker limit is reached. Cap total
lease time and use credible progress where available, never thread liveness as proof of progress.
- A batch fetch starts every lease at receive time. For
B records processed serially, the
last sees the sum of preceding durations; with C handler slots it waits behind roughly
ceil(B/C)-1 waves, but correlated tails and scheduling matter. Measure receive-to-start and
receive-to-ack, reduce prefetch, or extend per message—do not multiply one percentile and call
it a probabilistic bound.
nack with immediate requeue and no delay is a hot loop: the same message returns
instantly, fails again, and the pool spends its capacity on one item. Requeue with a delay
and a delivery counter, and route it to poison-messages-and-dlq at the threshold.
- Never write
while (true) { var msg = poll(); executor.submit(() -> handle(msg)); } onto an
unbounded executor. It drains the broker's queue into the heap: the queue's backpressure
disappears, depth reads zero while the process is overloaded, and every in-flight lease is on
the clock at once. Acquire the permit before poll.
- Queue depth alone cannot predict wait, while oldest-message-age alone can be stale,
approximate, reset by retry, or dominated by one poison item. Use age for SLO alerting and a
controller signal set—visible/in-flight depth, arrival/drain rate, service-time distribution,
saturation, startup delay and downstream capacity. Validate stability and scale-down hysteresis.
- A shared queue can balance work dynamically, but it is not the per-worker-deque work-stealing
algorithm. Prefetch can strand work behind slow handlers; measure distribution and credit.
The in-JVM mechanics are
forkjoinpool-and-work-stealing.
- Strict priority starves the low class permanently while high-priority arrivals sustain above
capacity. Bound the starvation explicitly — age items into a higher class after a stated time
in queue, or give each class a weighted share of workers. "Rarely happens" is not a policy.
- On shutdown, stop intake and resolve polls racing with shutdown, then drain or cancel held
work. Releasing a delivery while its old handler still runs invites overlap; retain resource
guards/idempotency. Interruption does not prove termination. The grace budget and ordering
are
kubernetes-service-lifecycle.
- A durable broker may intentionally have no hard depth rejection, but accepted backlog is never
economically unbounded. Set maximum useful age, retention/storage quotas and catch-up/recovery
objectives; shed or defer admission before work becomes guaranteed-expired.
Security and tenant isolation
- Authenticate producers/workers and authorize queue, task type and tenant; never trust a
priority, callback URL, class name or serialized payload merely because it came from a queue.
- Validate size/schema before leasing expensive capacity. Encrypt sensitive payloads, minimize
DLQ copies and define deletion/retention for primary, retry and dead-letter queues.
- Apply per-tenant concurrency/quotas so one tenant cannot consume every worker or age another
tenant past its deadline. Preserve trace, task, attempt and idempotency identifiers without
putting secrets or raw PII in metric labels.
Primary references
References
- The lease model — choosing the visibility timeout from the
processing-time distribution, the duplicate-work window drawn as a sequence, heartbeat
extension with its failure mode and its cap, and what to do instead of treating a lease as a
lock. Read when setting or reviewing a visibility timeout, or when duplicate side effects
appear with no retry in the code.
- Worker loop and scaling — a competing-consumer ownership
protocol with bounded concurrency, lease heartbeat and drain-on-shutdown; the autoscaling
signal against the wrong ones; priority with ageing; and a test that kills a worker mid-lease.
Read before writing or reviewing a worker, or when deciding what the pool scales on.
1---2name: task-queues-and-competing-consumers3description: Distributing work to a pool of interchangeable workers through a queue: the lease and visibility-timeout model, and why an expired lease duplicates work instead of failing it; sizing the timeout from processing plus prefetch wait; heartbeats and their failure mode; admission and retention bounds; priority starvation; and age, backlog, arrival and drain rate as autoscaling signals. Use when two workers process one message although nothing retried or failed, when a lease is relied on for mutual exclusion, when a handler outlives its lease, when a queue has no maximum depth, when autoscaling is driven by queue depth, or when a poll loop feeds an unbounded executor. Not ack placement (delivery-semantics), repeat-safe handlers (idempotency), the message that never succeeds (poison-messages-and-dlq), queue arithmetic (littles-law-and-queueing), the concurrency limit (concurrency-limiting-and-bulkheads), shedding (rate-limiting-and-load-shedding), or the Kafka consumer group, a log (kafka-consumers-in-java).4---56# Task Queues And Competing Consumers78## Purpose910Decide whether work belongs on a queue consumed by a pool of interchangeable workers, and11then operate that pool so the queue's own mechanics do not corrupt the work. The queue owns12assignment through pull or broker delivery/credit. Ready workers can take more work, but13prefetch, task size, priority and dispatch policy determine actual balance.1415The failure this prevents is silent double-execution. In an SQS-style visibility model, receiving16does not remove a message; it hides it for a bounded time, and when that time expires it becomes visible17again for another worker. If the first worker is still running — slow dependency, long GC18pause, a batch that grew — the message is now being processed twice, concurrently, with19nothing failing and nothing retrying. **The visibility timeout is a bet on how long the work20takes, and losing the bet duplicates the work.**2122## Workflow2324Inspect broker/queue type, acknowledgement mode, client/framework and Java versions, prefetch,25retention and redelivery configuration. RabbitMQ channel acknowledgements and JMS sessions26are not SQS receipt leases. Preserve the deployed baseline. Missing evidence is unknown;27deliver the ownership/ack/recovery path, budgets and actual validation. Run crash/requeue28experiments only in isolated or already authorized environments.29301. **Run the decision block below**: interchangeable workers and independent items, or explicit31 per-key lanes with ordering/recovery semantics.322. **Measure lease exposure**, not just handler time: prefetch/permit wait + queue client work +33 handler + acknowledgement, under degraded dependencies and pauses. Select an explicit34 premature-redelivery versus crash-recovery objective; there is no universal percentile.353. **Pick one of the three responses to lease expiry** and write down which: size from the36 tail, extend by heartbeat while working, or make the handler repeat-safe (`idempotency`)37 and accept the overlap. Most systems need the third regardless.384. **Bound accepted backlog by age, bytes/items, retention and recovery capacity.** If the39 managed broker cannot reject at a depth, enforce admission upstream and specify what the40 producer sees (`rate-limiting-and-load-shedding`).415. **Bound intake before delivery**: reserve permits before pulling, or configure broker credit/42 prefetch and bounded dispatch for push consumers. Handle receive failure and submission rejection43 without leaking permits or deliveries. The limit is `concurrency-limiting-and-bulkheads`.446. **Scale from a signal set.** Age is closest to a latency SLO, but combine it with depth,45 arrival/drain rate, in-flight saturation and startup delay; broker age can be approximate or46 reset by redelivery. `references/worker-loop-and-scaling.md` gives the control model.477. **Prove it by killing a worker mid-lease** and asserting redelivery, one observable side48 effect under the declared contract, and no unexplained missing item. Happy paths alone do49 not exercise recovery; sample fault cases do not prove every failure mode.5051## Decision block5253```text54Use a task queue with competing consumers when:55- items commute, or ordering is explicitly enforced by a broker group/partition and the56 worker preserves that lane's ownership57- the worker is stateless and any worker can take any item58- producer and consumer rates differ over time and a bounded buffer absorbs the difference59- the work is retryable and its side effect can be made repeat-safe6061Avoid a task queue when:62- correctness needs an order the queue cannot express or preserve through retry/redelivery;63 FIFO/message-group queues can serialize a key, but head-of-line blocking is the cost64- the same item must be consumed independently by several subscribers with their own65 positions, or must be replayable after it succeeded; here an acked message is gone6667Prefer a partitioned log instead when:68- per-key ordering, replay, or several independent consumer groups are required69 (kafka-consumers-in-java)7071Prefer fenced ownership or resource-side concurrency control when:72- stale concurrent execution would violate correctness. Neither a queue lease nor leader73 election alone proves exactly one effect (leader-election)7475Prefer an in-process executor instead when:76- the work need not survive the process (executors-and-task-lifecycle)77```7879## Rules8081- **A visibility timeout is not a lock.** It bounds how long a message stays hidden; it excludes82 nobody, and two workers holding one item is the model working as designed. Mutual exclusion83 needs a fencing token the _resource_ checks (`leader-election`) — a lease alone does not84 survive a GC pause on its holder.85- Size from the measured **receive-to-ack** distribution plus safety/resolution margin, against86 a stated premature-redelivery error budget and maximum crash-recovery delay. Segment by task87 class; censored timings from already-expired work do not reveal the unseen tail.88- A heartbeat that extends the lease can keep wedged work hidden until renewal stops or a89 broker limit is reached. Cap total90 lease time and use credible progress where available, never thread liveness as proof of progress.91- **A batch fetch starts every lease at receive time.** For `B` records processed serially, the92 last sees the sum of preceding durations; with `C` handler slots it waits behind roughly93 `ceil(B/C)-1` waves, but correlated tails and scheduling matter. Measure receive-to-start and94 receive-to-ack, reduce prefetch, or extend per message—do not multiply one percentile and call95 it a probabilistic bound.96- `nack` with immediate requeue and no delay is a hot loop: the same message returns97 instantly, fails again, and the pool spends its capacity on one item. Requeue with a delay98 and a delivery counter, and route it to `poison-messages-and-dlq` at the threshold.99- Never write `while (true) { var msg = poll(); executor.submit(() -> handle(msg)); }` onto an100 unbounded executor. It drains the broker's queue into the heap: the queue's backpressure101 disappears, depth reads zero while the process is overloaded, and every in-flight lease is on102 the clock at once. Acquire the permit before `poll`.103- Queue depth alone cannot predict wait, while oldest-message-age alone can be stale,104 approximate, reset by retry, or dominated by one poison item. Use age for SLO alerting and a105 controller signal set—visible/in-flight depth, arrival/drain rate, service-time distribution,106 saturation, startup delay and downstream capacity. Validate stability and scale-down hysteresis.107- A shared queue can balance work dynamically, but it is not the per-worker-deque work-stealing108 algorithm. Prefetch can strand work behind slow handlers; measure distribution and credit.109 The in-JVM mechanics are `forkjoinpool-and-work-stealing`.110- Strict priority starves the low class permanently while high-priority arrivals sustain above111 capacity. Bound the starvation explicitly — age items into a higher class after a stated time112 in queue, or give each class a weighted share of workers. "Rarely happens" is not a policy.113- On shutdown, stop intake and resolve polls racing with shutdown, then drain or cancel held114 work. Releasing a delivery while its old handler still runs invites overlap; retain resource115 guards/idempotency. Interruption does not prove termination. The grace budget and ordering116 are `kubernetes-service-lifecycle`.117- A durable broker may intentionally have no hard depth rejection, but accepted backlog is never118 economically unbounded. Set maximum useful age, retention/storage quotas and catch-up/recovery119 objectives; shed or defer admission before work becomes guaranteed-expired.120121## Security and tenant isolation122123- Authenticate producers/workers and authorize queue, task type and tenant; never trust a124 priority, callback URL, class name or serialized payload merely because it came from a queue.125- Validate size/schema before leasing expensive capacity. Encrypt sensitive payloads, minimize126 DLQ copies and define deletion/retention for primary, retry and dead-letter queues.127- Apply per-tenant concurrency/quotas so one tenant cannot consume every worker or age another128 tenant past its deadline. Preserve trace, task, attempt and idempotency identifiers without129 putting secrets or raw PII in metric labels.130131## Primary references132133- [Amazon SQS visibility timeout](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html) — redelivery, in-flight limits, FIFO groups and extension limits.134- [RabbitMQ consumer acknowledgements](https://www.rabbitmq.com/docs/confirms) — delivery acknowledgement and requeue semantics, which are not identical to SQS visibility.135- [JMS acknowledgement modes](https://jakarta.ee/specifications/messaging/3.1/jakarta-messaging-spec-3.1) — session and acknowledgement semantics.136137## References138139- [The lease model](references/lease-model.md) — choosing the visibility timeout from the140 processing-time distribution, the duplicate-work window drawn as a sequence, heartbeat141 extension with its failure mode and its cap, and what to do instead of treating a lease as a142 lock. Read when setting or reviewing a visibility timeout, or when duplicate side effects143 appear with no retry in the code.144- [Worker loop and scaling](references/worker-loop-and-scaling.md) — a competing-consumer ownership145 protocol with bounded concurrency, lease heartbeat and drain-on-shutdown; the autoscaling146 signal against the wrong ones; priority with ageing; and a test that kills a worker mid-lease.147 Read before writing or reviewing a worker, or when deciding what the pool scales on.