fix(worker): serialise graph_maintenance per bank at claim time - #3235
Open
nicoloboschi wants to merge 1 commit into
Open
fix(worker): serialise graph_maintenance per bank at claim time#3235nicoloboschi wants to merge 1 commit into
nicoloboschi wants to merge 1 commit into
Conversation
Every graph_maintenance run is the same bank-wide sweep: the payload carries only bank_id, run_graph_maintenance_job discards the request context, and the relink pass drains the whole queue. A second concurrent run for one bank adds no work — and claim_graph_maintenance_batch locks queue rows FOR UPDATE with no SKIP LOCKED precisely because it assumes a single runner per bank, so the runs convoy on each other while each holds a worker slot. Same guarantee consolidation already gets from its busy-bank exclusion, applied as a predicate on the existing claim queries rather than a claim phase of its own. graph_maintenance has no reserved-slot floor and the poller's fairness pass claims with shared_limit=1, so a trailing phase would drop it below every other operation type and let a single pending retain starve it; as a predicate it keeps competing by created_at. The predicate also takes at most one same-bank row per batch. Excluding busy banks alone does not cover that: with several pending rows and nothing yet processing, one batch claims them all. Several pending rows per bank are reachable through the recovery paths — recover_own_tasks resets all of a worker's processing rows at once, plus _schedule_retry / _defer_operation / admin recover. Fixes #3230
nicoloboschi
force-pushed
the
fix/3230-graph-maintenance-bank-serialisation
branch
from
August 7, 2026 09:58
73f2ce2 to
953caa2
Compare
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
graph_maintenancehas no per-bank serialisation at claim time. Two concurrent runs against one bank do no extra work — the payload carries onlybank_id,run_graph_maintenance_jobdiscards the request context, and the relink pass drains the whole queue — so both runs are the same bank-wide sweep.It is worse than redundant.
claim_graph_maintenance_batchlocks queue rowsFOR UPDATEwithoutSKIP LOCKED, deliberately, because it is written assuming one runner per bank (its docstring said submit-time dedup guaranteed that; it does not — dedup only inspects'pending'rows). So the runs convoy on each other's row locks while each holds a worker slot. Reported in #3230: nine concurrent runs on one bank, a six-deep blocking chain, retain starved to zero for ~4h.The fix
The same guarantee
consolidationalready has, expressed as a predicate on the existing claim queries and shared between both dialects (graph_maintenance_bank_serialization_sqlindb/ops.py).Two things differ from the consolidation form, both forced by the shape of the problem:
It is a predicate, not a separate claim phase. A trailing phase works for
consolidationonly because it has a reserved-slot floor of 2 (WORKER_SLOT_TYPE_DEFAULTS);graph_maintenancehas 0, and the poller's fairness pass callsclaim_taskswithshared_limit=1(poller.py:459). A single pending retain would then consume the shared slot in the generic query andgraph_maintenancewould never be claimed at all — the queue grows, links are never topped up. As a predicate on that same query it keeps its place in thecreated_atordering.test_not_starved_by_newer_pending_workcovers this.It also takes at most one same-bank row per batch. Excluding busy banks alone does not cover that: with several pending rows and nothing yet
processing, one batch claims them all — the convoy, unchanged. Several pending rows per bank are reachable through the recovery paths:recover_own_tasksresets every processing row for a worker back to pending in one statement, and_schedule_retry/_defer_operation/hindsight-admin recovereach restore rows independently. That, not the submit path, is where #3230's 22 simultaneously-pending rows come from — submit-timededupe_by_bankis atomic (it takesFOR NO KEY UPDATEon the bank row before the check), so it yields at most one pending row per bank.Inherited caveat, unchanged from
consolidation: a row wedged inprocessingholds its bank until something releases it —hindsight-admin recover, or a restart with a stableHINDSIGHT_API_WORKER_IDsorecover_own_tasksmatches it. That is a general gap in claim recovery (there is no time-based stale-claim reaper for any operation type), not something specific to graph_maintenance, and it is not addressed here.No advisory locks (#2817), no new table, no new config flag.
Relationship to #3231
Same issue, different mechanism. #3231 adds a dedicated claim phase after the generic shared-pool query, which introduces the starvation described above. This supersedes it.
Tests
tests/test_graph_maintenance_claim_serialization.py, real Postgres, 9 tests. These callops.claim_tasksdirectly rather thanWorkerPoller.claim_batch, so the slot limits under test are exact and not a function of ambient in-flight work in the shared test DB.Fail on
main, pass with the fix:test_not_claimed_while_bank_has_run_in_flighttest_single_batch_claims_at_most_one_per_bank(also asserts it is the oldest row)test_idle_bank_still_claimed_while_another_is_busytest_reserved_pool_is_serialised_tootest_reserved_and_shared_phases_do_not_double_claimGuard against over-blocking (pass both ways — these are what a naive guard breaks):
test_not_starved_by_newer_pending_worktest_retry_blocked_older_row_does_not_block_a_claimable_onetest_other_operation_types_unaffectedDialect parity:
test_guard_survives_the_oracle_sql_rewriteasserts on_rewrite_pg_to_oracle's output directly (NOW()→SYSTIMESTAMPin both the outer query and the correlated subquery,!= ALLexpanded,LIMIT→ROWNUMinjected on the outerWHERErather than the subquery's). Oracle integration tests need anORACLE_TEST_DSNI don't have, so this is the check that the shared fragment stays valid there.Fixes #3230