Work Queues, RPC and Publish-Subscribe

Topics Covered

The Work Queue, and What It Actually Guarantees

The shape, in terms of the primitives

What the pattern does not guarantee

How a message is chosen for a consumer

Where the acknowledgement mode changes everything

Competing consumers against partitioned consumers

Sizing the consumer pool

The work queue is the simplest pattern the broker supports and the one most often built with a guarantee it does not provide.

The shape, in terms of the primitives

One queue, several consumers, and the distribution rule that follows.

A work queue is a single queue with more than one consumer attached. The broker distributes each message to exactly one of them, in a round-robin order weighted by which consumers have capacity under their prefetch window. There is no exchange decision involved: every message in the queue is a candidate for every consumer, so the pattern needs only a default-exchange publish with the queue name as the routing key.

The property people want from this is load balancing, and the broker's version of it is narrower than that phrase suggests. The broker balances message count under the prefetch window, not work. A consumer with basic.qos(prefetch_count=100) holding ninety-nine cheap messages and one expensive one has a full window and receives nothing more, while an idle consumer with a window of 1 receives one message at a time and is starved of the chance to help.

So the distribution quality is set by the prefetch value, and the correct value is governed by the variance of the work rather than its average. A workload where every message takes 50 milliseconds distributes well at any prefetch; a workload mixing 10-millisecond and 10-second messages distributes well only at a prefetch of 1, which costs a round trip per message. That tension is the pattern's single real design decision.

What the pattern does not guarantee

Four things, each of which someone has assumed.

It does not guarantee order. With multiple consumers, two messages published in sequence are delivered to different consumers that finish in an unpredictable order, so any ordering requirement is violated by the pattern's defining property. A work queue with one consumer preserves order and is no longer a work queue.

It does not guarantee exactly-once processing. A message is redelivered when its consumer dies before acknowledging, and a consumer that dies after the work but before the ack has done the work twice. At-least-once is the only guarantee available, which makes idempotence a requirement rather than a refinement.

It does not guarantee fairness across publishers. A single publisher that floods the queue occupies the consumers, and the broker has no notion of which publisher a message came from, so nothing throttles one tenant in favour of another. A queue per tenant with consumers across all of them is the only way to get that, and it is a different pattern.

And it does not guarantee that a slow consumer is bypassed. A consumer that accepts a message and then takes an hour holds that message for an hour; the broker will not reassign it, because from the broker's position an unacknowledged message is being worked on. consumer_timeout, default 30 minutes in current versions, is the only mechanism that eventually intervenes, and it does so by closing the channel.

The broker balances message count under the prefetch window, not work. A consumer holding ninety-nine cheap messages and one expensive one has a full window, so distribution quality is set by the prefetch value and governed by the variance of the work.

How a message is chosen for a consumer

The dispatch loop, which is simpler than the behaviour suggests.

The queue process holds a list of attached consumers and a position in that list. When a message becomes ready it walks the list from the current position, offers the message to the first consumer whose unacknowledged count is below its prefetch limit, and advances the position. A consumer at its limit is skipped without penalty and is offered the next message that arrives after it acknowledges.

Two consequences follow from the walk being positional rather than scored. The first is that consumers are not offered work in proportion to their capacity: a consumer with a window of 100 and a consumer with a window of 1 attached to the same queue are visited equally often, and the large-window consumer accumulates a backlog only because it keeps accepting. The second is that there is no attempt to find the least-loaded consumer, because doing so would require a comparison across consumers on every message, and the queue process is a single Erlang process whose per-message cost is the thing the broker most wants to keep small.

That design choice is why prefetch is the only control available. The broker will not balance for you, and an operator who wants even distribution gets it by making every consumer's window small enough that accepting a message is close to a statement that the consumer is free.

Where the acknowledgement mode changes everything

Three modes, and two of them are not really work queues.

With auto_ack set, the broker considers a message delivered the moment it is written to the socket. There is no unacknowledged count, so the prefetch window does not apply and the queue pushes messages as fast as the socket accepts them. That maximises throughput and means a consumer crash loses every message buffered in its client library, which on a fast publisher can be tens of thousands. It is a correct choice for metrics and for anything whose loss is cheaper than its delivery.

With manual acknowledgement and a prefetch window, the arrangement described above holds, and this is the only configuration that is a work queue in the sense people mean. With manual acknowledgement and no prefetch set at all, the window is unlimited, so the first consumer to attach is offered the entire queue, which looks like a work queue and distributes nothing. That last case is the most common misconfiguration in the pattern, and it is invisible under light load because a queue that never has two ready messages at once cannot demonstrate it.

The reading an operator should take from this is that a work queue's behaviour is a property of the consumer's configuration rather than of the queue's. Nothing about the queue declaration says how work is distributed, which means the distribution cannot be reviewed by looking at the topology.

Competing consumers against partitioned consumers

Two arrangements, and the one people mean when they say work queue.

Competing consumers is the arrangement above: all consumers eligible for all messages, maximum utilisation, no ordering. Partitioned consumers is the alternative, in which messages are routed by some key to one of several queues and each queue has a single consumer, giving per-key ordering at the cost of per-key throughput and of hot-partition risk.

RabbitMQ supports the second through a consistent-hash exchange or through explicit routing keys, and it is the arrangement Kafka provides by default, which is a useful way to hold the difference: Kafka's partitions are partitioned consumers and RabbitMQ's queue is competing consumers, and each can be made to imitate the other with effort.

Choosing between them is a question about whether the work has per-entity ordering requirements. Updating a user's balance does; sending a notification does not. The failure mode of choosing competing consumers for ordered work is subtle, because it is correct under light load when messages happen to complete in order and incorrect under load, which is the worst possible distribution of failures.

Sizing the consumer pool

The arithmetic, and why the answer is usually smaller than expected.

The pool size needed is the arrival rate times the service time, by Little's law: 500 messages per second each taking 200 milliseconds needs 100 concurrent workers to keep up with no backlog, and more than that to drain one. That number is the floor, and running below it guarantees unbounded growth regardless of any other tuning.

What makes the real answer smaller is that a worker is usually not CPU-bound. A worker spending 190 of its 200 milliseconds waiting on a database can be one of many concurrent tasks in a single process, so 100 concurrent workers may be four processes with 25 tasks each rather than 100 processes. The concurrency is what matters and the process count is an implementation detail, and conflating them produces either a wildly over-provisioned deployment or a connection count that exhausts the broker.

The cap on all of it is usually downstream. If the database behind the workers handles 200 writes per second, adding workers past that point converts a broker backlog into a database queue, which is strictly worse because the broker is designed to hold a backlog and the database is not. The pool should be sized to the downstream limit and the broker left to absorb the difference, which is the division of labour the pattern exists for.

Required concurrency is arrival rate times service time, which is a floor rather than a target. The useful ceiling is the downstream limit, past which adding workers converts a broker backlog into a database queue.
Key Insight

The broker balances message count under the prefetch window rather than work, so distribution quality is governed by the variance of the service time and not its average. A workload where every message takes 50 milliseconds distributes well at any prefetch; one mixing 10-millisecond and 10-second messages distributes well only at a prefetch of 1, which costs a round trip per message. That tension is the work queue's single real design decision.