perf+fix(graph-maintenance): catch the #2529 sweep deadlock in continuous perf, and drive dropped passes to zero#2534
Open
nicoloboschi wants to merge 6 commits into
Open
Conversation
prune_stale_cooccurrences/prune_orphan_entities scan entity_cooccurrences via a join/NOT EXISTS plan with no consistent lock-ordering guarantee, while retain's concurrent cooccurrence upserts (entity_resolver) lock the same rows in sorted (entity_id_1, entity_id_2) order. When the sweep and a concurrent upsert touch overlapping rows in opposite orders, Postgres detects a genuine cycle and aborts one side with DeadlockDetectedError — this was 39 of 41 DeadlockDetectedError occurrences in a week of self-hosted production logs. Both prunes are idempotent bank-wide deletes, so wrap the sweep in the existing retry_with_backoff helper (already deadlock-aware, previously only used internally by acquire_with_retry's legacy pool path) instead of letting a transient deadlock drop the maintenance pass entirely. Adds a raw two-connection reproduction of the deadlock plus a test that the sweep now survives one transient DeadlockDetectedError and still returns correct prune counts. Co-authored-by: Cursor <cursoragent@cursor.com>
…sweep deadlock The existing graph-maintenance suite runs run_graph_maintenance_job in isolation, so its Pass 2/3 cooccurrence sweep never overlaps a concurrent writer and can never deadlock — which is why continuous perf never caught #2529. The new graph-maintenance-contention suite drives prune_stale_cooccurrences against retain-shaped sorted cooccurrence upserts and gates on the deadlock escape rate (dropped/observed): ~100% unprotected (fails), ~0% with the retry_with_backoff fix (passes).
…so deadlocks stop dropping passes Completes #2529. The retry wrap alone still let ~14% of sweep deadlocks escape under sustained retain contention (perf suite, small scale): the backoff was deterministic (concurrent retriers woke in lock-step and re-collided) and capped at 3 attempts. - db_utils.retry_with_backoff: add equal-jitter to the backoff delay so contenders that deadlock together don't retry in sync (benefits every retrier, incl. the legacy acquire path). Covered by a new pure-function unit test. - graph_maintenance: give the idempotent Pass 2/3 sweep a larger retry budget (8) — it's background work with no client waiting, so a longer jittered tail beats dropping a pass and leaking stale graph rows. graph-maintenance-contention perf suite now measures 0% escape (0 dropped) at small and medium vs ~100% unfixed; sweep_workers capped at 2 (prod dedups to one maintenance job per bank, so 3+ concurrent sweeps was an unfaithful amplifier).
…e via ordered locking Prototype: instead of only retrying the deadlock, eliminate the lock-order inversion that causes it. prune_stale_cooccurrences selects its victim rows in the same sorted (entity_id_1, entity_id_2) order retain's cooccurrence upsert locks them (a materialised FOR UPDATE CTE puts LockRows above the Sort), then deletes the already-locked rows. Same lock order on both sides => no cycle. - ops_postgresql: ordered-lock CTE prune. PG only — Oracle's DELETE can't carry the CTE the same way, so it stays on the ORA-00060 retry path (documented). - system_perf contention suite: hollow-run guard re-keyed on workloads running (upserts+sweeps>0) not deadlocks>0, so a source-level fix (0 deadlocks) passes; escape-rate denominator now max(observed,dropped). Verified (small): 0 deadlocks either side, 0 dropped, 200 upserts + 336 sweeps concurrent, 10s vs ~30s retry path; full-revert regression still FAILs 100% escape.
…ses (code-review) - _run_sweep returned a bare tuple[int, int] (from #2529's base commit); the project bans multi-item tuple returns even for private fns. Return a small _SweepCounts dataclass instead. - contention suite's shared counters were a raw dict with known keys; convert to a _ContentionCounters dataclass, matching the file's existing style (_GraphMaintTimers). No behaviour change; 18 graph-maintenance tests + perf smoke (0 deadlocks, prevented-at-source) still green.
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
Supersedes / completes #2529. That PR fixed the
entity_cooccurrencessweep deadlock by wrapping Pass 2/3 ofrun_graph_maintenance_jobinretry_with_backoff. This PR includes @jordigilh's original fix commit unchanged, then answers three follow-ups:1. Why continuous perf missed it — and a suite that catches it
The existing
graph-maintenanceperf suite runsrun_graph_maintenance_jobin isolation (populate → delete → one pass, zero concurrent writers). But theDeadlockDetectedErroris a contention phenomenon: it only occurs whenprune_stale_cooccurrences' unorderedDELETEscan overlaps retain's sorted(entity_id_1, entity_id_2)cooccurrence upsert (entity_resolver._flush_pending). With no concurrent upsert there is no lock cycle, so the isolated suite structurally cannot reproduce it. And in production a losing deadlock silently drops a maintenance pass — visible only as slow graph bloat over time, never as a latency number. Coverage gap, not a tuning gap.New suite
graph-maintenance-contention(hindsight-dev/benchmarks/perf/system_perf.py) closes it: it seeds a hot set of stale cooccurrence pairs, then drives retain-shaped sorted upsert loops against loops callingrun_graph_maintenance_job, and counts how many maintenance passes the resulting deadlock silently drops. The gate metric is the deadlock escape rate (dropped / max(observed, dropped)) — not a harddropped == 0, so it tolerates a small retry tail while still cleanly separating a full regression.2. Prevent the deadlock at the source (ordered locking)
Rather than only retrying, this PR removes the lock-order inversion that causes the cycle.
prune_stale_cooccurrences(PostgreSQL) now selects its victim rows in the same sorted(entity_id_1, entity_id_2)order that retain's upsert locks them — a materialisedFOR UPDATECTE putsLockRowsabove theSort, so locks are acquired ascending on both sides — then deletes the already-locked rows. Same order ⇒ no cycle. This mirrors how #2353 (queue) andentity_resolveralready avoid their deadlocks (consistent lock ordering, not retries).DELETEcan't carry that ordered-lock CTE the same way, so it stays on the ORA-00060 retry path (documented inops_oracle.py). Deliberate dialect asymmetry.3. Retry hardening — the backstop
Ordered locking covers the dominant path (the 39/41 prod deadlocks). The
retry_with_backoffwrap stays as the backstop for the residual paths (theprune_orphan_entitiesFK cascade, and Oracle), now hardened so it effectively never drops a pass on those either:retry_with_backoffso concurrent retriers don't wake in lock-step and re-collide (benefits every caller). Covered by a new pure-function unit test.Verified (perf suite,
--scale small)With ordered locking the deadlock is prevented — 0 on both the sweep and upsert side — while 200 upserts + 336 sweeps genuinely ran concurrently, and it's ~3× faster (no backoff sleeps). Reverting all three fixes to
mainreturns the suite to 100% escape / FAIL, so it still catches a full regression.Note on the mechanism: "
LockRowsaboveSort" is the documented/expected PG plan for a top-levelORDER BY … FOR UPDATE, not a hard contract — verified empirically (0 deadlocks across repeated runs), not assumed.Tests
Perf suite (embedded pg0, no external setup):
ruff/ lint hooks clean on all changed files.Notes for reviewers
SUITESset, soperf-test.yml's daily run gates on it going forward. If this lands before the fix onmain, the daily perf job correctly goes red until it does.