Ordering
Copy page
This page states which messages QueueBox delivers in order and which it does not.
The inbox orders by aggregate_id.
The outbox orders by key.
Nothing orders two different aggregates or two different keys.
Order and the aggregate
Section titled “Order and the aggregate”The inbox aggregate_id column is the unit of order on the inbox side.
Both consumption modes hold one message of an aggregate in flight at a time.
The two modes scope that reservation differently, so do not treat them as the same rule.
- In push mode, the relay reserves one in-flight message per
aggregate_idacross every source together. The relay is one process that reads every push row, so its claim has no source term. - The relay forwards the messages of one aggregate in the order of
created_at. - In pull mode, the claim reserves one in-flight message per
(source, aggregate_id), because a pull worker binds to one source. The claim never returns a message whose aggregate already holds a message in stateprocessingunder a live lease on that source. The rule holds across every worker instance of that source, because it lives in the claim statement. - A pull claim on one source never blocks a pull claim of the same aggregate on a different source.
A message of aggregate
Aon sourceordersand a message of aggregateAon sourcepaymentscan run at the same time. - QueueBox does not serialize the claim of two different aggregates.
- A row with no
aggregate_idtakes part in no ordering.
Set the aggregate identifier with aggregateIdPath on the source.
A broker source can also read it from the x-aggregate-id header.
# fragmentsources: orders: type: http path: /orders idempotencyKeyPath: $.eventId eventTypePath: $.type aggregateIdPath: $.orderIdHow the relay keeps the aggregate order
Section titled “How the relay keeps the aggregate order”- The claim excludes an aggregate that already has a message in state
processing. - The relay claims and forwards one message at a time. The outbox poller publishes with the configured concurrency, so parallel work happens at delivery, not at forwarding.
- A database lock serializes the relay claim across every replica. See Claims and leases.
- The claim fence stops a replica that lost its claim from forwarding a second copy. Two replicas therefore never write one aggregate into the outbox twice.
Guarantee. At most one message per aggregate is in state processing at any time, across every replica.
The relay forwards the messages of one aggregate in creation order.
The relay writes the aggregate_id into the outbox key.
The outbox then delivers the rows of one key in insert order.
The order of one aggregate therefore holds from the inbox to the destination.
Order and the key
Section titled “Order and the key”The outbox key column is the unit of order on the outbox side.
It is the inbox aggregate rule, applied to the delivery to a destination.
- The outbox delivers the rows of one key in insert order, at any
outbox.concurrencyand with any number of replicas. - The database fills the
sequencecolumn on insert. The claim orders the rows of one key bysequence, not bycreated_at, so rows that one transaction writes keep their insert order. - The claim takes a row of a key only when no earlier row of that key is
pendingorprocessing. One row of a key is in flight at a time. - A row of a key that waits for a retry holds back the later rows of its key until the row is sent or dead.
- A dead row releases its key, and the poller delivers the next row of the key. One poison row does not stall a stream.
- A replay of a dead row delivers it after the rows that passed it.
- A row with a null or empty
keytakes part in no ordering. The poller publishes it in parallel with other rows. - QueueBox does not order two different keys.
BEGIN;INSERT INTO outbox (topic, key, payload) VALUES ('order.created', 'order-42', '{"step":1}');INSERT INTO outbox (topic, key, payload) VALUES ('order.paid', 'order-42', '{"step":2}');INSERT INTO outbox (topic, key, payload) VALUES ('order.shipped', 'order-42', '{"step":3}');COMMIT;The three rows above reach the destination in the order 1, 2, 3.
One writer per key
Section titled “One writer per key”The rule holds for rows that one writer inserts per key.
Two transactions that insert rows of the same key at the same time can commit out of sequence order.
A row that commits late can then arrive after a row with a higher sequence.
An event store that checks the stream version on append has one writer per stream, so the rule holds for it.
Throughput of one key
Section titled “Throughput of one key”One row of a key in flight limits the throughput of that key to one publish round trip per row. Spread a busy stream over more keys only when the consumer does not need order across them.
Claim order, not commit order
Section titled “Claim order, not commit order”A poller delivers in claim order, not in commit order.
This is a difference from a capture tool that reads the database log.
A row can take its identifier or its sequence before another row and still commit after it.
A reader that needs commit order must not derive it from the identifier.
Order inside one aggregate or one key is the guarantee that QueueBox gives. It is enough for the outbox pattern, because one aggregate has one writer.
The SQL Server pull claim serializes per source
Section titled “The SQL Server pull claim serializes per source”On SQL Server, the pull claim takes an exclusive application lock on the source name. Every claim on one source therefore runs one at a time. The per-aggregate reservation still works under this lock.
- Two handlers overlap only when a handler takes longer than the claim round trip. A short handler can look fully serialized.
- Measurement gives a ceiling near 110 to 140 claims per second per source. More workers on one source do not raise that ceiling.
- Scale a busy SQL Server source with more source names, not with more workers on one source.
PostgreSQL does not have this limit.
Its claim uses SKIP LOCKED, which never blocks a concurrent claim, so workers on one source divide the work.
Size worker counts per database.
A PostgreSQL sizing plan does not carry over to SQL Server, and the reverse is also true.
The claim transaction on SQL Server
Section titled “The claim transaction on SQL Server”The SQL Server claim statement opens its own transaction and commits it. It rolls back on the lock-failure path. Keep the claim alone in that transaction:
- Put no other application work in the claim transaction.
- Commit the claim before any handler runs.
A caller that breaks these rules leaves the claim uncommitted until its wider transaction commits. It loses the whole wider transaction on a lock failure. It also holds the per-source lock for the rest of that wider transaction. The C#, Go and TypeScript client libraries follow these rules.
The driver timeout on SQL Server
Section titled “The driver timeout on SQL Server”Set the driver request or command timeout to at least 30 seconds. The application lock of the claim times out at 10 seconds and raises Msg 51000. A shorter driver timeout aborts the call before the server raises Msg 51000. The abandoned transaction then holds the per-source lock until the connection resets, and every claim on that source stalls.