ADR-023: Receipt Plane Ring Partitioning¶
Status¶
Accepted (implemented). lease_claim_receipts is the default in 0.6. The validation artifact under docs/adr/bench/ is the empirical evidence the design holds; this ADR is the architectural record.
Evolution (0.7, #246). The original design routed deadline-backed claims through row-local lease_claims (so the deadline-rescue cursor could stay index-backed) and reserved lease_claim_batches.deadline_at for a later compact-deadline path. That path is now the default: every job claimed in one claim_ready_runtime call shares one claimed_at, hence one deadline_at, so a deadline-backed claim writes the same compact lease_claim_batches row as a zero-deadline claim, with the batch's deadline_at populated. At 256-worker saturation this removed the ~83k steady-state live lease_claims rows the row-local path materialized (and the e2e p99 regression they caused). Deadline rescue gains a second per-slot cursor over (deadline_at, batch_id) on claim_ring_slots (index-backed by a partial (deadline_at, batch_id) WHERE deadline_at IS NOT NULL index per lease_claim_batches child) that expands expired batches and force-closes open members into lease_claim_closure_batches with outcome deadline_expired. Row-local lease_claims and its deadline cursor are retained additively for claims that materialize into mutable leases and for legacy in-flight claims written before the upgrade. The sentences below that say deadline-backed claims live in lease_claims describe the pre-#246 shape; the compact representation is now used for both deadline modes.
Context¶
ADR-019 committed the hot queue plane to a vacuum-aware discipline: ready and terminal queue entries reclaim space by partition rotation and TRUNCATE, not by row-level cleanup. The remaining planes have narrower, explicit churn: materialized leases live in a bounded mutable lease ring, deferred_jobs is a plain scheduled/retryable backlog table promoted by delete-and-insert, and dlq_entries is an operator hold table with bounded retention cleanup. The ADR-019 validation artifact recorded 276 exact dead tuples across the entire schema after a 1000/s soak — queue_lanes=19, ready=0, done=255, leases=2, attempt_state=0 — consistent with the design intent.
The experimental receipt-backed short-job path introduced after that validation adds three tables:
lease_claims— append-only claim receipts (durable claim history)lease_claim_closures— append-only closure tombstonesopen_receipt_claims— a bounded "currently live receipt-backed attempt" frontier, introduced so rescue and queue-count queries would not degrade into anti-joins against unbounded claim history
lease_claims and lease_claim_closures honour the ADR-019 contract: they are insert-only. open_receipt_claims does not. Its design is one INSERT per claim and one DELETE per completion, so every completion produces a dead tuple on that table.
Measurements at the time the receipt ring landed confirmed this is the remaining MVCC source. In an internal long-horizon run from custom-20260424T065828Z-227187 (since-extracted to postgresql-job-queue-benchmarking, 28-minute clean phase at ~800/s per replica, receipts on), the open_receipt_claims heap held a median of 28,390 dead tuples and a peak of 93,789 while every other hot table stayed under 100 dead tuples. The autovacuum floor is autovacuum_naptime=60s, which is global, so per-table thresholds do not move the median: a measured before/after run with per-table knobs on open_receipt_claims left that median unchanged. Knobs treat the symptom; they cannot remove the DELETE from the hot path.
The receipt-backed short-job path is the only way to retire per-claim mutable lease row churn on the common path, and making it the default is a 0.6 release goal. Delivering that goal without violating ADR-019 requires bringing open_receipt_claims onto the same rotation-and- prune discipline as the rest of the engine.
Design goals and non-goals¶
Guided by the 0.6 priorities from ADR-019:
- Preserve at-least-once delivery. No claim may be lost across any crash, restart, or partition-truncation boundary.
- Preserve stale-writer protection by
(job_id, run_lease). Completion must still lose cleanly against a rescue or cancel on the same attempt. - Keep the claim hot path bounded and predictable under partition maintenance. The fix must remove per-completion receipt-row churn; one compact, append-only emission row per claim batch is acceptable only if it removes broader claim-ring parent scans from the hot stale-cursor recovery path.
- Eliminate the remaining MVCC churn source. Steady-state dead tuples on the receipt plane should be zero once rotation catches up.
- Finish the vacuum-aware story before 0.6 ships; hold the quality bar rather than the timeline.
Non-goals:
- Do not change the heartbeat / deadline / callback-timeout rescue contract. Those continue to live on
attempt_stateandactive_leases. - Do not change the external API or the
(job_id, run_lease)stale-writer guard. - Do not introduce any new reservation or pre-start state. The archived lease-plane redesign spike record shows that direction has been tried and rejected repeatedly on cost grounds.
Decision¶
Apply ADR-019's rotation-and-prune pattern to the receipt plane. Remove open_receipt_claims as a distinct table.
Physical layout¶
lease_claimsbecomesPARTITIONED BY LIST (claim_slot)with a small fixed set of child partitions (lease_claims_0..N-1). Row-local receipt claims live here when Awa needs per-claim deadline indexing or later materialization metadata.lease_claim_batchesis partitioned byclaim_slotwith matching children. Receipt claims (zero-deadline and, since #246, deadline-backed — every job claimed in one call shares onedeadline_at) write one compact row per claim batch, storing arrays of job ids, run leases, receipt ids, lane ids, attempts, and max attempts plus a derived receipt multirange for exact membership checks.lease_claim_closuresbecomesPARTITIONED BY LIST (claim_slot)with matching children. A closure row lives in the sameclaim_slotas its originating claim.lease_claim_closure_batchesis partitioned byclaim_slotwith matching children. Compact successful completions write globally allocated receipt identities here, plus a derivedint8multirange, so open-claim proofs can use an indexedreceipt_ranges @> receipt_idmembership check while prune still truncates the matching claim and closure partitions together.- A new control-plane pair
claim_ring_stateandclaim_ring_slotscoordinates rotation, mirroring the existinglease_ring_stateandlease_ring_slots.claim_ring_slotsalso stores per-slot rescue cursors: stale rescue sweeps receipt rows and compact claim batches by(claimed_at, job_id, run_lease), while deadline rescue sweeps deadline-backed claims in both shapes: row-local rows by(deadline_at, job_id, run_lease)and compact batches by(deadline_at, batch_id)(#246). open_receipt_claimsis deleted. Its indexes and the schema-install backfill are dropped.
Hot path¶
- Claim: append queue-slot-local batch/range attempt evidence to
ready_claim_attempt_batchesand append receipt claims to the current claim-slot partition in the same transaction. Both deadline modes use compactlease_claim_batches(#246): all jobs claimed in one call share one deadline, and the batch-deadline rescue cursor stays index-backed via the partial(deadline_at, batch_id)sweep index. Row-locallease_claimsremains for claims that materialise into mutable leases and for legacy in-flight rows. The claim result carriesclaim_slotand the immutablereceipt_idthrough to the worker so compact closure evidence can be tied back to the exact claim partition and attempt. - Complete: successful compact receipt completion records terminal history through
receipt_completion_batchesand records claim-local closure proof throughlease_claim_closure_batches. Non-success exits, receipt rescue, and cancellation close by claim shape: a row-locallease_claimsclaim appends an explicitlease_claim_closuresrow, while a compactlease_claim_batchesclaim appends alease_claim_closure_batchesrow carryingready_slot/ready_generation. A compact claim has nolease_claimsrow, so an explicit closure for it would be invisible to the queue-prune count proof (which joinslease_claims); the closure batch keeps it countable viacompact_count. - The claim step and compact successful completion step do not
UPDATEorDELETEreceipt-plane history. Non-success, rescue, cancellation, and terminal-delete closure paths append durable closure rows —lease_claim_closuresfor row-local claims,lease_claim_closure_batchesfor compact claims; row-local claims can also set thelease_claims.closed_atfence so a waiter rechecks the claim as closed after the winning transaction commits. - Short-job fast path moves the cost to append-only ledgers: claim writes receipt rows plus one ready-slot attempt batch row for the claimed lane range, and compact success writes batch-level terminal and claim-closure evidence without a per-job closure row, count-delta row, or receipt delete.
- Non-success closure batches are ordered by
claim_slotbefore(job_id, run_lease)so a flusher presents partition-local closure rows to Postgres. That ordering does not change the stale-writer guard; it only improves locality for the append-only receipt plane. - Default successful completions avoid writing a terminal payload copy when the runtime payload is empty or unchanged from
ready_entries. When an entire done batch has empty terminal payloads, completion also skips the ready-payload lookup that would otherwise be needed to prove unchanged non-empty payloads can be elided. - The receipt-backed compact completion SQL takes a per-
(job_id, run_lease)advisory transaction lock, validates the matching receipt claim by(claim_slot, job_id, run_lease, receipt_id)from eitherlease_claimsorlease_claim_batches, skips claims that have already materializedleasesrows, removes matchingattempt_state, appends compact claim-local closure evidence, and appends compact terminal history in one statement. Known-key materialization, explicit close paths, and receipt rescue use the same advisory key before row locks, so a same-attempt race observes the committed winner before deciding whether the receipt is still open. The compact terminal history carries the public terminal-history contract through{schema}.terminal_jobs; the compact closure batch carries the claim-prune contract for successful completions. Exact terminal counts read retained compact batches directly; the terminal-count delta ledger is reserved fordone_entriesterminal mutations.
Open-claim queries¶
Every read that currently targets open_receipt_claims becomes a bounded read over the active lease_claims and lease_claim_batches child partitions, anti-joined with durable closure evidence. Evidence can be an explicit lease_claim_closures row, a matching compact lease_claim_closure_batches item, or durable disposition rows retained for older rows:
- "Is
(job_id, run_lease)still open?" — exact lookup into active row-local claims or compact claim-batch items, anti-join durable closure evidence. Used by the completion guard and byload_jobon receipt-backed attempts. - "Scan stale receipt claims for rescue." — per-slot cursor scan ordered by
(claimed_at, job_id, run_lease), limited to row-local claims and compact claim-batch items old enough to be rescue candidates, anti-join durable closure evidence and materialized leases, take the same per-attempt advisory key used by completion, and close stale candidates by appending rescue closures. - "Scan expired receipt deadlines for rescue." — two independent per-slot cursor scans, both limited to claims whose deadline has already passed and both anti-joining durable closure evidence and materialized leases under the same per-attempt advisory key used by completion (#246). The compact-batch scan is ordered by
(deadline_at, batch_id), expands each expired batch into its members, and closes open members by appendingdeadline_expiredrows tolease_claim_closure_batches; its cursor advances past a batch only when every member is closed. The legacy row-local scan is ordered by(deadline_at, job_id, run_lease)overlease_claimsand closes intolease_claim_closures, covering in-flight claims written before the compact-deadline upgrade. - "Count in-flight receipt-backed attempts." — count row-local claims plus compact claim-batch items minus matching closure or durable disposition evidence.
Stale claim-cursor recovery is deliberately not an open-claim query. A claim transaction also writes ready_claim_attempt_batches in the ready row's queue-ring slot, keyed by (ready_slot, ready_generation, queue, priority, enqueue_shard) plus an int8multirange of covered lane_seq values. If the post-commit cursor advance is lost, the next claim can advance over already-emitted attempts by reading this queue-slot-local ledger. That keeps the hot claim path from taking parent lease_claims / closure evidence locks while old claim partitions are being truncated, while amortizing the proof row across the whole claim batch.
Both rescue cursors are bounded cyclic sweeps. Each pass scans forward from the cursor and wraps to the start of the partition when it reaches the end, but it does not visit rows that are too new to be candidates for that rescue class. Stale rescue can advance over closed claims, materialized lease-plane claims, old claims with fresh heartbeat evidence, and claims it closes in the current rescue transaction; it stops before a stale open claim that it did not close. Deadline rescue can advance over closed claims, materialized lease-plane claims, and claims it closes in the current rescue transaction. These cursors keep liveness scans from repeatedly proving old completed receipts are closed when a long reader prevents partition prune, without making every tick re-prove claims that are not yet old or expired enough to rescue.
Rotation and prune¶
- The maintenance leader owns
claim_ring_staterotation on a cadence chosen to keep the active scan surface bounded. Initial target: rotate at the same cadence as the queue ring so claim partitions age out roughly in step with the ready / done partitions they reference. - A claim-slot partition may be truncated only when every claim in it is represented by durable closure evidence. If open claims remain, prune skips the partition until normal completion, normal non-success closure, or the separate receipt-rescue scans close those claims.
- Queue-ring prune must not remove terminal history for a segment before matching receipt claims have durable closure evidence. It also must not remove retained ready or tombstone rows that are still at or ahead of their lane's claim cursor, because those rows are the cursor's proof that the prefix has been spent. These sealed-slot skip proofs run before the ready/terminal child-table exclusive locks so maintenance skips active slots without blocking hot claim reads, then run again after the locks so rows that committed while the lock waited are visible before truncate. Current successful compact completions write terminal/closure evidence synchronously, and compatibility/cold paths preserve or create closure evidence before hiding terminal history.
- Prune order mirrors
prune_oldestandprune_oldest_leases, except the open-claim proof intentionally runs both before and after the child-table exclusive locks: FOR UPDATEonclaim_ring_state.FOR UPDATEon the targetclaim_ring_slotsrow.- Prove every visible claim in the sealed slot has durable closure evidence.
- Bounded
ACCESS EXCLUSIVEon all claim-ring partitions for that slot (row-local claims, compact claim batches, explicit closures, and compact closure batches). - Recheck not-current and re-prove no open claims under the partition locks, then reset the slot's rescue cursor and
TRUNCATE. - Partition truncation never races with claim because claim writes to the ring's current slot and the prune path rechecks the sealed partition after taking the exclusive child locks.
Invariants preserved¶
- At-least-once delivery: a partition cannot truncate while live claims remain in it. Rescue is the gating step, not the prune itself.
(job_id, run_lease)stale-writer protection: the authoritative record is the row-local claim or compact claim-batch item plus durable closure evidence. The closure table follows the claim shape, not the exit reason: compactlease_claim_batchesclaims close intolease_claim_closure_batcheson every path (successful completion, non-success exit, cancellation, rescue), while row-locallease_claimsclaims close intolease_claim_closures. Wide/cold paths that operate on row-local claims uselease_claim_closures.- Heartbeat / callback-timeout rescue: unchanged for materialized leases. Deadline rescue is split by storage shape: materialized leases stay on the lease-side deadline path, while receipt-only claims use the deadline cursor and append a
deadline_expiredclosure before entering the same retry / DLQ routing.
Migration¶
This is a breaking schema change even though the external API does not change.
- A new migration creates
claim_ring_state,claim_ring_slots, and the partitionedlease_claims,lease_claim_batches,lease_claim_closures, andlease_claim_closure_batchesshapes. - Existing
lease_claimsandlease_claim_closuresrows are rewritten into the current slot of the new partitioned parents. Compact claim batches and compact closure batches are new storage and start empty on upgrade. - The legacy
open_receipt_claimstable is not part of the live read path.prepare_schema()drops it when empty, and refuses to proceed if it still contains rows so an operator can drain or reverse-migrate those rows deliberately. - Subsequent claim, complete, rescue, and count paths target the partitioned claim/closure-evidence tables. The live receipt set is derived as an anti-join over the active claim-ring partitions; stale-rescue and deadline-rescue scans use separate per-slot cursors stored on
claim_ring_slots.
Historical reverse-migration outline¶
The original 0.5-to-0.6 rollout runbook carried a manual escape hatch for reverting the receipt-plane partitioning before 0.6 workers had taken over. It was deliberately never shipped as a down migration, and it is not a supported rollback after queue storage has accepted work; retain it here so an incident responder can identify the historical boundary rather than infer a downgrade from the current schema.
- Quiesce all 0.6 workers and receipt-plane writers, take a database snapshot, and verify
lease_claim_batchesandlease_claim_closure_batchesare empty before renaming the partitionedlease_claimsandlease_claim_closuresparents so their pre-0.6 names are available. - Create unpartitioned
lease_claimsandlease_claim_closurestables from the exact pre-ADR-023 release schema, including its indexes and constraints. - Copy every row from the renamed partitioned parents with
INSERT ... SELECT, then reconcile row counts and open-claim evidence before dropping the partitioned parents. - Inspect both compact batch tables separately. They are compact claim/closure evidence that older runtimes cannot read — 0.6 wrote
lease_claim_batchesfor zero-deadline receipt claims from the start, so live claims can exist in the batch representation with no row-local counterpart. If either table contains rows, do not start the older fleet: restore the snapshot or continue forward on 0.6 unless release-matched reverse SQL has materialized equivalent row-local claims and closures.
TLA+ coverage (AwaSegmentedStorage, AwaStorageLockOrder) is extended to model the claim-ring rotation and the rescue-before-truncate precondition, parallel to the existing lease-ring model.
Validation¶
Success criteria for this redesign, measured on the long-horizon portable harness used for the ADR-019 baseline:
open_receipt_claimsis absent from the schema. Steady-state dead tuples across the queue-storage schema return to the ADR-019-validation shape: low hundreds, concentrated in ring-state singletons.- Throughput on the
1x32,4x8, and soak profiles is no worse than the current branch and should improve slightly because claim and completion each drop a table touch. - Crash-under-load recovers cleanly. The rescue-before-truncate path is exercised by the existing crash-recovery scenarios and by a new test that drives rescue concurrently with partition prune.
lease_claim_receiptsis on by default for 0.6 with no dead-tuple regression relative to the ADR-019 validation run.
Consequences¶
Positive¶
- The 0.6 vacuum-aware story becomes complete: no hot table relies on
DELETEorUPDATEfor reclamation. - The short-job hot path drops one
INSERTat claim and oneDELETEat completion. Per-job database work is strictly reduced. - Autovacuum tuning on
open_receipt_claimsbecomes dead code and is removed. The per-table HOT tuning on the small ring-state and head tables stays because it addresses a separate per-row UPDATE class that this ADR does not change. - Receipts become the default short-job path for 0.6, retiring the per-claim mutable lease row on the common path.
Negative¶
- This is a breaking schema migration.
- "Currently open" queries move from a single bounded-frontier lookup to a bounded anti-join across a small number of active partitions. Query planning needs spot-checking once the partition count is chosen.
- Rescue gains a partition-aware variant and must run before prune takes bounded
ACCESS EXCLUSIVE. The interaction point is small but adds a prune-path precondition not present forready/done. - The default-success path now writes one compact completion-batch row and one compact claim-closure batch row for the worker completion batch. ADR-026 explains how compact terminal history is exposed through
{schema}.terminal_jobs, how claim-local closure evidence protects receipt pruning, and how exact counts and segment-level retention are preserved.
Alternatives Considered¶
Per-table autovacuum tuning on open_receipt_claims¶
Rejected. A measured 28-minute soak with aggressive per-table thresholds and cost knobs left the median dead-tuple count on this table unchanged relative to the unmodified baseline (custom-20260424T041700Z-278649 vs. custom-20260424T065828Z-227187). The rate-limiter is autovacuum_naptime=60s, which is global, and the table is already eligible for vacuum on every wake-up. Per-table knobs improve vacuum efficiency when it runs but do not change the steady-state floor.
Documented operational autovacuum_naptime reduction¶
Rejected as the primary fix. Lowering autovacuum_naptime globally improves reclamation cadence but pushes configuration requirements onto operators and applies to every table in their database. It contradicts the ADR-019 principle that vacuum-awareness is a property of the schema design, not of operator tuning.
Awa-owned periodic VACUUM on open_receipt_claims¶
Rejected. A background VACUUM loop would mask the churn but keeps a hot DELETE on the common completion path and leaves the table as a permanent architectural outlier. The rest of the engine has no equivalent self-vacuum loop.
UPDATE-based soft close on open_receipt_claims¶
Rejected. Marking a closed_at column and sweeping closed rows periodically keeps the table live-bounded, and a HOT-eligible UPDATE avoids a new heap tuple. But the sweep still performs DELETEs in batch, index bloat still tracks throughput, and the architectural outlier persists.
Ship 0.6 with receipts off¶
Rejected. Shipping with receipts off lets 0.6 hit the dead-tuple budget today, but it leaves the short-job path on the mutable leases ring and defers the work tracked in the archived lease-plane redesign spike. ADR-019's vacuum-aware intent is only satisfied when receipts are on by default and do not regress the dead-tuple budget. This ADR is the path to that posture.
Relationship to Earlier ADRs¶
- ADR-019 established the vacuum-aware discipline. This ADR applies that discipline to the one remaining hot table that did not follow it.
- ADR-013 (run-lease-guarded finalization) is unchanged. The authoritative record for
(job_id, run_lease)staleness moves from a bounded mutable frontier to partitioned append-only tables; the guarantee does not. - The archived lease-plane redesign spike identifies
open_receipt_claimsas the compromise that unblocked the receipt-backed path. This ADR is the follow-through that the spike anticipated.
Implementation and Validation Status¶
This ADR has been implemented for 0.6:
lease_claims,lease_claim_batches,lease_claim_closures, andlease_claim_closure_batchesare partitioned byclaim_slot.claim_ring_stateandclaim_ring_slotscontrol rotation and prune.claim_ring_slotsstores per-slot stale-rescue and deadline-rescue sweep cursors so maintenance walks receipt history in bounded cyclic windows without reintroducing a per-claim mutable frontier. The stale sweep visits only claims old enough for the rescue cutoff; within that window it can pass claims whose heartbeat evidence is fresh and revisit them after wrap. Stale open claims stop advancement until rescue closes them. The deadline sweep visits only expired receipt deadlines and closes expired receipt-only claims.open_receipt_claimsis removed from fresh installs and is no longer a hot path table.lease_claim_receiptsdefaults totrue.- Successful compact receipt completion writes compact terminal history plus compact claim-local closure evidence. SQL compatibility delete hides compact completed rows by writing
receipt_completion_tombstones, without mutating the compact terminal batch or reopening the receipt. - Receipts mode supports per-claim deadlines. Since #246 the claim path writes
deadline_atonto the compactlease_claim_batchesrow whenQueueConfig.deadline_duration > 0(all jobs in one claim call share one deadline); legacy row-local deadline rows remain readable. The sibling rescue scan (rescue_expired_receipt_deadlines_tx) advances byclaim_ring_slots.deadline_cursor_*for row-local claims andclaim_ring_slots.batch_deadline_cursor_*for compact batches, force-closing deadline-backed receipt claims whosedeadline_athas passed without a closure or materialized lease, writing a'deadline_expired'closure. The maintenance entry point (rescue_expired_deadlines) merges the lease-side and receipt-side scans into one batch per tick, so receipts mode and the existing hard-deadline behaviour compose without operator intervention.
Validation evidence is split by purpose:
- Runtime and long-horizon evidence lives in the receipt-ring validation record. The recorded runs include the 115-minute 4x8 receipts-on long-horizon run and the 12-hour overnight run; receipt closure partitions stayed at 0 dead tuples across every phase, and receipt claims remained bounded.
- Spec and implementation correspondence lives in the storage model mapping. The storage TLA+ family models claim-ring rotation, partition prune safety, receipt rescue, running cancel, and DLQ retry trace witnesses.
- Operator-facing tuning and defaults live in Queue storage tuning.
The detailed phase-by-phase implementation notes were intentionally kept out of this ADR. ADRs record the decision and its consequences; dated build logs, benchmark output, and branch-era investigation notes belong in validation artifacts or the 0.6 storage-design archive.