Skip to content

ADR-023 Receipt-Ring Validation Artifact (2026-04-26)

This file records the exact commands, configuration, and raw results backing the validation numbers in ADR-023. The run exercises the post-Phase-6 system: lease_claim_receipts defaulted on, the legacy open_receipt_claims table deleted, and lease_claims / lease_claim_closures partitioned by claim_slot with the claim ring rotating in lockstep with the lease ring.

Note on paths. This validation artefact pre-dates the extraction of the long-horizon harness into its own repository. References to benchmarks/portable/... below describe the in-tree layout at the time of the run; the harness now lives at hardbyte/postgresql-job-queue-benchmarking. The data files referenced by run-id (custom-20260426...) are retained on the host that produced the run; they are not in the new public repo.

Run identity

  • Run id: custom-20260426T032000Z-839ddc
  • Branch: feature/vacuum-aware-storage-redesign at 7b688e4
  • Started: 2026-04-26 05:15:45Z (15:15 NZST), finished ~17:14 NZST
  • Duration: 115 min (5m warmup + 25m clean + 30m idle-in-tx + 30m recovery + 25m clean)

Host

  • 24-core x86_64, 91 GiB RAM
  • Linux 6.18.21, glibc 2.42
  • Postgres 17.2-alpine in compose
  • max_connections = 400 (raised from default 200 — see benchmarks/portable/postgres.conf for the reasoning; smaller ceilings caused pool exhaustion on the 4×8 profile)
  • shared_buffers = 256MB, autovacuum = on, autovacuum_naptime = 1min, autovacuum_vacuum_scale_factor = 0.2

CLI

uv run python benchmarks/portable/long_horizon.py \
  --systems awa \
  --replicas 4 --worker-count 8 --producer-rate 200 \
  --phase warmup=warmup:5m \
  --phase clean_1=clean:25m \
  --phase idle_1=idle-in-tx:30m \
  --phase recovery_1=recovery:30m \
  --phase clean_2=clean:25m \
  --skip-build

Per-replica producer rate is 200/s, so aggregate offered load is 800/s.

Headline result

Receipt-plane peak dead tuples stayed at ≤49 across all phases — including a 30-min idle-in-tx phase that holds open transactions to block autovacuum. The lease_claim_closures_<0..7> partitions stayed at exactly 0 dead tuples in every phase, confirming the append-only-then-truncate property the partitioning was designed to preserve.

open_receipt_claims is absent from the schema and from manifest.adapters.awa.event_tables. The receipt control table no longer exists.

Throughput by phase

Aggregate completion / enqueue rates summed across the 4 replicas (median over the 5s sample window of each phase):

phase type aggregate completion/s aggregate enqueue/s p99 e2e median (ms) p99 e2e peak (ms)
clean_1 clean 769.8 791.9 92.0 244.1
idle_1 idle-in-tx 359.2 640.5 n/a (no clean drain) 67 076
recovery_1 recovery 744.3 752.0 n/a n/a
clean_2 clean 614.0 736.0 n/a n/a

The e2e p99 spike of 67 s during idle_1 is the expected blocked-vacuum latency: held-open transactions prevent claim-ring partitions from being truncated and the steady-state pipeline backs up. The recovery and following clean phases drained without losing any claims (see receipt plane numbers below).

Receipt plane — peak n_dead_tup per partition per phase

table clean_1 idle_1 recovery_1 clean_2
awa_exp.lease_claims_0 7 8 1 6
awa_exp.lease_claims_1 0 0 3 2
awa_exp.lease_claims_2 3 0 0 8
awa_exp.lease_claims_3 0 0 8 1
awa_exp.lease_claims_4 4 0 0 8
awa_exp.lease_claims_5 2 0 8 8
awa_exp.lease_claims_6 7 2 8 11
awa_exp.lease_claims_7 8 0 0 5
awa_exp.lease_claim_closures_0..7 0 0 0 0
receipt-plane total 31 10 28 49

Closure partitions are append-only between rotations; their truncate runs under ACCESS EXCLUSIVE on a partition that the rotate path has already moved off. Zero-dead-tuple steady state is the designed shape.

Receipt plane — peak total_relation_size_mb

Largest single partition during clean_2 was lease_claims_1 at 1.26 MB. Aggregate across all 16 receipt-plane partitions stayed < 12 MB through the entire run.

Warm tables — peak n_dead_tup per phase

table clean_1 idle_1 recovery_1 clean_2
awa_exp.attempt_state 0 0 0 0
awa_exp.queue_lanes 0 0 0 0
awa_exp.queue_claim_heads 0 0 0 0
awa_exp.queue_enqueue_heads 0 0 0 0
awa_exp.queue_ring_state 76 665 40 40
awa_exp.queue_ring_slots 95 665 158 0
awa_exp.lease_ring_state 103 28 503 232 104
awa_exp.lease_ring_slots 0 0 0 0
awa_exp.claim_ring_state 78 1 631 84 76
awa_exp.claim_ring_slots 0 0 0 0

The *_ring_state tables are singletons (one row each); their dead tuples are HOT-update churn that recovers within one autovacuum pass once held-open transactions release. The 28 503 spike on lease_ring_state during idle_1 collapses back to 232 in recovery_1 and 104 by clean_2. This matches the architectural contract in correctness/storage/AwaDeadTupleContract.tla: ring-state rows are Warm (bounded by row count, churn-prone but reclaim-fast).

ADR-019 baseline comparison

ADR-019 (docs/adr/bench/019-queue-storage-validation-2026-04-19.md) is dominated by short throughput tests against a single-replica DB. The closest comparable number is the mixed-workload soak's exact_dead_total = 276 after a 10s, 1000 jobs/s drain. The Phase 6 multi-replica long-horizon equivalent is the 49 receipt-plane peak in clean_2 — a different shape (longer, multi-replica, includes an idle-in-tx phase), but the order-of-magnitude reduction confirms the prediction in ADR-023: by removing the open_receipt_claims row from the receipt path, receipt-plane MVCC churn drops from "low hundreds" to "low tens" and is fully partition-truncate-reclaimed instead of autovacuum-reclaimed.

Validation status

  • All four TLA+ specs in correctness/storage/ pass under TLC (AwaSegmentedStorage, AwaSegmentedStorageRaces, AwaStorageLockOrder, AwaSegmentedStorageTrace, AwaDeadTupleContract). TLC is wired into .github/workflows/nightly-chaos.yml.
  • cargo test --workspace green at 7b688e4.
  • 4 receipt-plane chaos scenarios in awa/tests/receipt_plane_chaos_test.rs pass (#[ignore] group, exercised in nightly-chaos.yml).
  • open_receipt_claims is absent from prepare_schema() and from the live schema after install (test test_open_receipt_claims_is_absent_after_install).

Known limitations / follow-ups

Residual ~2.4 GB/h/replica RSS growth (resolved 2026-04-27)

The 115-min run saw per-replica RSS climb from ~zero at start to 3.9– 4.6 GB at T+95m. Initial investigation traced this to glibc malloc arena fragmentation and applied MALLOC_ARENA_MAX=2 in bench_harness/adapters.py:_base_env. That capped arena count (1-2 distinct arenas) but a 12h validation attempt at T+1h still showed 2.7-3.6 GB/replica (~3.2 GB/h/replica), with the 1-2 capped arenas growing via repeated 64 MB chunk allocations.

A jemalloc heap profile (and a parallel code audit) traced 99.1% of allocations to tokio::runtime::task::core::Cell::new from awa-worker/src/dispatcher.rs:662: the Dispatcher::drain_ready path spawns every job-runner future into a shared Arc<Mutex<JoinSet<()>>>, but JoinSet::join_next() was only called during shutdown drain. Completed JoinHandles — and the task Cell each one keeps alive (which captures the entire execute_task closure: cloned Arcs of pool/storage/metrics, the job payload, the dispatch permit) — accumulated indefinitely under steady-state load.

Fixed in commit c0df1df by reaping completed handles via set.try_join_next() before each spawn batch. Validated end-to-end with the 12h overnight run below; per-replica RSS held at 17-18 MB for the entire 11.6h (vs 3.6 GB at T+1h pre-fix).

Pool sizing

The pre-fix max_connections = 200 ceiling caused pool-timeout errors on the 4×8 profile because each replica's bench pool defaults to 80 connections (worker_count × 4 + 48), and 4 × 80 = 320 exceeds the ceiling. The new defaults — max_connections = 400 in benchmarks/portable/postgres.conf and per-replica pool size in benchmarks/portable/awa-bench/src/long_horizon.rs — are pinned and captured in the manifest for cross-run comparability.

12-hour overnight run (2026-04-27, after JoinSet leak fix)

Run id: custom-20260426T101003Z-ba9cb4. Started 2026-04-26 22:10 NZST, completed 2026-04-27 09:46 NZST (11.6 h wall time). Same branch + commit c0df1df (JoinSet drain fix). Profile:

--phase warmup=warmup:5m
--phase clean_1=clean:4h
--phase idle_1=idle-in-tx:30m
--phase recovery_1=recovery:30m
--phase clean_2=clean:4h
--phase idle_2=idle-in-tx:30m
--phase recovery_2=recovery:30m
--phase clean_3=clean:90m

Receipt plane — 7-phase summary

phase type claims peak dead closures peak dead claims peak MB closures peak MB
clean_1 clean 85 0 3.58 3.37
idle_1 idle-in-tx 38 0 4.35 3.19
recovery_1 recovery 42 0 6.50 4.55
clean_2 clean 68 0 57.57 36.45
idle_2 idle-in-tx 33 0 59.67 37.56
recovery_2 recovery 37 0 101.63 63.73
clean_3 clean 42 0 98.18 61.55

Closure partitions stayed at 0 dead tuples through every phase across two full idle-in-tx stress cycles — the architectural property ADR-023 set out to deliver. Receipt-plane peak dead-tuple count over the entire 11.6 h was ≤85.

Warm singletons — peak n_dead_tup per phase

table clean_1 idle_1 recovery_1 clean_2 idle_2 recovery_2 clean_3
awa_exp.queue_ring_state 78 668 668 44 44 44 44
awa_exp.lease_ring_state 108 28 541 28 584 148 23 563 237 2 505
awa_exp.claim_ring_state 78 1 635 1 635 79 186 77 76
awa_exp.attempt_state 0 0 0 0 0 0 0
awa_exp.queue_lanes 0 0 0 0 0 0 0
awa_exp.queue_claim_heads 0 0 0 0 0 0 0

Ring-state row counts spike under blocked vacuum and recover within an autovacuum pass once held-open transactions release — exactly the Warm reclaim shape in the dead-tuple contract spec.

Per-replica RSS

Hourly samples taken from /proc/<pid>/status:

sample replica 0 replica 1 replica 2 replica 3
T+1h 18 080 kB 17 240 kB 17 176 kB 17 308 kB
T+5h 18 284 kB 17 376 kB 17 232 kB 17 396 kB
T+9h (idle_2) 18 404 kB 17 428 kB 17 260 kB 17 396 kB
T+11h 18 436 kB 17 448 kB 17 260 kB 17 396 kB

Net per-replica drift over 11 h: < 400 kB. Total allocator-arena mappings stayed at 16-18 anonymous regions, ~16 MB total per replica. Compare pre-fix: 2.7-3.6 GB at T+1h, projecting to ~150 GB at T+12h.

Throughput regression — out of scope for ADR-023

phase aggregate completion/s aggregate enqueue/s total backlog peak
clean_1 759 776 95
idle_1 365 645 354 878
recovery_1 747 746 360 925
clean_2 344 704 4 956 477
idle_2 167 596 5 732 775
recovery_2 221 707 6 591 795
clean_3 200 700 9 229 258

After idle_1 leaves a ~360k-job backlog, recovery_1's drain rate just matches the steady enqueue rate — it does not actually drain the backlog. From clean_2 onward completion rate falls to 200-350/s while enqueue holds at ~700/s, so the backlog grows monotonically to 9.2 M jobs / 12 GB ready_entries by the end. This is not an ADR-023 issue — the receipt plane stayed clean throughout (closures stayed at 0 dead tuples; claim partitions reclaimed each rotation). The slowdown is a separate scaling characteristic exposed by holding a ~Million-row ready_entries accumulation, tracked separately.

Three 30-minute regression-confirmation runs (2026-04-27)

After the 12 h validation surfaced the post-idle throughput regression fixed in c0df1df / ab99a31 / d21e5db, three short bench runs were taken at the same 4×8×200/s topology to confirm the regression stays gone under different pressure shapes. All three runs stream metrics through the OTel side-stack at docker/observability/ (commit 7905c74), so the dashboard is the canonical operator view and these tables are the textual record.

Run 1 — clean baseline

custom-20260427T043422Z-ab7901. 4×8×200/s, warmup 2m + clean 28m. Headline: completion 783/s vs enqueue 792/s, claim peak 25, closures 0. Steady-state shape; no surprises.

Run 2 — single-cycle idle-in-tx stress (the original regression scenario)

custom-20260427T051026Z-5bc479. warmup 2m + clean_pre 8m + idle-in-tx 5m + clean_post 15m.

phase comp/s enq/s claim peak closure peak
clean_pre 787 800 16 0
idle_1 779 781 3 0
clean_post 784 792 36 0

The original 12 h regression had clean_pre 759 → idle_1 365 → clean_post 344. This run is the post-fix shape: clean_post within 3/s of clean_pre, and within 8/s of enqueue. The post-idle drop is gone.

Run 3 — two-cycle stress (most informative)

custom-20260427T054607Z-6a8f52. warmup 2m + clean_1 5m + idle_1 5m + recovery_1 5m + clean_2 5m + idle_2 3m + recovery_2 3m + clean_3 2m. Two full stress cycles in 30 minutes.

phase comp/s enq/s gap claim peak closure peak
clean_1 783 800 17 15 0
idle_1 755 757 3 15 0
recovery_1 798 800 2 1 0
clean_2 790 792 2 21 0
idle_2 773 785 12 3 0
recovery_2 773 787 14 8 0
clean_3 774 788 14 13 0

Receipt-plane closure peak: 0 across every one of the seven phases, including two full idle-in-tx stress cycles. Claim peak ≤21 across the entire run. Throughput keeps within 2% of the producer rate in all phases — the post-idle regression does not re-emerge across repeated stress cycles.

Warm singleton (lease_ring_state) dead-tuple peaks per phase, for the dead-tuple contract spec's Warm reclaim shape:

phase lease_ring_state queue_ring_state claim_ring_state
clean_1 101 68 69
idle_1 5 909 296 296
recovery_1 6 010 75 301
clean_2 93 72 79
idle_2 3 510 176 175
recovery_2 3 608 181 181
clean_3 93 65 66

Spikes under held-tx, full recovery within one clean phase. Exactly the Warm/bounded-by-row-count shape declared in correctness/storage/AwaDeadTupleContract.tla.

Files

  • 115-min run: benchmarks/portable/results/custom-20260426T032000Z-839ddc/
  • 12-hour run: benchmarks/portable/results/custom-20260426T101003Z-ba9cb4/
  • Run 1 (clean baseline): benchmarks/portable/results/custom-20260427T043422Z-ab7901/
  • Run 2 (single-cycle stress): benchmarks/portable/results/custom-20260427T051026Z-5bc479/
  • Run 3 (two-cycle stress): benchmarks/portable/results/custom-20260427T054607Z-6a8f52/
  • All include summary.json, manifest.json, raw.csv, plots, html

Second 12-hour overnight run (2026-04-27 → 2026-04-28)

Run id: custom-20260427T081444Z-2471c6. Started 2026-04-27 20:14 NZST, completed 2026-04-28 07:56 NZST (11h 35m wall). Branch tip a45dca6 (post-doc-consolidation). Same 7-phase profile as the prior 12h run (4 replicas × 8 workers × 200/s producer = 800/s aggregate; warmup:5m → clean_1:4h → idle_1:30m → recovery_1:30m → clean_2:4h → idle_2:30m → recovery_2:30m → clean_3:90m).

Headline

Receipt-plane invariant held across the entire 11.6 h including two full idle-in-tx stress cycles:

  • lease_claim_closures_0..7 peak n_dead_tup = 0 in every phase.
  • lease_claims_0..7 peak n_dead_tup = 24 across the run (well under the 200 threshold).

Per-replica RSS: held at 17184–17552 kB across 5 sampled checkpoints spanning 8 hours of the run. Net drift ~150 kB. The JoinSet leak fix from c0df1df continues to hold.

Run log: 0 ERROR/FATAL/panic. 474 sqlx WARN slow-statement events, all on the queue_counts query during high-backlog phases — see "queue_counts perf characterisation" below.

Receipt plane — 8-phase summary

Peak n_dead_tup per partition family per phase. Dashes mean the metric was not sampled in that phase (no events fell within the sample window).

phase claims peak closures peak
warmup 0 0
clean_1 16 0
idle_1 14 0
recovery_1 8 0
clean_2 9 0
idle_2 4 0
recovery_2 8 0
clean_3 0 0

Warm singletons — peak n_dead_tup per phase

table clean_1 idle_1 recovery_1 clean_2 idle_2 recovery_2 clean_3
lease_ring_state 105 28 563 (≤ 132) 132 26 686 (≤ 88) 88
claim_ring_state 78 1 616 (≤ 79) 79 323 (≤ 76) 76
queue_ring_state 78 633 (≤ 9) 9 9 (≤ 9) 9

Same Warm/bounded-by-row-count reclaim shape as the first 12h run. Spike under held-tx, full reclaim within one clean phase.

Throughput by phase

Per-replica completion / enqueue rates (median over the 5s sample window). Multiply by 4 for aggregate.

phase completion/s enqueue/s
warmup 198.5 198.5
clean_1 190.2 190.2
idle_1 118.1 164.9
recovery_1 184.5 183.4
clean_2 95.7 174.0
idle_2 42.8 158.3
recovery_2 52.0 175.4
clean_3 31.5 175.8

Same post-idle backlog-growth shape as the first 12h run. Receipt plane stayed clean throughout, so the slowdown is not an ADR-023 property — it is the queue_counts hot-path described next.

queue_counts perf characterisation (targeted bench, off-pool)

Driven by a fresh-postgres microbench (artifacts/queue_counts_bench/run.sh) that seeds N rows into a single ready_entries partition for one queue and runs EXPLAIN (ANALYZE, BUFFERS) on the exact-count CTE used by QueueStorage::queue_counts_exact. Three repeats per size; the run isolates the count from concurrent enqueue/claim traffic.

backlog rows execution_ms (median) total_ms (median)
100 000 14.1 29.6
1 000 000 116.1 134.7
5 000 000 460.2 475.1
10 000 000 845.9 860.9

Linear in the backlog. The cost is dominated by a single sub-CTE, lane_counts, which scans every row in ready_entries and joins against queue_claim_heads to filter by lane_seq >= claim_seq. At 10 M rows the EXPLAIN plan reports 877 ms in Finalize Aggregate over a Parallel Seq Scan + Hash Join across ready_entries_0 partition, with the parallel workers each reading ~55 k buffer pages (3 × 8 GB residency across the partition heap).

Every other sub-CTE finishes in under a millisecond:

sub-CTE actual time (10 M backlog)
lane_counts 877 ms (full ready_entries scan)
live_running (leases) 0.04 ms (8 partitions, all empty)
live_running (lease_claims anti-join) 0.04 ms (claim ring TRUNCATE'd hot)
live_terminal (done_entries) 0.08 ms
pruned_terminal (queue_lanes ⨝ rollups) 0.13 ms

The dispatcher caches queue_counts for CLAIMER_QUEUE_COUNTS_MAX_AGE = 250 ms (awa-worker/src/dispatcher.rs:20). Once the backlog crosses ~3 M rows the exact query takes longer than the cache window, so every replica's claim batch ends up waiting on a fresh exact count. Across 4 replicas this saturates one connection per replica with the count query alone, which is why throughput collapses from ~750/s aggregate in clean_1 to ~125/s in clean_3.

Fix direction (not landed in this branch)

The receipt-plane numbers tell us this is not an ADR-023 issue; the partitioned receipt tables are doing exactly what they were designed to do. The fix space is:

  1. Replace the ready_entries scan with a counter read. queue_lanes.available_count already tracks per-(queue, priority) available entries and is updated transactionally by enqueue/claim/rotate. Summing that column over the matching logical-queue physical stripes is O(few rows) regardless of backlog size and gives the same semantic answer as the current row-count scan.
  2. Lift queue_counts out of the dispatcher claim hot path. The dispatcher only uses queue_counts to size target_claimers. That is a slow-moving control-loop input — a 1–5 second cache window would be safe and would absorb the worst-case query cost without visible impact. Resolution: both directions landed before the 0.6 cut. The queue_counts_exact lane_counts CTE now reads sum(queue_lanes.available_count); the dispatcher's claimer-target loop uses an AvailableSignal from the same lane-head counter; the legacy queue_counts_cached method, the queue_count_snapshots cache table, and the dispatcher's CLAIMER_QUEUE_COUNTS_MAX_AGE constant are gone. The receipt-plane validation in this artifact holds independently of those throughput-side changes.

Files

  • Long-horizon results bundle: benchmarks/portable/results/custom-20260427T081444Z-2471c6/ (summary.json, manifest.json, raw.csv, index.html, plots/). raw.csv is 529 MB and is gitignored — the bundle is local-only archive evidence; this artifact captures the derived numbers.
  • Overnight check log: artifacts/overnight/checks.log (5 sampled health checkpoints from the autonomous cron).
  • queue*counts microbench: artifacts/queue_counts_bench/ (run.sh, timings.csv, four explain*<N>\_<r>.txt per repeat).