Skip to content

Write amplification in the delivery path ​

This is the design deep-dive behind the 0.2.0 delivery path. It uses the numbers of the first measurement round; the final, interleaved 0.2.0 against 0.1.0 comparison is on Performance: 0.2.0 vs 0.1.0. To move a 0.1.0 installation, follow Upgrading from 0.1 to 0.2.

This document explains why the first version of relaybus (0.1.0) spent most of its database time rewriting delivery rows, what Phase 1 changed, which guarantees that change must not weaken and how each one is preserved, and what it measured.

The problem ​

A delivery is the unit of work: one row per (event, handler). In 0.1.0 a delivery row went through three writes after it was inserted:

  1. claim: UPDATE ... SET status = 'processing', attempts = attempts + 1, lease_owner, lease_token, lease_expires_at
  2. lease renewals while the handler runs: UPDATE ... SET lease_expires_at
  3. completion: UPDATE ... SET status = 'completed', completed_at, lease cleared

The table also carried eight indexes (due time, ordering keys, status, handler, purge). Several of them contain status or the due time, and those are exactly the columns the three updates change.

Dead tuples and HOT, in short ​

PostgreSQL never changes a row in place. An UPDATE writes a new version of the row and leaves the old version behind as a dead tuple that autovacuum removes later. Normally the new version also needs a new entry in every index of the table, because each index points at the physical location of a version.

There is one shortcut, a HOT (heap-only tuple) update. If the update changes no indexed column, and the new version fits on the same page, PostgreSQL chains the new version to the old one inside the page and adds no index entries: the indexes keep pointing at the first version of the chain. HOT updates are cheap and their dead versions are pruned from the page on the fly. A single update of an indexed column (here status) disables the shortcut for that update.

0.1.0 never got HOT updates, because every state change touched status or the due time. Measured on the baseline (benchmarks/results/main, one to eight dispatcher processes): 0% HOT updates, about 2.3 KB of WAL per delivery and a steady stream of dead tuples and dead index entries.

What one delivery leaves behind ​

benchmarks/pageinspect.ts publishes one event with one handler, claims it and completes it, then reads the heap page and every index with pageinspect (PostgreSQL 17, autovacuum off so the evidence stays). Before (the complete output is in pageinspect-before.txt):

text
heap page of relaybus_deliveries (3 line pointers)
  lp  xmin   xmax   ctid    flags
  1   775    776    (0,2)   XMIN_COMMITTED|XMAX_COMMITTED
  2   776    777    (0,3)   XMIN_COMMITTED
  3   777    0      (0,3)   XMAX_INVALID
indexes of relaybus_deliveries: 10
  relaybus_deliveries_completed_idx              entries=1  -> heap tids (0,3)
  relaybus_deliveries_due_idx                    entries=2  -> heap tids (0,1) (0,2)
  relaybus_deliveries_due_unkeyed_idx            entries=2  -> heap tids (0,1) (0,2)
  relaybus_deliveries_event_handler_unique       entries=3  -> heap tids (0,1) (0,2) (0,3)
  relaybus_deliveries_handler_created_idx        entries=3  -> heap tids (0,1) (0,2) (0,3)
  relaybus_deliveries_pkey                       entries=3  -> heap tids (0,1) (0,2) (0,3)
  relaybus_deliveries_status_created_idx         entries=3  -> heap tids (0,3) (0,1) (0,2)
tuple counters:
  relaybus_deliveries      inserted=1 updated=2 hot_updated=0 deleted=0

Three heap tuples where each points at the next through ctid (none is HEAP_ONLY or HEAP_HOT_UPDATED: no HOT), two of them dead, and 17 index entries for one delivery (6 at publish, 6 at claim, 5 at completion). Everything except the last tuple and its last entries is garbage that autovacuum has to find, and vacuuming the indexes costs more than vacuuming the heap.

Life of one delivery row, before and after

What changed ​

The idea comes from queue implementations that survived this problem (Oban, River, graphile-worker, pg-boss, PgQ): do not write the row to say who has it, and delete the row when it is done.

Lane leases ​

Every delivery gets a lane, a number from 0 to 255 computed from its ordering key (or from the event id when it has none). All deliveries of one key share a lane. Workers lease lanes, not rows.

Lane lease model

sql
CREATE TABLE relaybus_lanes (
  lane smallint PRIMARY KEY CHECK (lane >= 0 AND lane < 256),
  epoch bigint NOT NULL DEFAULT 0,          -- fencing token, +1 every time the lane changes hands
  lease_owner text,
  lease_expires_at timestamptz,
  in_flight text[] NOT NULL DEFAULT '{}'    -- ids of the deliveries the owner has claimed
) WITH (fillfactor = 50);
  • Claim (one short transaction): lock the lane rows this worker already holds, take free lanes that have due work (four, then eight, ... at a time, so a worker holds only as many lanes as its batch needs), read the due deliveries of those lanes, then write one lane row per lane to record the batch in in_flight. No delivery row is written and no row lock is taken on one.
  • Renew: extendLease updates the lane row, not the delivery.
  • Complete: delete the delivery rows. The dispatcher groups the completions of deliveries that finish within dispatcher.completionBatchWindow (2 ms by default) into one completeMany call, which is one statement (DELETE ... USING the fenced lane rows).
  • Fail (rare): reschedule and bury update the delivery (non-HOT, accepted) and remove it from in_flight.

The lane table has no secondary index and 50% free space per page, so every update of a lane row is heap-only. Measured for one claim and completion: relaybus_lanes inserted=256 updated=2 hot_updated=2 and, in the heap page, the chain HOT_UPDATED -> HOT_UPDATED|HEAP_ONLY -> HEAP_ONLY (pageinspect-after.txt):

text
heap page of relaybus_deliveries (1 line pointers)
  lp  xmin   xmax   ctid    flags
  1   782    784    (0,1)   XMIN_COMMITTED
indexes of relaybus_deliveries: 6        -- 3 of them hold the row, the others stay empty
  relaybus_deliveries_event_handler_unique       entries=1  -> heap tids (0,1)
  relaybus_deliveries_lane_due_idx               entries=1  -> heap tids (0,1)
  relaybus_deliveries_pkey                       entries=1  -> heap tids (0,1)
lane 218: the lane row this claim touched, versions of that row only
  lp  xmin   xmax   ctid    flags
  18  779    783    (3,56)  HOT_UPDATED|XMIN_COMMITTED|XMAX_COMMITTED
  56  783    783    (3,57)  HOT_UPDATED|HEAP_ONLY|XMIN_COMMITTED|XMAX_COMMITTED
  57  783    784    (3,57)  HEAP_ONLY|XMIN_COMMITTED
tuple counters:
  relaybus_deliveries      inserted=1 updated=0 hot_updated=0 deleted=1
  relaybus_lanes           inserted=256 updated=2 hot_updated=2 deleted=0

One heap tuple (deleted by the completion, not rewritten) and 3 index entries instead of 17. The lane row's updates added no index entry and are shared by every delivery of the batch.

Delete on complete ​

A completed delivery is deleted. listDeliveries({ status: 'completed' }) and statistics().completed are empty unless the store keeps a completion history (completionHistory: true): an append-only table, one insert per completion, no updates. Events are still removed by retention (retention.completed) once they have no deliveries left.

Schema without hot-path index churn ​

Indexes on deliveries now only cover columns that no happy-path statement writes:

indexserves
(event_id, handler) unique, primary keyidempotent publish, fenced single-row writes
(lane, available_at, id) WHERE status = 'pending'claim, statistics
(lane, handler, key, sequence) WHERE key IS NOT NULL AND status = 'pending'per-key ordering check, blocked-key claim
(updated_at, id) WHERE status = 'dead'dead-letter purge

status_created and handler_created (listing) and the unkeyed and processing partial indexes are gone. The 0.1.0 indexes also made the planner choose status_created for WHERE status = 'processing' AND id = ... updates (357 to 525 buffers per single-row UPDATE, against 18 to 50 through the primary key). Fenced writes now always go through the primary key (EXPLAIN of a 50-delivery completion: 308 buffers, 0.23 ms). Both the lane table (fillfactor = 50) and deliveries (autovacuum_vacuum_scale_factor = 0.02) carry storage parameters set by setup().

Events remember their handlers ​

Because the delivery row is gone after completion, events.handlers records every handler an event ever had a delivery for. A broker redelivery or a publish retry with the same event id adds deliveries only for handlers that are not listed, so a completed handler never runs twice (the RabbitMQ and Kafka inbox tests pin this). This costs one text[] on the event insert and no update.

Guarantees and how each one is preserved ​

guaranteehow it is preserved
Atomic publishUnchanged: events and deliveries are inserted in the caller's transaction.
Never lose an eventA delivery row exists from publish until its handler's completion is committed. A claim writes only to the lane. If the worker dies, the delivery is still a pending row, and the lane lease expires and is taken over, so it is redelivered. A completion deletes the row only after the handler returned (or in the handler's own transaction).
Per-handler retry and dead letterEach (event, handler) is its own row. reschedule and bury update that row exactly as before; a dead row is never claimed again; requeue moves it back with a fresh budget.
Per-key ordering in commit orderUnchanged on the publish side (the key counter row is locked until commit). On the claim side a keyed delivery is picked only if no earlier delivery of its handler and key is still a pending row (a running one is one) and none of its key is in flight; and a key lives in one lane, owned by one worker at a time.
At-least-onceAn expired lane lease is taken over by the next claimer, so everything its holder had in flight runs again. Completion is the only thing that removes a row.
Effectively-once for transactional handlerscomplete(lease, now, transaction) takes FOR KEY SHARE on the lane row inside the handler's transaction and deletes the delivery there. A takeover locks the lane FOR UPDATE, which conflicts, so it skips the lane until the transaction ends. (The weakest conflicting lock is used on purpose: the owner's own claim and lease renewals are plain updates of the lane row and must not wait for every completion in progress.) Either the handler's writes and the deletion commit together, or the takeover finds the delivery and redelivers it into a transaction whose first completion rolled back. Both cannot commit. Tested in lane-leases.integration.test.ts ("does not let a completion and a takeover both win").
Multi-process safety, fencingThe lease token is lane:epoch:claims. Every state change checks that the lane still has that epoch, still lists the delivery in in_flight, and that the delivery's claims counter (bumped whenever a claim ends) still equals the token's, so the lease of an older attempt cannot touch a newer one. A takeover bumps the epoch, so all writes of the previous holder return false. Two claimers never see the same lane: the claim takes free lanes with FOR UPDATE SKIP LOCKED.
Attempts and poison messagesThe claim does not write an attempt. The attempt is recorded when it ends in failure (reschedule/bury add one), and when a lane is taken over each delivery that was in its in_flight list is charged one attempt (a crash is a lost attempt). A crash loop therefore still counts and ends in the dead letters (contract test "keeps counting attempts of a poison delivery until it is dead-lettered"). A graceful release charges nothing.
Graceful shutdownAborted handlers are released (no attempt spent), then the dispatcher calls relinquish, which hands back every lane that runs nothing, so other workers do not wait for the lease to expire.
Inbox deduplicationevents.handlers outlives the deliveries (see above).

Measurements ​

Both columns come from the same harness (benchmarks/, including the exactness validation described in benchmarks/README.md), run on the same machine: the 0.1.0 baseline at commit e222e0a and Phase 1 at perf/phase-1. Every one of the 51 drain/mixed runs of each side is VALID: for every (event, handler) pair the handled set equals the published set (lost 0, unexpected 0), duplicates 0, per-key order violations 0, nothing left pending and no dead letters when the run finished.

Read with care: the machine is a laptop (Apple M4, Docker Desktop VM) that was shared with other work during these runs (load average around 8), so absolute numbers vary by tens of percent between runs and the two columns were not taken back to back. A short idle re-check of the default configuration (20k events, --quick) gave 1 instance, defaults: 2,215 -> 3,439 deliveries/s, and 4 instances, tuned: 5,970 -> 28,254, which is why the full-run row "1 inst, defaults" (2,165 -> 1,586) should be treated as noise. Quote ratios, not absolute values. "Dead tuples after" is n_dead_tup of the deliveries table right after the window: before it counts the two dead versions per delivery that updates leave, after it counts the one deleted tuple per delivery that autovacuum has not reached yet (autovacuum was on; runs are short).

Headline. PostgreSQL dispatch: up to 47,651 deliveries/s (8 dispatcher processes, tuned) against 3,958 before (12x); with one tuned process 2,631 -> 12,372 (4.7x). WAL per delivery 2,377-2,692 B -> 257-625 B (about 5-9x less), deliveries updated 0 (was 2 per delivery), lane updates 100% HOT (HOT updates on deliveries were 0% before and are not applicable now). MongoDB dispatch: 547-611 -> up to 10,422 deliveries/s (17x with 4 processes), oplog bytes per delivery 1,155 -> 198-266 and updates per delivery 1.0 -> 0.02-0.21.

Known regressions to track (not fixed in Phase 1): single-event publish on both stores is slower than 0.1.0 in these runs (PostgreSQL P=1 batch=1 1,350 -> 479 events/s in the full run, 867 -> 705 in the idle re-check; MongoDB large-batch publish about 0.2-0.4x), which is the cost of the extra events.handlers bookkeeping and of the claim-side work moving to publish time (lane hash, richer documents). They need a dedicated idle-machine investigation; the dispatch gains above dwarf them for any workload that processes what it publishes, but they are real.

pg: dispatch (scenario 2, 200k preloaded events, no-op handler) ​

configdeliveries/s (before -> after)WAL B/deliveryHOT upd % (deliveries / lanes)dead tuples afterstmts/delivery
1 inst, defaults (conc 10, batch 10, pool 10, poll 1s)2,165 -> 1,586 (0.7x)2,377 -> 6250.0 / 10074,678 -> 62,9041.21 -> 0.21
1 inst, conc 10, batch 501,784 -> 3,146 (1.8x)2,345 -> 5500.0 / 100128,285 -> 01.25 -> 0.21
1 inst, conc 50, batch 50, pool 202,696 -> 5,337 (2.0x)2,374 -> 3840.0 / 100123,533 -> 135,1821.06 -> 0.04
1 inst, conc 50, batch 200, pool 202,554 -> 4,263 (1.7x)2,372 -> 3880.0 / 100135,746 -> 160,0141.06 -> 0.04
1 inst, tuned (conc 200, batch 200, pool 40, poll 50ms)2,631 -> 12,372 (4.7x)2,517 -> 3600.0 / 9861,146 -> 142,1111.02 -> 0.01
1 inst, tuned+ (conc 400, batch 400, pool 64, poll 50ms)7,613 -> 14,825 (1.9x)2,432 -> 3850.0 / 99316,279 -> 110,4151.01 -> 0.01
4 inst, defaults (conc 10, batch 10, pool 10, poll 1s)2,411 -> 5,299 (2.2x)2,378 -> 4610.0 / 100161,297 -> 188,7011.25 -> 0.21
4 inst, conc 10, batch 504,053 -> 7,090 (1.7x)2,291 -> 4580.0 / 100337,389 -> 139,2031.23 -> 0.21
4 inst, conc 50, batch 50, pool 202,377 -> 5,426 (2.3x)2,529 -> 3870.0 / 99125,462 -> 169,6481.06 -> 0.04
4 inst, conc 50, batch 200, pool 206,727 -> 11,039 (1.6x)2,438 -> 3680.0 / 99313,921 -> 51,3111.05 -> 0.04
4 inst, tuned (conc 200, batch 200, pool 40, poll 50ms)7,338 -> 18,789 (2.6x)2,540 -> 2570.0 / 94308,922 -> 200,0001.02 -> 0.01
4 inst, tuned+ (conc 400, batch 400, pool 64, poll 50ms)2,486 -> 25,437 (10.2x)2,661 -> 2860.0 / 97141,181 -> 200,0001.01 -> 0.01
8 inst, defaults (conc 10, batch 10, pool 10, poll 1s)3,917 -> 7,019 (1.8x)2,361 -> 4570.0 / 100346,585 -> 156,4111.24 -> 0.21
8 inst, conc 10, batch 501,965 -> 4,390 (2.2x)2,444 -> 4600.0 / 100132,804 -> 177,2321.27 -> 0.21
8 inst, conc 50, batch 50, pool 202,517 -> 26,541 (10.5x)2,594 -> 3870.0 / 98178,156 -> 186,7851.06 -> 0.04
8 inst, conc 50, batch 200, pool 202,490 -> 26,811 (10.8x)2,605 -> 2800.0 / 99140,228 -> 200,0001.06 -> 0.04
8 inst, tuned (conc 200, batch 200, pool 40, poll 50ms)3,958 -> 47,651 (12.0x)2,601 -> 2570.0 / 92332,611 -> 200,0001.02 -> 0.01
8 inst, tuned+ (conc 400, batch 400, pool 64, poll 50ms)2,069 -> 30,332 (14.7x)2,692 -> 2850.0 / 95159,433 -> 200,0001.01 -> 0.01

pg: publish only (scenario 1) ​

configevents/spublish call p50 / p99 msWAL B/event
P=1 batch=1 pool=default1,350 -> 479 (0.4x)0.40 / 0.9 -> 0.98 / 4.91,768 -> 1,485
P=1 batch=100 pool=default15,507 -> 14,733 (1.0x)5.28 / 11.8 -> 5.18 / 13.51,797 -> 1,487
P=8 batch=1 pool=default3,617 -> 2,604 (0.7x)0.94 / 2.3 -> 1.48 / 4.01,779 -> 1,507
P=8 batch=100 pool=default48,167 -> 29,593 (0.6x)9.99 / 25.7 -> 14.72 / 84.81,905 -> 1,545
P=32 batch=1 pool=default3,737 -> 2,339 (0.6x)1.13 / 2.9 -> 2.14 / 5.61,776 -> 1,503
P=32 batch=100 pool=default56,807 -> 42,987 (0.8x)11.48 / 29.5 -> 15.02 / 43.51,917 -> 1,551
P=8 batch=1 pool=103,343 -> 1,732 (0.5x)0.95 / 2.1 -> 2.22 / 6.41,779 -> 1,518
P=8 batch=100 pool=1059,633 -> 47,720 (0.8x)9.13 / 21.1 -> 12.19 / 29.01,897 -> 1,538
P=32 batch=1 pool=345,135 -> 3,227 (0.6x)2.34 / 7.6 -> 3.55 / 15.91,773 -> 1,510
P=32 batch=100 pool=3458,727 -> 33,987 (0.6x)35.69 / 93.7 -> 53.67 / 309.21,992 -> 1,585
ceiling 4 procs x 8 publishers batch=14,789 -> 3,046 (0.6x)2.38 / 9.1 -> 3.66 / 17.41,773 -> 1,506
ceiling 4 procs x 8 publishers batch=10048,080 -> 35,167 (0.7x)43.12 / 142.3 -> 54.75 / 238.81,988 -> 1,593

pg: mixed steady state (scenario 5), end-to-end commit-to-handler latency ​

configoffered/sachieved/s (before -> after)e2e p50 / p95 / p99 ms beforee2e p50 / p95 / p99 ms afterbacklog max (before -> after)
1000/s, defaults dispatchers1,0001,000 -> 1,000503.6 / 952.0 / 1,000.52,262.5 / 7,034.2 / 8,085.7854 -> 4,731
5000/s, defaults dispatchers5,0003,383 -> 2,62717,053.7 / 39,731.6 / 42,175.911,684.4 / 33,548.4 / 40,128.9142,912 -> 91,608
1000/s, tuned dispatchers1,000981 -> 1,00012.4 / 89.2 / 186.215.8 / 148.1 / 297.2128 -> 197
5000/s, tuned dispatchers5,0001,897 -> 2,14489.2 / 285.6 / 396.6127.6 / 442.5 / 658.8703 -> 627
10000/s, tuned dispatchers10,0002,149 -> 2,42139.4 / 90.9 / 131.4128.8 / 479.1 / 764.8148 -> 906

mongo: dispatch (scenario 2, 200k preloaded events, no-op handler) ​

configdeliveries/s (before -> after)oplog B/deliveryoplog entries/deliveryjournal B/deliveryops/deliveryupdates/delivery
1 inst, defaults (conc 10, batch 10, pool 10, poll 1s)547 -> 1,069 (2.0x)1,157 -> 266- -> 0.311,445 -> 2272.8 -> 1.01.01 -> 0.21
1 inst, conc 50, batch 50, pool 20597 -> 3,359 (5.6x)1,155 -> 219- -> 0.181,431 -> 1422.2 -> 0.21.00 -> 0.08
1 inst, tuned (conc 200, batch 200, pool 40, poll 50ms)609 -> 5,621 (9.2x)1,155 -> 198- -> 0.121,419 -> 1082.1 -> 0.11.00 -> 0.02
4 inst, defaults (conc 10, batch 10, pool 10, poll 1s)590 -> 2,901 (4.9x)1,156 -> 262- -> 0.301,424 -> 2105.6 -> 1.01.00 -> 0.20
4 inst, conc 50, batch 50, pool 20452 -> 7,721 (17.1x)1,155 -> 223- -> 0.201,419 -> 1465.1 -> 0.31.00 -> 0.09
4 inst, tuned (conc 200, batch 200, pool 40, poll 50ms)611 -> 10,422 (17.1x)1,155 -> 199- -> 0.131,414 -> 1105.0 -> 0.11.00 -> 0.03

mongo: publish only (scenario 1) ​

configevents/spublish call p50 / p99 msoplog B/event
P=1 batch=1 pool=default454 -> 360 (0.8x)1.12 / 3.0 -> 1.06 / 3.71,294 -> 1,351
P=1 batch=100 pool=default9,693 -> 2,453 (0.3x)7.48 / 16.1 -> 26.48 / 92.81,060 -> 1,109
P=8 batch=1 pool=default2,021 -> 830 (0.4x)2.01 / 4.8 -> 3.55 / 16.91,300 -> 1,348
P=8 batch=100 pool=default37,587 -> 13,200 (0.4x)13.20 / 43.5 -> 37.51 / 123.81,056 -> 1,107
P=32 batch=1 pool=default2,813 -> 1,839 (0.7x)4.93 / 14.3 -> 6.77 / 21.91,301 -> 1,353
P=32 batch=100 pool=default30,140 -> 5,293 (0.2x)65.49 / 248.4 -> 297.18 / 1,781.81,062 -> 1,098
ceiling 4 procs x 8 publishers batch=11,802 -> 1,083 (0.6x)6.91 / 29.2 -> 5.95 / 29.21,286 -> 1,342
ceiling 4 procs x 8 publishers batch=10025,293 -> 17,633 (0.7x)72.34 / 318.6 -> 105.58 / 377.31,059 -> 1,107

mongo: mixed steady state (scenario 5), end-to-end commit-to-handler latency ​

configoffered/sachieved/s (before -> after)e2e p50 / p95 / p99 ms beforee2e p50 / p95 / p99 ms afterbacklog max (before -> after)
1000/s, defaults dispatchers1,0001,000 -> 1,00015,906.3 / 27,222.2 / 27,769.41,581.3 / 3,141.8 / 3,720.927,750 -> 2,080
1000/s, tuned dispatchers1,0001,000 -> 1,00020,398.8 / 31,920.2 / 32,887.4168.5 / 513.7 / 1,083.433,814 -> 594
5000/s, tuned dispatchers5,0001,405 -> 1,65826,160.0 / 45,218.2 / 45,218.23,019.3 / 6,895.6 / 8,414.063,928 -> 7,541

MongoDB ​

The MongoDB store had the same problem in another form: every claim was one conditional findOneAndUpdate per delivery (plus per-key reads), every completion one updateOne per delivery, and each of them rewrote indexed fields (status, dueAt, lease fields): about 3 oplog entries and 3 index rewrites per delivery and roughly 2.5 round trips. The 0.1.0 baseline tops out near 600 deliveries/s for one dispatcher, many times slower than PostgreSQL.

Phase 1 gives MongoDB the same model, adapted to what MongoDB can do atomically:

  • Lane documents. A <prefix>lanes collection holds 256 documents { _id: lane, epoch, owner, holder, leaseExpiresAt, inFlight, lost }. Deliveries carry a laneId (FNV-1a hash of the ordering key or event id, modulo 256). The existing lane: 'ready' | 'parked' field of a delivery (claim-index membership) is unchanged; laneId is the lease partition.
  • Claim without delivery writes. A claim reads the candidates of the lanes it holds (and, a few at a time, free lanes with due work), checks the per-key rules, and records the batch with one conditional bulkWrite on the lane documents. No delivery document is written, so no indexed field of a delivery changes on the happy path. The lease token is <laneId>:<epoch>.
  • Completion. completeMany deletes the delivery documents of a batch in one transaction (and inserts the optional completed_deliveries history documents). There is no per-delivery update.
  • Fencing without a lane write on completion. MongoDB transactions use snapshot isolation, so two completions in one lane that both wrote the lane document would conflict with each other and transactional handlers would re-run. Instead a takeover moves the lane's inFlight ids into lost in the very write that bumps the epoch, and then charges each of them one attempt; a completion deletes the same delivery documents the charge updates. A completion and a takeover therefore write the same documents and exactly one of them commits; the other conflicts, retries, finds the new epoch and reports the lease as lost (tested, including a completion that started before a takeover). Concurrent completions of one lane do not conflict.
  • Attempts. Charged when an attempt ends (reschedule, bury) or when a lane is taken over (a crash). If the process dies between the epoch bump and the charge, the ids stay in lost and the next holder charges them first; a crash after the charge and before lost is cleared charges one extra attempt (a documented limitation).
  • Restarted workers. Lanes record the store instance (holder) that owns them, so a restarted process that reuses its leaseOwner string does not adopt its dead predecessor's live lanes; it takes them over after the lease expires.
  • Failure handling. A failure after the batch was recorded gives it back to the lane; parking of blocked followers is best effort (logged through the logger option); after recording, each keyed pick is re-checked against requeued predecessors and given back if it was overtaken.
  • Events carry handlers (a completed handler never runs again for a redelivered event) and createdAt (orphan event sweep).
  • Upgrade. setup() converts a 0.1.0 database in place, repeatably: processing deliveries become pending with their attempts kept, lease fields are removed, laneId and the events' handlers are backfilled in batches, obsolete indexes are dropped.

The measured effect on oplog entries and bytes per delivery is in the MongoDB tables above. Keyed deliveries still pay per-key reads (an unfinished-predecessor check per head); the store parks long backlogs of one key out of the claim index so a deep queue behind one slow delivery costs nothing per claim.

Upgrading from 0.1.0 ​

The step-by-step guide is Upgrading from 0.1 to 0.2. The mechanics:

MongoDB is upgraded by setup() as well (see above). PostgreSQL:

setup() (run by bus.start() unless setupStore: false) upgrades a 0.1.0 database in place, inside the same advisory-lock transaction as a fresh install, and can be run any number of times. PostgreSQL, in order:

  1. The upgrade runs only when the deliveries.lease_token column still exists (the marker of a 0.1.0 table). It adds events.handlers and fills it from the existing deliveries (completed ones included, so nothing completed under 0.1.0 runs again), adds deliveries.lane and fills it with the same hash new deliveries use, turns processing deliveries into pending (their attempt stays counted, exactly as a lost lease would count it), and drops lease_owner, lease_token and lease_expires_at.
  2. The 0.1.0 indexes that no longer exist are dropped (DROP INDEX IF EXISTS), the new ones are created (CREATE INDEX IF NOT EXISTS), the 256 lane rows are seeded (ON CONFLICT DO NOTHING), and the storage parameters are set.
  3. Deliveries that were completed stay as completed rows and keep being listed, counted and purged by retention.completed until they are gone. New completions are deleted.

What to plan for:

  • Stop every 0.1.0 worker first. They would fail on the dropped columns. Running two versions side by side is not supported.
  • One-off rewrite. Step 1 updates every delivery row once. Purge old completed deliveries before upgrading a large table (bus.purgeExpired() with a short retention.completed), so there is less to rewrite.
  • Your own migration tool. createPostgresSchemaSql() renders the same statements as one script. Apply it in one transaction with the workers stopped, then start the new version with setupStore: false.
  • Rollback. There is no down migration: take a backup (or run the upgrade in a transaction you can roll back during rehearsal). Going back to 0.1.0 needs a restore.
  • Test coverage. The upgrade is tested against the verbatim 0.1.0 DDL with pending, processing, completed and dead keyed and unkeyed rows, run twice (packages/postgres/test/lane-leases.integration.test.ts).

Trade-offs and operations ​

What got worse, and what to do about it:

  • Completed deliveries are not listable by default. listDeliveries({ status: 'completed' }) and statistics().completed are empty. Turn on completionHistory (PostgreSQL, MongoDB) or retainCompleted (in-memory store) if you need an audit trail. It costs one extra insert per completion, so keep it off on hot paths.
  • Lane exclusivity. While a worker holds a lane under a live lease, other workers do not see its pending deliveries, even if they are idle. A worker that crashes delays its lanes by the lease duration (dispatcher.leaseDuration, 30 s by default); a worker that stops gracefully gives them back at once. Lanes are taken per claim and handed back when they run nothing, so a backlog spreads over workers. A few hot ordering keys live in few lanes and therefore run on few workers, which is what ordering means anyway.
  • Deleting still leaves dead tuples. A delete leaves one dead tuple per delivery, not three, and no dead index entries from updates; autovacuum still has to reclaim them. The tables are created with autovacuum_vacuum_scale_factor = 0.02. For sustained thousands of deliveries per second, lower autovacuum_vacuum_cost_delay on the table or schedule a VACUUM, and alert on n_dead_tup.
  • Retry and dead-letter updates are not HOT. reschedule and bury update available_at and status, which are indexed. They are the rare path by design. requeue is an admin action.
  • Admin listings are scans. listDeliveries has no dedicated index any more (it used to have two). It orders by created_at, so on a large table filter by status or handler, or add the index you need.
  • Failure accounting moved. An attempt is charged when it ends or when a lane is taken over, not at claim. A worker that dies is charged at takeover, after the lease expired; between the crash and the takeover listDeliveries shows the deliveries as processing.
  • Events outlive their deliveries. Events (and their handlers list) are deleted by retention, not at completion; keep retention.completed longer than your longest broker redelivery window. After a purge a redelivery is a new event.
  • A claim is more than one statement in the shallow-queue case. When the held lanes do not fill the batch, the claim continues in a short transaction to take free lanes. With a deep backlog it is one statement.
  • Fixed lane count. 256 lanes are enough for any realistic number of workers; the count is stored in each delivery, so it is not a setting.
  • Schema change. Mixed 0.1.0 and new workers are not supported; upgrade is a stop-the-world migration (see above).

Not part of Phase 1 (neither implemented nor measured here), candidates for a next phase: wake-up of idle dispatchers across processes with LISTEN/NOTIFY (a latency hint only, polling stays the correctness path), a single-statement publish that avoids the per-delivery foreign-key row lock on events, and a UNLOGGED or partitioned delivery table.

How to reproduce ​

The numbers on this page come from the first measurement round, taken with the earlier harness (benchmarks/run.ts, drain to completion, one sample per cell). That harness was replaced by the interleaved, fixed-window one that produced the final comparison. To reproduce current numbers:

sh
pnpm install --frozen-lockfile
pnpm bench:quick                              # HEAD against main, about 8-10 minutes
pnpm bench:full --base v0.1.0 --candidate v0.2.0 --interleave

benchmarks/README.md describes the harness (every publisher and dispatcher is its own Node process, databases run in Testcontainers, retention is disabled so no purge interferes). The numbers above come from a MacBook (Apple M4, 10 cores, 16 GB) running Docker Desktop, with the Node workers and the databases sharing the same cores: read the ratios, not the absolute values. The old pageinspect.ts script is in the git history before the harness rework.

Released under the MIT License.