Repository navigation
fix(queue-storage): keep prune's checks off the exclusive lock and remove its lock-order cycles (#721) - #723
Conversation
…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.
Pre-merge checks:
|
Codex Review Summary: ✅ CompletedThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
…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.
Benchmark A/B: 53df2af vs 0.6.1653df2af (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.
Setup
The persona soak on 53df2af is running now: pg17 and pg18, 25,000 claims held per killed abandon process, |
Persona soak on 53df2af: first pass FAILED on pg17 and pg18; 0.6.16 baseline runningSettings:
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 ( Soak verdict
Prune outcomes (
|
| 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) |
Persona soak: 53df2af vs v0.6.16 under identical settingsSame settings as my previous comment: 25,000 claims held per abandon kill, 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.
What's new in 53df2af: a prune lock-order cycle
What both builds share under a 25k abandoned-claim backlog
Caveats
|
|
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 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 |
Persona soak, 53df2af with
|
| 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. Anawa serveadmin query stayed active for 51–52 s under a 30 sstatement_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.
ShareLockwaits 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
AccessExclusiveLockon 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.
Persona soak on 2d19f20: no deadlocks, and nothing worse than v0.6.16Settings are identical to the 53df2af vs v0.6.16 comparison:
Correction to my earlier commentsI labelled 22 and 16 of the 53df2af deadlocks "claim + prune". They were deadline-rescue + prune: the statement was the deadline cursor walk,
|
There was a problem hiding this comment.
💡 Codex Review
Line 25 in 0f48051
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".
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
SHARE ROW EXCLUSIVEon the sealed slot's partitions, runs the skip gates, the failed-row carry and the rollup scan under it, and requestsACCESS EXCLUSIVEonly for theTRUNCATE.SHARE ROW EXCLUSIVEwaits 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.ACCESS EXCLUSIVErequest runs from a savepoint: it waitsprune_lock_timeout, rolls back so queued readers proceed, and retries with twice the wait up to 320 ms (orprune_lock_timeoutwhen that is longer) before reportingblocked.claim_ring_slotsrow without locking it and takesFOR UPDATEonly once it holds the partitions. Rescue takes the partitions and then that row.ACCESS EXCLUSIVE. Heartbeat, retry, fail and snooze readlease_claimsand then update it in one transaction, which deadlocks with a lock upgrade.blocked, noterror.deadlock_timeoutis compared with the longest single prune lock wait (QueueStorage::prune_lock_wait_ceiling), andstatement_timeoutwith eight times that.AwaStorageLockOrder.tlalisted 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
ready_claim_attempt_batchespartition 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.AwaQueuePruneTwoPhase.tlachecks this against one claimer; its no-recheck config finds the lease claim that commits during the upgrade.Evidence
deadlock_timeout = 400ms(posted below): no deadlocks on2d19f207, 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.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.test_prune_wins_against_overlapping_short_readers(fiveBlockedout 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
blocked.Tests changed
The three
…_carried_by_concurrent_prunetests 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.