ADR-026: Narrow Terminal History¶
Status¶
Accepted. The "Queue-prune logic must continue treating ready and terminal rows as one retention unit" consequence below is amended by ADR-032: Failed terminal retention floor for failed terminal rows inside the failed_retention floor: queue prune carries those rows forward into the live segment as wide self-contained terminal rows (the synthetic shape this ADR already defines for unclaimed/scheduled cancellation), decoupling them from the retained ready body so they stay retryable past their original segment. Ordinary terminal history is still one retention unit.
Context¶
The queue-storage runtime intentionally keeps the runnable row immutable in ready_entries and records durable terminal history when an attempt completes, fails, or is cancelled. The first queue-storage shape wrote every terminal fact to done_entries. That shape was safe and easy to inspect, but it duplicated the immutable job body on the hottest successful completion path: args, max_attempts, run_at, created_at, uniqueness metadata, and often an unchanged runtime payload were written once in ready_entries and then again in done_entries.
The duplication is visible in the WAL and row-size budget. Reference queue-storage runs showed this shape:
| Shape | Completed jobs/s | p99 e2e | WAL/job |
|---|---|---|---|
| Tuned production queue storage | 7,885/s |
203 ms |
2,241 B |
| Narrow terminal history | 8,337/s |
205 ms |
1,932 B |
Skip done_entries entirely |
8,402/s |
230 ms |
1,441 B |
Skipping durable terminal history is not acceptable: it weakens the operator/API source of truth. The useful signal is that most of the safe gain comes from avoiding duplicated terminal body bytes, not from deleting the terminal fact.
Decision¶
For terminal rows whose source attempt still has a retained ready row, avoid copying immutable ready-body fields into terminal history.
For failed, cancelled, non-receipt, and wide terminal rows, write a narrow done_entries row:
- keep terminal identity and ordering fields:
ready_slot,ready_generation,job_id,kind,queue,state,priority,attempt,run_lease,lane_seq,enqueue_shard,attempted_at, andfinalized_at - keep
payloadonly when the terminal runtime payload differs from the ready-row payload - leave duplicated immutable body fields nullable and normally
NULL:args,max_attempts,run_at,created_at,unique_key, andunique_states
Reads that materialize a JobRow or move a terminal row to another storage family hydrate through a left join from done_entries to the retained ready_entries row using the ready segment and lane key:
For successful receipt-backed completions that do not need wide terminal body fields, write compact rows to receipt_completion_batches instead of one done_entries row per completed job. Each compact terminal batch stores arrays of (job_id, run_lease, lane_seq, attempt, attempted_at) plus the shared ready segment, queue, priority, shard, finalized timestamp, and originating claim slot. The same completion transaction also writes a compact claim-local row to lease_claim_closure_batches, keyed by immutable receipt ids allocated from lease_claim_receipt_id_seq and stored either on row-local lease_claims rows or inside compact lease_claim_batches.receipt_ids. The closure batch also stores a derived int8multirange for indexed membership checks. The claim transaction returns receipt_id to the worker for every receipt claim; compact lease_claim_batches claims also return the inserted batch_id and a one-based item index. Compact completion validates row-local claims by (claim_slot, job_id, run_lease, receipt_id) and compact batch claims by direct (claim_slot, batch_id, index) plus the expected (job_id, run_lease, receipt_id) after taking the per-attempt advisory lock. Receipt rescue uses the same advisory key before writing rescue closures, so successful completion and rescue serialize without making compact success FOR UPDATE every claim row. The claim transaction writes compact ready_claim_attempt_batches range evidence in the ready row's queue slot before writing receipt claims, so stale claim-cursor recovery can prove that covered lanes already emitted attempts without reading claim-ring parent tables. Zero-deadline claims also use compact lease_claim_batches; deadline-backed claims remain row-local in lease_claims so deadline rescue can stay index-backed on deadline_at. The runtime routes claim lookup and compact closure writes to the originating claim_slot child partitions, while compact terminal history and claim-attempt evidence remain queue-slot routed. Compact terminal batches are counted directly while retained; the terminal-count delta ledger is reserved for done_entries terminal mutations. The compact closure batch carries the ready slot and generation for the completed group, so queue and claim prune can compare exact row counts for a sealed segment and skip conservatively when counts do not prove closure. Terminal history and claim-closure proof are therefore separate append-only ledgers, so successful completions do not need per-job lease_claim_closures rows and stale receipt scans do not need to unnest terminal-history arrays.
Receipt stale-rescue and deadline-rescue cursors scan only claims that are old or expired enough to be rescue candidates, so long-lived retained claim partitions do not make each maintenance tick re-prove fresh claims.
The compact path is only for receipt-backed jobs with no unique key, no tags, no terminal errors, and no custom metadata. Awa-owned provenance metadata (_awa_original_priority and _awa_original_queue) remains compact-safe: it is either already present on the retained ready payload, or it is claim-time priority-aging provenance that does not require duplicating the whole terminal body in done_entries.
Schema preparation creates a read-only {schema}.terminal_jobs view with the hydrated terminal shape. It is the preferred SQL surface for inspection, reporting, and external read-only tooling. The view expands compact completion batches and joins both physical terminal families back to retained ready_entries. The physical done_entries table remains the write/transition surface for terminal rows that can be retried, moved to DLQ, discarded, or carried forward by failed-retention pruning. Compact completed rows are not retryable; SQL compatibility delete hides them by writing receipt_completion_tombstones. That compatibility delete expands retained compact batches to find the target job; Awa does not maintain a job_ids GIN index because that would add WAL to every successful compact completion for a cold administrative path.
The retained ready backing row remains immutable and inert until queue prune reclaims the segment.
Terminal rows are narrow only for claimed attempts whose immutable body is already represented by a retained ready row: running and waiting_external attempts. Cancelling an unclaimed available job tombstones the ready lane and writes a wide done_entries row because the job never had a claimed attempt snapshot. Scheduled/deferred cancellation also remains wide because there is no ready backing row in the ready ring.
When a terminal row is deleted, the retained ready backing row is not deleted and does not become live again. A ready-backed terminal row was already claimed, so the lane is behind the claim cursor; retry or queue-move paths append a fresh ready row at a new lane position. Ready-row cleanup remains segment-level work owned by queue prune.
Queue-ring prune reclaims retained ready bodies, ready tombstones, compact completion batches, compact completion tombstones, pending done_entries terminal-count deltas, and any remaining terminal facts for the segment together. The TLA+ model records this as TerminalHasRetainedReadyBody: every modelled ready-backed public terminal row has a retained ready body until the terminal fact is moved/deleted or queue prune removes the segment.
Exact terminal counts are part of this terminal-history contract. Awa keeps them exact with retained compact batches plus three derived stores:
queue_terminal_live_counts, keyed by(ready_slot, queue, priority, enqueue_shard, counter_bucket), stores folded counts for retained queue segments.queue_terminal_count_deltas_*, partitioned with the queue ring, stores pending signed deltas fordone_entriesterminal mutations that have not been folded yet.receipt_completion_batches_*andreceipt_completion_tombstones_*provide the retained compact completion count directly, without another hot-path derived write.queue_terminal_rollupsstores permanent counts for pruned queue segments.
done_entries terminal mutations append a narrow delta row in the same transaction as the physical terminal mutation:
done_entriesterminal insertion writes a positive delta;- retry, purge, discard, DLQ move, and compatibility delete of
done_entriesrows write a negative delta; - the delta key includes
ready_generation, so reusedready_slotpartitions cannot inherit stale deltas.
Compact receipt completion batches do not append terminal-count deltas. A 512-job compact batch writes one compact terminal-history row for a queue/priority/shard group and is counted from its retained completed_count until queue prune folds that segment into permanent rollups. SQL compatibility delete of a compact completed row writes receipt_completion_tombstones, and exact counts subtract retained compact tombstones. Wide done_entries terminal rows keep job-id counter bucketing so later terminal deletes cancel their original positive bucket exactly.
queue_counts_exact computes the live terminal component as queue_terminal_live_counts + SUM(queue_terminal_count_deltas) + SUM(receipt_completion_batches.completed_count) - COUNT(receipt_completion_tombstones), then adds pruned rollups from queue_terminal_rollups / queue_lanes. The exact read remains honest while maintenance is behind. If the terminal-counter trust marker is not set, the read path falls back to scanning {schema}.terminal_jobs so rolling upgrades and recovery windows stay honest.
The maintenance leader rolls sealed delta segments into queue_terminal_live_counts in deterministic key order, then truncates the matching delta child in the same transaction. Candidate selection is driven by sealed slots that actually contain pending deltas, so an empty old prefix cannot hide a later sealed slot that needs rollup. Rollup skips the current queue slot and any slot with active leases, retained ready rows at or beyond their lane claim cursors, or open receipt claims. The retained-ready check runs before the exclusive delta-child lock and before the receipt-closure proof, because a slot with pending ready rows cannot be rolled up or pruned yet. Once the delta child is locked, rollup proves receipt claims are closed with the same exact-count proof queue prune uses and skips conservatively when counts do not prove closure.
Rollup is also MVCC-horizon aware. If PostgreSQL reports another backend with an open snapshot in the current database, or an idle transaction with an assigned transaction id, maintenance returns before mutating queue_terminal_live_counts or locking the delta child. The pending deltas remain append-only and exact reads remain honest because they include the delta sum. When the horizon clears, a later maintenance tick folds the sealed deltas. Queue prune also truncates the delta child after folding terminal history into permanent rollups, so a lagging counter rollup cannot block retention.
The trust marker remains meaningful: it means the folded counter plus all unrolled deltas is complete for retained done_entries rows in the active schema. Compact completion batches are direct-counted in both trusted and untrusted modes. Rebuild recomputes the folded counter from {schema}.done_entries and clears the delta ledger.
Correctness requirements for the delta ledger:
- The delta append must commit atomically with the terminal mutation it describes. If the state transition rolls back, the delta rolls back.
- Exact reads must include every committed terminal mutation exactly once:
done_entriesrows through the rolled-up counter or unrolled delta sum, compact receipt completions through retained batches minus retained compact tombstones, and pruned segments through rollups. - Rollup must be crash-safe and idempotent. Applying deltas and truncating the delta segment must commit together.
- Rollup must acquire counter rows in deterministic order.
- Rollup may defer while the MVCC horizon is pinned, but the deferral must happen before mutating folded counters or truncating pending deltas.
- Segment prune may truncate pending delta rows and retained compact batches only because it first folds terminal history from
terminal_jobsinto permanent rollups; exact reads no longer need that retained segment evidence after the terminal segment is pruned. - The storage TLA+ models track terminal deltas as append-only, partition-truncated derived state; they do not make job safety depend on counter rollup timing.
Consequences¶
Positive¶
- Keeps the durable terminal fact and all existing public API semantics.
- Reduces WAL and row bytes on the dominant completion path.
- Preserves
attempted_atandfinalized_atin terminal history, so terminal attempt timing remains inspectable through{schema}.terminal_jobs. - Keeps the ring-prune safety story unchanged: ready rows, terminal rows, compact completion batches, compact tombstones, and
done_entriesterminal deltas are still reclaimed by queue-slot truncation after the queue slot is proven inactive.
Negative¶
- Direct SQL against
done_entries.args,max_attempts,run_at,created_at,unique_key,unique_states, orpayloadmust tolerateNULLon ready-backed terminal rows. Direct SQL againstdone_entriesmust also tolerate successful receipt-backed completed jobs being absent from that table. Use{schema}.terminal_jobsunless code intentionally needs the physical storage representation. done_entriesterminal delete paths have one more responsibility: append the matching negative terminal-count delta before re-enqueuing, moving to DLQ, or discarding.- Queue-prune logic must continue treating ready and terminal rows as one retention unit. (ADR-032 amends this for
failedrows inside thefailed_retentionfloor, which prune carries forward as wide self-contained terminal rows.) - Exact terminal-count reads must include folded counters, pending
done_entriesdeltas, retained compact batches minus compact tombstones, and permanent rollups. Operational SQL that reads onlyqueue_terminal_live_countssees foldeddone_entriesstate, not the full exact count. - Pending delta rows can accumulate while long reader transactions pin the MVCC horizon. That is intentional: it trades a larger append-only
done_entriesledger for near-zero dead tuples inqueue_terminal_live_countsduring the pin. Compact receipt successes do not add to this ledger.
Alternatives Considered¶
Keep fully materialized terminal rows¶
This is the pre-ADR behavior. It is simple for direct SQL users but spends extra WAL and heap bytes on every terminal transition.
Remove durable terminal history for successful completions¶
This produced the lowest WAL/job result in experiment, but it removes the durable terminal fact that queue counts, load_job, admin inspection, and retention rely on. It is rejected.
Store compact successful receipt completions synchronously¶
This is the chosen design. It adds another physical terminal family, but keeps one public terminal surface (terminal_jobs), one exact-count contract, and one queue-ring prune boundary. It deliberately does not add a background materializer or make completion visible before durable terminal history commits.
Drop terminal counters and scan done_entries¶
Rejected for the normal exact-count path. It is simple and exact, but makes a common admin/UI read proportional to retained terminal history. That moves cost from completion to observability rather than removing it.
Make terminal counts eventually consistent¶
Rejected for queue_counts_exact. Awa exposes exact queue counts and uses the trust marker to make that contract explicit. Eventual counts may be useful for a cheaper overview endpoint, but they should not replace the exact path.
Keep striped live counters only¶
Counter bucketing reduces contention, but it still updates mutable rows in the terminal path. This is rejected for the high-throughput queue-storage path because benchmark evidence showed the live-counter table becoming the dominant dead-tuple source under pinned readers after ready rows became append-only.
Defaults¶
This ADR changes the batching defaults that determine whether lower WAL turns into durable throughput:
enqueue_shards = 1remains the strict-FIFO default; use more shards only when partitioned FIFO is acceptable.claimers = 1andclaim_batch_size = 512remain the queue defaults.AWA_COMPLETION_BATCH_SIZE = 512andAWA_COMPLETION_FLUSH_MS = 1remain the completion defaults. Queue storage usesAWA_COMPLETION_SHARDS = 1for ordinary runtimes and4when the configured runtime worker capacity is at least512; canonical storage keeps8.queue_slot_count = 16,lease_slot_count = 8,claim_slot_count = 8, andlease_claim_receipts = trueremain the queue-storage defaults.terminal_count_rollup_interval = 30sfolds pendingdone_entriesterminal-count deltas for sealed queue slots. Each tick processes at most four sealed slots. Exact reads include pending deltas and retained compact batches, so this cadence affects compaction pressure, not correctness.
The queue-storage short-job completion path also uses one fused statement for the common receipt-backed, payload-empty terminal transition: insert compact completion batch rows, insert compact claim-closure batch rows, and delete any matching attempt_state. Before that statement, the transaction takes per-(job_id, run_lease) advisory locks in deterministic key order so duplicate completion attempts and known-key materialization or close paths serialize and observe the winning evidence. The fused statement validates row-local lease_claims by the immutable receipt tuple and compact lease_claim_batches by the claim-returned batch id and one-based item index, and skips claims that already have a materialized leases row; those attempts keep the general lease-deleting transaction path. A materialized receipt can be in a newer lease-ring slot than the original claim carried, so the general receipt fallback matches the lease by stable ready-lane identity and (job_id, run_lease). Jobs with unique-key transitions, terminal payload metadata, materialized lease state, or missed receipt evidence also keep the general transaction path.
If a later retry, DLQ move, discard, or SQL compatibility delete removes a done_entries terminal row before claim prune has reclaimed the originating receipt, the terminal-delete path first materializes an explicit lease_claim_closures row. Compact successful completions keep their closure evidence in lease_claim_closure_batches; SQL compatibility delete hides them from public terminal reads by writing receipt_completion_tombstones and does not reopen the receipt.
The offered-rate benchmark turns that lower write budget into an explicit capacity check. With one queue shard, one claimer, claim_batch_size = 512, adaptive queue-storage completion shards, and max_workers = 1024, a 10-second 10k/s no-op workload keeps durable completions at the offered rate, drains to zero backlog, and writes 1.8 KiB WAL per completed job. A 12k/s offered workload exceeds that reference configuration and accumulates backlog during the offer window before draining afterward.
Relationship to Rejected ADR-024¶
The relevant historical ADR-024 was Deferred done_entries Materialisation. It removed the done_entries insert from receipt completion entirely, treated lease_claim_closures as the immediate completion source of truth, and added a background materializer plus read-side synthetic projections and prune guards. Its A/B benchmark was worse than the synchronous path: 1,839/s vs 2,803/s, with higher p99 latency. It also added new moving parts: a materializer cadence, materializer lag monitoring, rotation catch-up, and temporary closure-without-terminal read semantics.
ADR-026 deliberately keeps the opposite contract:
- completion still writes durable terminal history synchronously;
{schema}.terminal_jobsis the terminal source of truth for counts and admin reads;done_entriesremains the transition surface for retryable/movable terminal rows;- there is no materializer, no lag window, and no extra prune precondition;
- the fast path is an implementation detail of the same logical transition: successful completion writes compact terminal history plus compact claim-local closure evidence atomically, while non-success and cold terminal-delete paths use
lease_claim_closureswithdone_entries.
The overlap with ADR-024 is the insight that duplicating ready-body JSONB is wasteful. The difference is where the system draws the safety boundary: ADR-024 deferred the terminal fact; ADR-026 keeps the terminal fact durable and only narrows or compacts its payload, then fuses receipt-claim locking, compact terminal insert, and compact claim-closure insert so the synchronous contract is cheap enough to hit the throughput target.
The first tuning move for overload remains semantic enqueue sharding when the workload can accept partitioned FIFO. This ADR lowers the durable completion write budget underneath those defaults.
Validation¶
- Runtime tests assert that completed and failed ready-backed terminal rows are narrow, hydrate correctly through
load_job, and can retry without resurrecting the retained ready backing row. - Runtime tests assert that the
{schema}.terminal_jobscompatibility view hydrates ready-backed terminal rows while physical terminal rows remain narrow or compact. - Runtime tests assert that successful compact receipt-backed completions write no completed
done_entriesrow, appear interminal_jobs, write no per-job completed receipt closures, write compactlease_claim_closure_batchesevidence for row-local and compact claim-batch receipts, write no terminal-count deltas, can be hidden by SQL compatibility delete throughreceipt_completion_tombstones, remain closed from the receipt plane, and are counted by trusted direct-batch reads, untrusted fallback scans, and rebuild paths. - Runtime tests assert that receipt claims materialized into
leasesafter lease-ring rotation still complete through the general receipt fallback and delete the materialized lease by stable identity. - Runtime tests assert that cancelling an unclaimed available job tombstones the ready lane and writes a wide terminal row because the job never had a claimed attempt snapshot.
- Existing DLQ move, bulk move, retry, and discard flows hydrate terminal rows before moving them, remove the terminal fact, and leave retained ready backing rows inert until queue prune.
- Runtime tests assert that
done_entriesterminal mutations append signed deltas, exact counts include folded counters plus pending deltas plus retained compact batches minus compact tombstones, maintenance rolls sealed deltas intoqueue_terminal_live_counts, prune folds terminal history into permanent rollups, and rebuild restores counters fromdone_entries. correctness/storage/AwaSegmentedStorage.tlamodels retained terminal ready bodies, compact receipt closure evidence, terminal-to-DLQ terminal-fact deletion, and queue prune reclaiming retained ready bodies with any remaining terminal facts.
Relationship to Other ADRs¶
- Refines ADR-019's queue-storage terminal family.
- Preserves ADR-023's receipt-plane safety: completion still closes the exact claim and writes a durable terminal fact.
- Complements ADR-025: enqueue sharding attacks producer head-row contention; this ADR reduces completion-side WAL/row pressure.
- Supersedes the useful part of the rejected ADR-024 deferred-materialisation experiment without adopting its asynchronous terminal-history contract.