Queen is a queue with two halves, and knowing which half you are looking at explains most of its behaviour.
The Kafka half is the log. A queue is split into partitions; each message gets a monotone integer offset inside its partition; a consumer group owns one cursor per partition, so groups read the same data independently and a group can be moved backwards to re-read history.
The RabbitMQ half is the work queue. A consumer claims a batch under a lease with an expiry and then reports each message as completed, failed, retried or dead-lettered; a message that exhausts its retry budget is copied into a real dead-letter table; and consumers that name no group compete for the same messages.
Where the two halves would contradict each other, the log wins: progress is recorded as a cursor, not as per-message state, so “this one message is still in flight” is not a thing the system can represent.
Neither half involves the client in coordination. The broker hands out no partition assignments, there is no rebalancing protocol, and a consumer that dies is recovered by its lease expiring rather than by a group membership change.
flowchart LR subgraph Q["queue: orders"] direction TB P1["partition customer-1<br/>offsets 0,1,2,3"] P2["partition customer-2<br/>offsets 0,1"] P3["partition customer-3<br/>offsets 0,1,2"] end PR["producers"] --> Q Q --> G1 Q --> G2 subgraph G1["consumer group: billing"] direction TB C1["cursor per partition<br/>1 / 0 / 2"] end subgraph G2["consumer group: audit"] direction TB C2["cursor per partition<br/>3 / 1 / 0"] end
One queue, one ordered lane per entity, and one independent cursor per lane per group. The two groups above have both read the same messages and are simply at different positions. Nothing is copied for the second group, and neither group can hold the other up.
The vocabulary
| Term | What it is |
|---|---|
| Queue | A named container with a configuration. Created implicitly by the first push to it, or explicitly with POST /api/v1/configure. |
| Partition | An ordered lane inside a queue, created the first time you push to its name. The default name is Default. One row in queen.log_partitions. |
| Offset | A per-partition BIGINT, allocated monotonically under that partition’s row lock. It is not global, not a timestamp, and not comparable across partitions. |
| Segment | One row in queen.log_segments holding many messages as length-prefixed frames, packed and zstd-compressed, covering an inclusive offset range. It is the unit of storage, of retention, and of a timestamp seek. |
| Consumer group | A named reader of a queue. Each group has its own cursor per partition. Omitting a group puts you in the implicit group __QUEUE_MODE__. |
Cursor (committed) |
The last acked offset for one (partition, group), in queen.log_consumers. Everything at or below it is done; the next message the group wants is committed + 1. |
| Lease | A claim on the span (committed, batch_end] for one (partition, group), carrying a worker id (the leaseId you echo back on an ack) and an expiry. |
| Ack | An offset commit that moves committed. On the wire it is addressed by transactionId plus partitionId, not by offset. |
transactionId |
The idempotency key of a push, and the address of a message in an ack. You supply it; if you omit it the broker uses the message id it minted. |
| Namespace and task | Two labels on a queue. They are what a discovery pop and the namespace/task listings match on. |
| DLQ | queen.log_dlq, a real table. A message whose retry budget runs out on a DLQ-enabled queue is copied there with its payload and the failure reason. |
There is no per-message row anywhere in that list. Consumption state for a whole partition is one row per group holding one integer, which is why ten thousand partitions cost ten thousand rows rather than ten thousand files or processes.
How a message moves
push -> queue "orders"
partition "cust-42" offsets: 0 1 2 3 4 5
segments: [ 0 .. 2 ][ 3 .. 5 ]
group "billing" committed = 2 -> next wanted = 3
group "audit" committed = -1 -> next wanted = 0A push to orders / cust-42 locks that partition’s row, allocates the next offsets, writes
one segment row, and commits. created_at is stamped while the lock is held, so it is
monotone per partition in commit order. Time-based retention and timestamp subscriptions
depend on that.
A pop by group billing claims from committed + 1 forward, up to the batch budget, and
takes a lease on what it delivered: batch_end = 5, span (2, 5]. Group audit is
unaffected; its cursor is a different row.
An ack for the message at offset 5 with status completed sets committed = 5. Offsets 3 and
4 are now also done, whether or not you acked them, because the cursor is the only record of
progress. If the consumer dies instead, the lease expires and the next pop for billing on
that partition re-delivers from offset 3.
The five invariants
- Order is per partition, in commit order.
log_partitions.last_offsetis bumped under that row’sFOR UPDATElock, and at most one write per partition is in flight at a time, so offset order equals commit order. There is no global ordering across partitions. - One in-flight leased batch per (partition, group).
queen.log_consumershas one row per pair, with onebatch_endand oneworker_id. A second consumer in the same group asking for the same partition gets nothing until the lease is acked or expires, so parallelism inside a group is bounded by the number of partitions, not by the number of workers. - An ack is an offset commit. Acking message N completes every earlier unacked message in
that partition for that group. You cannot complete offset 7 while leaving 5 outstanding for
the same group; when one call carries mixed outcomes the cursor clamps at the lowest signal,
and at equal offset
dlqbeatsfailedbeatsretry. - Deduplication is exact, per partition, and windowed. A repeated
transactionIdinsidededupWindowSeconds(default 3600, enabled) is rejected before an offset is allocated, so a duplicate writes nothing and comes back asstatus: "duplicate". The same key on a different partition is not a duplicate. - Delivery is at-least-once. A leased pop plus an explicit ack can redeliver but cannot
lose. Setting
autoAck=trueon a pop commits the cursor inside the pop transaction, which is at-most-once: a consumer that crashes after the response has already been marked done.
What the model does not have
- No global ordering. If your correctness argument needs a total order over more than one partition, Queen will not give it to you.
- No priority queueing of any kind. A partition is drained from its cursor forward.
- No per-message delivery state. There is nothing to clean up, and nothing to inspect that would tell you “message 4 is in flight” independently of its neighbours.
- No client-side assignment or rebalancing. Which is why a restart is cheap, and also why you cannot pin a worker to a partition and rely on the broker to keep it there.
- No bulk DLQ requeue. The dead-letter queue is readable (
GET /api/v1/dlq), and a single address can be deleted or replayed:POST /api/v1/messages/:partitionId/:transactionId/retryre-pushes the stored payload snapshot under a freshtransactionIdand then drops the DLQ row. Nothing drains a whole DLQ in one call.
Where to go next
Queues, partitions, namespaces and tasks
What exists after a push, what a partition costs, and how discovery finds a queue.
Producing
The push request, its per-item verdicts, and deduplication.
Consuming
Groups, subscription modes, long-poll, and the leased-batch limit.
Reference
Every route with its access level, every environment variable with its default.