Skip to content

fix(queue-storage): keep prune's checks off the exclusive lock and remove its lock-order cycles (#721) - #723

Merged
hardbyte merged 7 commits into
release/0.6.6from
claude/beautiful-dijkstra-vqdyqy
Nov 11, 2026
Merged

hardbyte merged 7 commits into
release/0.6.6from
claude/beautiful-dijkstra-vqdyqy

Conversation

@hardbyte

@hardbyte hardbyte commented Nov 11, 2026 •

Copy link
Copy Markdown
Owner

Partial work on #721 for the 0.6 line. It removes the hazard of queue prune running its checks under ACCESS EXCLUSIVE, gives prune a bounded longer wait for that lock, and removes two lock-order cycles between prune and the hot paths. It does not fix the starvation itself: under a large abandoned-claim backlog prune is still blocked most of the time, as on 0.6.16. That needs the hot paths to stop locking sealed slots (direction 1 of the issue); the ignored probe test added here is its acceptance test.

What changes

  • Queue prune takes two lock phases. It takes SHARE ROW EXCLUSIVE on the sealed slot's partitions, runs the skip gates, the failed-row carry and the rollup scan under it, and requests ACCESS EXCLUSIVE only for the TRUNCATE. SHARE ROW EXCLUSIVE waits for every transaction that wrote to the slot and blocks new writers, and conflicts with no reader. After the upgrade it re-runs the active-lease and pending-ready gates.
  • Bounded exclusive request, both rings. The ACCESS EXCLUSIVE request runs from a savepoint: it waits prune_lock_timeout, rolls back so queued readers proceed, and retries with twice the wait up to 320 ms (or prune_lock_timeout when that is longer) before reporting blocked.
  • Queue prune's lock order. It requests the segment partition first, then done, then ready. Claims lock the sealed segment partition first, and retry, fail and snooze lock done before ready.
  • Claim prune locks the slot row last. It reads the slot's claim_ring_slots row without locking it and takes FOR UPDATE only once it holds the partitions. Rescue takes the partitions and then that row.
  • Claim prune keeps its check under ACCESS EXCLUSIVE. Heartbeat, retry, fail and snooze read lease_claims and then update it in one transaction, which deadlocks with a lock upgrade.
  • Deadlocks count as contention. A deadlock that PostgreSQL resolves by cancelling a prune is reported as blocked, not error.
  • Startup warning. deadlock_timeout is compared with the longest single prune lock wait (QueueStorage::prune_lock_wait_ceiling), and statement_timeout with eight times that.
  • Lock-order model. AwaStorageLockOrder.tla listed rescue's cursor row before the claim partitions, following the SQL text. PostgreSQL takes a statement's relation locks before its row locks. The plans are corrected, and a witness config shows the 0.6.16 claim prune order deadlocks with rescue.

Why the queue count proof is not repeated after the upgrade

  • A receipt claim inserts its attempt batch into the ready slot's ready_claim_attempt_batches partition in the same statement that creates its claim row, so no receipt claim of the slot can begin after the writer lock is granted, and one that began earlier has committed by then. Closures only accumulate.
  • A claim that writes a lease directly touches no partition of the queue slot. If it read its ready row before the writer lock it can commit until the upgrade is granted, and the lease gate after the upgrade sees it.
  • Claim cursors never move backwards.

AwaQueuePruneTwoPhase.tla checks this against one claimer; its no-recheck config finds the lease claim that commits during the upgrade.

Evidence

  • Soak, 14 personas, 25,000 abandoned claims per killed process, pg17 and pg18, deadlock_timeout = 400ms (posted below): no deadlocks on 2d19f207, no violation type that 0.6.16 does not also have, and each at the same or a lower count. Queue prune went from 1 to 12 prunes on pg17 and stayed at 0 on pg18; blocked remains 81–100% of real attempts.
  • Two earlier heads failed the soak. The first version held a writer lock across claim prune's check and deadlocked with heartbeat and retry transactions (129 deadlocks in 15 minutes locally). The second deadlocked with rescue over the slot row (35 and 18 on the large run). Each has a regression test here that fails on the earlier code.
  • A 10-minute local soak with deadlock_timeout = 1ms, which runs the detector on every lock wait, logged two deadlocks on this head: queue prune against a queue-depth read, and claim prune against the harness's own monitoring query. Prune was the statement cancelled in both.
  • Tests. test_prune_wins_against_overlapping_short_readers (five Blocked out of five on 0.6.16), test_queue_prune_sees_a_lease_committed_while_it_waits_to_truncate, test_claim_prune_does_not_deadlock_with_a_read_then_write_transaction, test_claim_prune_does_not_hold_the_slot_row_while_it_waits_for_partitions.

Trade-offs

  • Longer worst-case reader stall. A reader of a sealed partition can wait up to 320 ms behind one exclusive request, where it waited 10 ms before.
  • Claim prune still stalls readers for its check when it gets the lock.
  • Multi-partition reads in their own order can still cycle with prune. Prune gives way and reports blocked.
  • The lease ring prune is unchanged.

Tests changed

The three …_carried_by_concurrent_prune tests staged a read behind prune by holding the live done slot during the carry. Readers no longer wait there, so they now hold prune at the counter cleanup that follows the truncate.

…ildren

Holds ACCESS EXCLUSIVE on the children of a sealed queue-ring and claim-ring slot, as prune does, and runs claim, heartbeat, complete, retry, fail and snooze on one warmed-up connection. Ignored until the hot paths stop locking sealed children.

Refs #721
…721)

Queue and claim prune take SHARE ROW EXCLUSIVE on the sealed slot, run their checks under it, and upgrade to ACCESS EXCLUSIVE only for the truncate, retrying the upgrade with a doubling lock wait up to 320 ms. Queue prune re-checks active leases and pending ready rows after the upgrade and requests the segment partition first.
@coderabbitai

coderabbitai Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Pre-merge checks: Passed 1 · Inconclusive 1

Failed Failed checks: 1 inconclusive

Check name Status Explanation Resolution
Title check Pending This assessment has not completed. The check will be retried on the next pre-merge check run.
Passed Passed checks: 1 passed

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-11T19:15:13.840451Z 0f48051 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@hardbyte hardbyte added the full-ci Run the full CI matrix (Python build+test, E2E) on this PR label Nov 11, 2026 — with Claude

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1b5c03ecc9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread awa-worker/src/client.rs Resolved
…ent's wait

lock_timeout applies to each partition a LOCK TABLE statement takes, so a queue prune lock statement can wait eight times the single-wait ceiling.
Heartbeat, retry, fail and snooze read lease_claims and then update it in one transaction, so a writer lock held across the open-claim check deadlocked with them when prune asked for ACCESS EXCLUSIVE. Claim prune takes ACCESS EXCLUSIVE first, with the escalating wait, and a deadlock that cancels a prune is reported as blocked.
@hardbyte

Copy link
Copy Markdown
Owner Author

Benchmark A/B: 53df2af vs 0.6.16

53df2af (this PR's head) is 19–21% faster than 0.6.16 in every saturation cell. At 800 jobs/s its end-to-end p99 drops from 31 ms to 24 ms. release/0.6.6 just before #722 (0515f339) matches 0.6.16, so the gain comes from the #507/#508 backports. WAL-flush waits fall from about 20% of active database time to 5–8%.

cell build jobs/s, median of 3 (min–max) claim p99 ms e2e p99 ms Postgres cores WAL-flush waits
4 workers 0.6.16 403 (401–403) 10,076 10,076 1.12 20%
0515f33 404 (395–408) 10,215 10,220 1.12 20%
53df2af 484 (482–495), +20% 8,413 8,413 1.38 5%
16 workers 0.6.16 1,544 (1,530–1,565) 2,706 2,708 1.15 22%
0515f33 1,547 (1,525–1,549) 2,679 2,681 1.14 18%
53df2af 1,862 (1,828–1,912), +21% 2,238 2,241 1.43 6%
64 workers 0.6.16 5,199 (5,190–5,245) 922 924 1.27 21%
0515f33 5,210 (5,116–5,347) 851 853 1.27 20%
53df2af 6,202 (6,093–6,214), +19% 754 755 1.57 8%
800 jobs/s, 32 workers 0.6.16 799 29 31 0.56 18%
0515f33 798 28 30 0.57 12%
53df2af 799 22 24 0.56 7%
  • Saturation cells keep 4,000 jobs queued, so their claim and end-to-end p99 are mostly time spent in the queue. Throughput is the figure to compare there.
  • WAL-flush waits are IO:WalSync and LWLock:WALWrite samples as a share of all non-idle backend samples, taken from pg_stat_activity once a second.
  • Postgres cores rise on 53df2af because the database does more work per second instead of waiting on flushes. WAL bytes per job are flat or slightly lower (64 workers: 1,668 → 1,602).

Setup

  • Host: Ryzen 9 5900X (24 threads), 62 GB.
  • Postgres: postgres:18.6-alpine, capped at 4 CPUs, synchronous_commit = on, data on NVMe. The awa adapter runs natively.
  • Builds: each is the bench adapter built against that awa revision; the run manifests record the commit.
  • Sequence: three rounds, with the three builds interleaved in each round. Every cell starts from a fresh Postgres data directory.
    • Saturation cells: --producer-mode depth-target --target-depth 4000, 30 s warmup, 150 s measured.
    • The 800 jobs/s cell: 60 s warmup, 300 s measured.
  • Disk check: before and after every cell, a commit-latency probe timed 200 single-row autocommit INSERTs. Its p50 was 0.50 ms in 71 of 72 probes and 1.64 ms in one, between two 0515f339 cells; the cells either side were in line with the rest of their rounds.
  • Excluded run: an earlier pass with Postgres data on a SATA SSD swung 2× between rounds of the same build (3,341 vs 1,606 jobs/s at 64 workers), so it is left out.
  • Script: scripts/run_awa_ab.sh on the benchmarking repo's brian/full-comparison-awa-0616 branch.

The persona soak on 53df2af is running now: pg17 and pg18, 25,000 claims held per killed abandon process, log_lock_waits = on, deadlock_timeout = 400ms. A second pass with Postgres limited to 2 CPUs follows. I'll post prune outcomes, reader lock waits and the soak verdict when both finish.

@hardbyte

Copy link
Copy Markdown
Owner Author

Persona soak on 53df2af: first pass FAILED on pg17 and pg18; 0.6.16 baseline running

Settings:

  • All 14 personas, 900 s load per engine.
  • AWA_SOAK_ABANDON_CLAIMS=25000, with 4 abandon processes SIGKILLed 180 s apart.
  • SOAK_PG_ARGS="-c log_lock_waits=on -c deadlock_timeout=400ms". 400 ms is above the 320 ms escalation ceiling, so the deadlock detector doesn't run during prune's last escalation step.
  • Engines: postgres:17 and postgres:18, no CPU limit.

I don't yet know whether any of this is a regression. A pass on v0.6.16 with identical settings and the same harness files is running now (scripts/persona-soak-against.sh v0.6.16 pg17 pg18), and I'll post the side-by-side when it finishes. I stopped the SOAK_PG_CPUS=2 pass to run the baseline first.

Soak verdict

pg17 pg18
verdict FAIL FAIL
ran_after_cancel 0 0
promotion_stall 2 (worst 17.8 s) 2 (worst 22.1 s)
claim_ring_stalled 1: stuck at slot 0, generation 8, 6,000 expired claims in the next slot 1: stuck at slot 0, generation 8, 0 expired claims in the next slot
rescue_backlog_slow 0 3: 25,000 / 50,000 / 47,500 expired claims still open 122–170 s after kills 2–4; the oldest eligible for 534 s
oracle_incomplete 1 (Python migrate probe) 1 (Python migrate probe)

Prune outcomes (maintenance-metrics.jsonl, final cumulative counts)

ring pg17 pg18
queue pruned 0, blocked 80, skipped_active 35 (34 queue.unclosed_claim_refs), backed_off 828 pruned 0, blocked 54, skipped_active 18 (16 queue.unclosed_claim_refs), backed_off 394
claim pruned 1, blocked 77, skipped_active 34 (claim.open), backed_off 789 pruned 8, blocked 48, skipped_active 23, noop 3, backed_off 381
lease noop 896 noop 455

Blocked as a share of real attempts (pruned + blocked): queue 100% on both engines, claim 99% (pg17) and 86% (pg18). pruned barely moved over the run: the claim ring stayed at 1 on pg17 and reached 8 on pg18 only after about 17 minutes. These counts only cover runtimes in the test process. When the python or abandon-child process leads maintenance, its prunes are missing.

Deadlocks (deadlock_timeout = 400ms)

cycle pg17 pg18 statement cancelled
claim + prune 22 16 the claim, every time
rescue + prune 13 2 the rescue, every time

Every cycle has the same shape. Prune waits for AccessExclusiveLock on a relation, e.g. LOCK TABLE awa.lease_claims_N, awa.lease_claim_batches_N, awa.lease_claim_closures_N, awa.lease_claim_closure_batches_N IN ACCESS EXCLUSIVE MODE. At the same time the other backend waits for a ShareLock on prune's transaction, which means prune already holds a row lock it needs. The rescue cycles come from the rescue cursor walk (SELECT … FROM awa.claim_ring_slots WHERE slot = $1 FOR UPDATE). No admin read took part in any cycle.

Lock waits over 400 ms

Most are not readers queued behind prune. They are enqueue-side waits on per-lane sequence advisory locks:

wait pg17 pg18
advisory ExclusiveLock in awa.set_sequence_next 2,386 (p50 660 ms, max 7.7 s) 2,288 (p50 654 ms, max 7.4 s)
advisory ExclusiveLock in awa.reserve_enqueue_seq 2,038 (p50 640 ms, max 6.7 s) 1,777 (p50 637 ms, max 6.1 s)
AccessExclusiveLock on object (sequence functions and insert_job_compat) 74 (max 0.9 s) 601 (max 1.1 s)
ShareLock on a transaction (includes the deadlock waiters above) 62 (max 8.3 s) 67 (max 5.0 s)

@hardbyte

Copy link
Copy Markdown
Owner Author

Persona soak: 53df2af vs v0.6.16 under identical settings

Same settings as my previous comment: 25,000 claims held per abandon kill, deadlock_timeout = 400ms, pg17 and pg18, and the 53df2af harness files for both builds (persona-soak-against.sh v0.6.16).

Both builds FAIL under this setup, so the FAIL itself is not new in this PR. 53df2af has fewer rescue-backlog and promotion-stall violations. But it is the only build that deadlocks: 35 and 18 claim/rescue + prune cycles, against none on 0.6.16.

v0.6.16 pg17 53df2af pg17 v0.6.16 pg18 53df2af pg18
verdict FAIL FAIL FAIL FAIL
ran_after_cancel 0 0 0 0
promotion_stall 7 2 5 2
rescue_backlog_slow 4 0 3 3
largest backlog in a rescue violation (expired claims, oldest eligible) 49,892, 530 s – 62,250, 534 s 47,500, 534 s
claim_ring_stalled 1 1 1 1
deadlocks 0 35 (22 claim + prune, 13 rescue + prune) 0 18 (16 claim + prune, 2 rescue + prune)
queue prune: pruned / blocked 1 / 30 0 / 80 0 / 36 0 / 54
claim prune: pruned / blocked 7 / 47 1 / 77 0 / 40 8 / 48
lock waits over 400 ms logged 3,226 4,735 6,150 4,734

What's new in 53df2af: a prune lock-order cycle

  • Shape. Prune requests AccessExclusiveLock on a claim-ring child, e.g. LOCK TABLE awa.lease_claims_N, awa.lease_claim_batches_N, awa.lease_claim_closures_N, awa.lease_claim_closure_batches_N IN ACCESS EXCLUSIVE MODE. A claim or rescue backend holds a lock on that relation. That backend in turn waits for a ShareLock on prune's transaction, so prune already holds a row lock it needs.
  • Rescue side. The rescue cycles come from the cursor walk's SELECT … FROM awa.claim_ring_slots WHERE slot = $1 FOR UPDATE.
  • Who gets cancelled. The detector cancelled the claim or rescue statement in every cycle, never the prune.
  • In production. With PostgreSQL's default deadlock_timeout of 1 s, these would surface as one-second stalls ending in 40P01 on a claim or rescue.
  • Not involved. No admin read appears in any cycle. v0.6.16 logged none under the same settings.

What both builds share under a 25k abandoned-claim backlog

  • Prune almost never succeeds. The queue ring pruned at most once per run in all four runs, and "blocked" is 86–100% of real attempts (pruned + blocked) on both builds. The release agent's 35–50% blocked on 0.6.16 was presumably a lighter abandon setting.
  • The claim ring stalls once in every run.
  • Most logged lock waits are enqueue-side. They are advisory-lock waits in awa.set_sequence_next and awa.reserve_enqueue_seq (p50 about 650 ms, worst 6–8.6 s), not readers queued behind prune.

Caveats

  • These are single runs per engine. Seeds differ between builds because the soak picks a random seed per run; each reproducer.md has the seed.
  • Prune counts only cover runtimes in the test process.
  • The SOAK_PG_CPUS=2 pass hasn't run. I can run it next, or soak a fix for the cycle, whichever is more useful.

@hardbyte
hardbyte marked this pull request as draft November 11, 2026 13:09

Copy link
Copy Markdown
Owner Author

Marked as draft: not for 0.6.17 in this form.

The soak comparison above shows this PR does not raise the pruned share under a 25k abandoned-claim backlog and adds a deadlock that 0.6.16 does not have. Prune takes FOR UPDATE on the ring state row and the target slot row before it requests ACCESS EXCLUSIVE on the slot's partitions. A statement that already reads those partitions and then needs one of those rows (the rescue cursor walk's SELECT … FROM claim_ring_slots … FOR UPDATE is the confirmed case) forms a cycle with it. On 0.6.16 prune gives up after 10 ms; here it holds the cycle for up to 320 ms per attempt, so the escalating wait cannot win it and the other statement can be the one cancelled.

Next step for this PR is to take the partition locks before the ring row locks, identify which row the claim path waits on in the claim + prune cycles, and re-soak. The description's evidence and trade-off sections predate these results.


Generated by Claude Code

@hardbyte

Copy link
Copy Markdown
Owner Author

Persona soak, 53df2af with SOAK_PG_CPUS=2

These are the settings from my previous two comments (25,000 claims held per abandon kill, deadlock_timeout = 400ms), with Postgres limited to 2 CPUs. Both engines FAIL. I haven't run this pass on v0.6.16, so the new violation types below have no baseline yet.

pg17, unlimited pg17, 2 CPUs pg18, unlimited pg18, 2 CPUs
promotion_stall 2 45 2 66
rescue_backlog_slow 0 4 3 4
claim_ring_stalled 1 2 1 1
live_owner_rescued 0 1 0 1
ui_query_outlived_statement_timeout 0 1 0 1
ran_after_cancel 0 0 0 0
deadlocks: claim + prune / rescue + prune 22 / 13 5 / 4 16 / 2 2 / 4
queue prune: pruned / blocked 0 / 80 2 / 39 0 / 54 0 / 54
claim prune: pruned / blocked 1 / 77 7 / 43 8 / 48 1 / 45

New with the database starved

  • live_owner_rescued. Rescue closed claims belonging to an abandon child that was still alive.

    • pg17: 22 of its 25,000 claims, at 67 s.
    • pg18: 500 of its 17,500 claims, at 40 s.

    The soak runs heartbeat staleness at 12 s (the library default is 90 s), so heartbeats delayed on a 2-CPU server can cross that line. Even so, a live owner's claims were rescued, so those jobs can run twice.

  • ui_query_outlived_statement_timeout. An awa serve admin query stayed active for 51–52 s under a 30 s statement_timeout. All 10 of its sessions were active.

  • Very long advisory-lock waits on enqueue.

    • awa.set_sequence_next: up to 114 s on pg17 and 128 s on pg18.
    • awa.reserve_enqueue_seq: up to 47 s and 37 s.
    • Without the CPU limit both stayed under 8 s.
    • ShareLock waits on another transaction also reached 11–14 s.

Same as the unlimited run

  • The deadlock shape is unchanged. In each cycle a claim or rescue statement waits on prune's transaction while prune waits for AccessExclusiveLock on a claim-ring child, and the claim or rescue is always the side cancelled. There are fewer cycles here (9 and 6).
  • Prune still almost never gets through. Blocked is 86–100% of real attempts, and the queue ring pruned at most twice per run.

Single runs per engine, with random seeds (recorded in each reproducer.md). Server logs, maintenance-metrics.jsonl and the reproducers for all three 53df2af passes and the v0.6.16 baseline are on the benchmark host. Tell me which you want attached.

Rescue takes its table locks on a claim slot's partitions and then locks the slot's claim_ring_slots row. Claim prune held that row while it waited for ACCESS EXCLUSIVE on the partitions, so the two deadlocked for as long as prune waited.
The lock-order model listed rescue's cursor row before the claim partitions, following the SQL text, so it could not see the cycle with claim prune. Relation locks come first. Adds a witness for the 0.6.16 claim prune order and the current queue prune lock order.
@hardbyte

Copy link
Copy Markdown
Owner Author

Persona soak on 2d19f20: no deadlocks, and nothing worse than v0.6.16

Settings are identical to the 53df2af vs v0.6.16 comparison:

  • AWA_SOAK_ABANDON_CLAIMS=25000
  • deadlock_timeout = 400ms and log_lock_waits = on
  • pg17 and pg18, no CPU limit, all 14 personas, 900 s load per engine
v0.6.16 pg17 2d19f20 pg17 v0.6.16 pg18 2d19f20 pg18
verdict FAIL FAIL FAIL FAIL
deadlocks 0 0 0 0
ran_after_cancel 0 0 0 0
promotion_stall (worst overdue) 7 (58.9 s) 0 5 (28.3 s) 4 (15.3 s)
rescue_backlog_slow (largest backlog, oldest eligible) 4 (49,892, 530 s) 3 (25,000, 123 s) 3 (62,250, 534 s) 3 (37,455, 532 s)
claim_ring_stalled 1 1 1 1
oracle_incomplete (Python migrate probe) 1 1 1 1
queue prune: pruned / blocked 1 / 30 12 / 50 0 / 36 0 / 54
claim prune: pruned / blocked 7 / 47 7 / 67 0 / 40 0 / 64
lock waits over 400 ms 3,226 3,115 6,150 4,142
longest ShareLock (transaction) wait 3.3 s 0.9 s 1.1 s 0.8 s
longest AccessExclusiveLock wait 0.9 s 0.9 s 1.3 s 1.2 s
  • No new violation types. Every violation on 2d19f20 also appears on v0.6.16, at the same or a lower count.
  • Rescue and promotion are better. Rescue backlogs are smaller and promotion stalls fewer and shorter.
  • Queue prune improved on pg17, from 1 to 12 prunes, with the blocked share falling from 97% to 81%. pg18 didn't prune the queue ring on either build.
  • Most long lock waits are still enqueue-side, on the per-lane sequence advisory locks, the same as before.
  • Caveat: these are single runs per engine with different random seeds. Each reproducer.md has its seed: 2d19f20 pg17 1794422127108924054, pg18 1794423184876294182.

Correction to my earlier comments

I labelled 22 and 16 of the 53df2af deadlocks "claim + prune". They were deadline-rescue + prune: the statement was the deadline cursor walk, SELECT deadline_cursor_* FROM awa.claim_ring_slots WHERE slot = $1 FOR UPDATE, which my classifier mistook for a claim. Corrected counts for 53df2af:

run deadline-rescue + prune heartbeat-rescue + prune claims involved
pg17 22 13 0
pg18 16 2 0
pg17, 2 CPUs 5 4 0
pg18, 2 CPUs 2 4 0

@hardbyte hardbyte changed the title fix(queue-storage): let ring prunes win against overlapping readers (#721) fix(queue-storage): keep prune's checks off the exclusive lock and remove its lock-order cycles (#721) Nov 11, 2026
@hardbyte
hardbyte marked this pull request as ready for review November 11, 2026 19:12
@hardbyte
hardbyte merged commit f739a5a into release/0.6.6 Nov 11, 2026
34 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

- Queue and claim ring prunes no longer starve under sustained claim load (#721). Prune requested `ACCESS EXCLUSIVE` on the sealed slot's partitions for 10 ms, so with claims, completions and heartbeats always overlapping it never got the lock. That request now waits the prune lock timeout, lets waiting readers through, and retries with twice the wait up to 320 ms (`QueueStorage::prune_lock_wait_ceiling`) before reporting `blocked`. Queue prune also ran its safety checks under that lock, so when it did get it every reader of those partitions waited for the checks, which take seconds on a large slot. It now takes `SHARE ROW EXCLUSIVE` first, which blocks writes to the sealed slot and no reads, runs the checks, the failed-row carry and the rollup scan under it, and requests `ACCESS EXCLUSIVE` only for the `TRUNCATE`; after the upgrade it re-checks active leases and pending ready rows, which is what catches a lease committed while it waited. Queue prune requests the segment partition first because claims lock it first; a statement that holds a later partition while it waits for an earlier one still blocks that attempt until its timeout. Claim prune still runs its open-claim check under `ACCESS EXCLUSIVE`, because heartbeat, retry, fail and snooze read `lease_claims` and then update it in one transaction and would deadlock with a lock upgrade. A deadlock that PostgreSQL resolves by cancelling a prune is now counted as `blocked` rather than `error`. Readers of a sealed slot can now wait up to 320 ms behind a contended prune instead of 10 ms. The startup warning compares the 320 ms ceiling, not the base timeout, with `deadlock_timeout`, and eight times the ceiling with `statement_timeout`, because the lock timeout applies to each of the eight partitions one lock statement takes.

P2 Badge Correct the claim that sustained-load starvation is fixed

Under sustained traffic with a large abandoned-claim backlog, this release note promises that queue and claim ring prune starvation is resolved, but the newly added acceptance test at awa/tests/queue_storage_runtime_test.rs:23266-23274 is explicitly ignored because hot paths still lock sealed-slot children. The bounded retries improve overlap with short readers but can still produce predominantly Blocked outcomes when contention persists, so this should describe the change as a partial mitigation until that acceptance test passes.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

full-ci Run the full CI matrix (Python build+test, E2E) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant