Bound the claim cursor discovery pass with a sequence watermark - #5
Open
jpcamara wants to merge 1 commit into
Open
Bound the claim cursor discovery pass with a sequence watermark#5jpcamara wants to merge 1 commit into
jpcamara wants to merge 1 commit into
Conversation
Claim cursors made fast polls cheap, but the cursor-free discovery pass that keeps them correct still scans from the low end of the polling index, so it costs O(graveyard) and stays the one term that grows without bound while the xmin horizon is pinned. On the system that motivated cursors it was 202,589 buffer hits per pass against 3,442 for a fast poll: roughly 77% of what polling still cost. Discovery now comes in two forms. A floored pass runs on the existing short cadence, bounded below by the ready_executions sequence high-water mark read just before the last unbounded pass. Every row allocated since then has a greater id, at every priority, so `id > floor` still finds a strictly-higher-priority arrival without rescanning the graveyard buried underneath it. The floor is a non-leading column of (priority, id) and so cannot be a boundary, but PostgreSQL still applies it inside the index and skips the heap visits that make the unbounded pass expensive. An unbounded pass runs on a new long cadence (claim_cursors_full_discovery_interval, 10 seconds by default), records the next floor, and remains the only pass that reaches a row sitting at or below the floor. Two rules keep the floored pass sound. It claims only the region below the cursor while the fast seek claims the region above it in the same poll, so the two stay disjoint and claims keep their (priority, id) order. And it never advances or clears the cursor -- it may only seed one an empty seek cleared -- because a pass that saw only part of the index would otherwise move the cursor over rows its floor had hidden, stranding them below it. Claiming in (priority, id) order is what makes that possible: the highest id a pass claims is not an id it has swept past, so a watermark that followed the claims would jump over unclaimed lower ids at other priorities. The floor's guarantee leans on ids being handed out in order, so it is recorded only where that holds: PostgreSQL 10+, whose catalog exposes the sequence's cache setting, with the cache at its default of 1. Anything else -- an older server, CACHE above 1, a table without its own sequence -- records no floor and keeps every pass unbounded. The full deadline is jittered only downward, so processes that booted together spread their graveyard scans without the interval ever ceasing to be an upper bound, and cursor state lives in a registry keyed by connection pool: floors and deadlines are positions in one table's id space and must not leak between datastores a process might poll. Floored claims also run with sort plans off (SET LOCAL enable_sort and, where it exists, enable_incremental_sort). With no visible rows and fresh statistics -- the pinned-horizon state itself -- every plan estimates near zero cost, and the tie can land on a legacy job_id polling index plus a sort that re-reads the whole graveyard; a sort can never beat a polling index that already provides the order. The settings live inside a savepoint, restored before it releases and reverted by PostgreSQL if it rolls back, so they cannot escape a caller's transaction. The tradeoff is healing latency. A rolled-back competing claim and a commit that lands after the floor was read both leave a row that only an unbounded pass can reach, and those now wait up to the full interval plus the short cadence it piggybacks on, rather than the short interval alone. That latency is a fixed, configurable constant rather than something that grows with the graveyard, which is why this is preferable to sweeping the graveyard incrementally. Setting the interval to 0 makes every discovery pass unbounded again. Pinned-horizon bench (200k unreapable dead tuples, heap decorrelated from id order as a long-lived table's is): discovery pass 200,283 -> 982 buffer hits and 49.1 -> 9.8 ms; a 200-poll loop at Solid Queue's default cadences 4,005,860 -> 418,406 buffer hits and 1,009 -> 360 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
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.
Stacked on
claim-cursors.Claim cursors made fast polls cheap. This bounds the cost of the discovery pass that keeps them correct — the one term that still grows without bound while the xmin horizon is pinned.
The problem
ReadyExecution.claimon the base branch has three query shapes. Measured on the PostgreSQL system that motivated cursors, with the xmin horizon pinned by long-running queries so vacuum cannot reap claimed ready executions:Discovery is ~77% of what polling still costs, and it is the only term bounded by nothing: the fast poll's cost is bounded by throughput × polling interval, while discovery scans from the low end of the
(priority, id)index — straight through the graveyard — every time it runs.Discovery can't simply be dropped or slowed down. It exists because a cursor is not a lower bound for live work:
SKIP LOCKED, this process advances past it, and that transaction then rolls back — the row is live again, below the cursor, where no fast seek will ever look.Discovery also does routine work: it finds strictly-higher-priority arrivals, which land below the cursor because their priority sorts first.
The design
Split those two jobs apart, because only one of them is rare.
A floored pass runs on the existing short cadence (
claim_cursors_discovery_interval, 1s). Its floor is thesolid_queue_ready_executionssequence high-water mark, read immediately before the last unbounded pass's claim query. Every row allocated since then has a greater id, at every priority, soid > floorstill finds higher-priority arrivals without rescanning the graveyard buried underneath them.An unbounded pass runs on a new long cadence (
claim_cursors_full_discovery_interval, 10s by default), records the next floor, and remains the only pass that can reach a row sitting at or below the floor. Setting the interval to0makes every discovery pass unbounded — byte-for-byte the base branch's behavior.Two rules keep the floored pass sound:
(priority, id) <= cursorwhile the fast seek claims(priority, id) > cursorin the same poll. Disjoint regions, discovery region first, so claims keep their global(priority, id)order.The floor is only recorded where its guarantee actually holds: PostgreSQL 10+ (whose
pg_sequencecatalog exposes the cache setting), sequence cache of 1 (PostgreSQL's default), and a table that owns its sequence. Anything else records no floor and keeps every pass unbounded — degradation, never an error. The cache is re-checked at every unbounded pass, soALTER SEQUENCE ... CACHEat runtime self-heals in both directions.Cursor state (positions, floors, deadlines) lives in a registry keyed by connection pool: these are positions in one table's id space and must not leak between datastores a process might poll. The full-pass deadline is jittered only downward (
interval - rand * 0.25 * interval), so worker fleets don't scan the graveyard in lockstep and the configured interval stays a hard upper bound.A plan-stability fix the benchmark caught
The base branch's additive schema keeps the legacy
(priority, job_id)polling indexes alongside the new(priority, id)ones. Under pinned-horizon statistics — zero visible rows, so every plan estimatesrows=1, cost≈8.3— the planner's choice for the floored query is a cost tie, broken arbitrarily. At 200k dead tuples it picked the legacy index withFilter: (id > floor): a heap fetch per dead tuple, 200,277 buffer hits, plus a Sort — the exact scan the pass exists to skip. Measured, not theoretical, and nondeterministic (a 40k graveyard chose well, 200k chose badly).A floored query only ever wants a polling index walked in
(priority, id)order; a sort-based plan can only re-read the graveyard. So the two floored claims run with sort plans off (SET LOCAL enable_sort, plusenable_incremental_sortwhere it exists) — inside a savepoint, with the previous settings captured first and restored before the savepoint releases, and reverted by PostgreSQL itself if it rolls back. The settings cannot escape a caller's transaction, other adapters and the classic path never reach this, and un-migrated schemas keep working because PostgreSQL treats disabled plan types as cost-penalized, not impossible. Disabled plans cannot tie with enabled ones, so the outcome is deterministic:Why no job can be stranded
Let
Fbe the floor andPthe cursor. Take any live, unclaimed rowR = (p, i).(p, i) > P, the fast seek covers it.i > F, the floored pass covers it — belowPvia the below-cursor sweep, and across all priorities via the seeding sweep when no cursor exists.Ris below the cursor and at or below the floor.Fwas read before the last unbounded pass's snapshot, soR's id was already allocated then; forRto be live and unclaimed now, it must be a rollback overshoot or a late commit. The unbounded pass reaches it within one (jittered-downward) full interval plus one short interval.So the floored pass loses nothing relative to unbounded discovery except healing those two anomalies; it introduces no new class of missed row. Supporting liveness facts: deadlines are recorded only after the claim transaction commits, so a raise leaves discovery due; a fresh or restarted process has no floor, and a nil floor forces an unbounded pass; a paused queue's key comes back with its deadlines expired; a replacement connection pool starts with no state and full-discovers on its first poll.
Designs rejected
A sound lower bound derived from PostgreSQL transaction state (
pg_snapshot_xmin/backend_xminplus a sequence watermark promoted once old xids resolve) — negative result. It needs an xid→sequence-id mapping PostgreSQL does not guarantee: xids are assigned at first write,nextvalneeds no xid, and aBEFORE INSERTtrigger can widen the gap arbitrarily. It would makeCACHE 1a correctness requirement rather than a degradation trigger. And fatally, it only bounds newly visible rows — a rolled-back claim resurrects a row committed long ago, which no xid-derived floor excludes. Sol (consulted at design time) confirmed the negative result independently.An incremental bounded sweep below the cursor. Slices must be key ranges (a row-count bound teaches you nothing when the region is pure graveyard), so per-pass cost is proportional to slice size — measured 3,625 buffer hits per 10k-id slice — and worst-case healing latency becomes
graveyard_size / slice_size × interval, which grows as the pathology worsens. A timed unbounded pass keeps that latency a fixed constant instead.Deriving the floor from the highest id claimed (the initial draft — rejected in design review). Because claims are ordered by
(priority, id), the highest id a pass claims is not an id it has swept past: with cursor(5,5000)and arrivalsA=(1,10000),B=(2,6000)at limit 1, claimingAand floating the floor to 10000 hidesBbelow both cursor and floor. The testsuccessive floored passes reach every arrival below the cursorfails if that variant is reintroduced — verified. The same review is why floored passes never move the cursor: advancing it from an id-filtered subset skips rows the filter hid.Benchmark
Synthetic pinned-horizon graveyard: 200,000 dead tuples confirmed "dead but not yet removable" by
VACUUM VERBOSE, four-index (additive) schema, measured through the realReadyExecution.claimpath withpg_stat_databasebuffer deltas. "Base" isclaim_cursors_full_discovery_interval = 0, i.e. the base branch's behavior exactly.The heap is deliberately decorrelated from id order, as a long-lived table's is (concurrent inserters each fill their own target pages). On a pristine append-only table an unbounded scan costs ~1 buffer per heap page; decorrelated it costs ~1 buffer per dead tuple — the regime the production numbers show. Measured 0.96 buffers per dead tuple, so the harness reproduces production's cost structure.
Discovery pass: 204x fewer buffer hits, 5x faster. A 200-poll loop at Solid Queue's default cadences (0.1s polling, 1s discovery, 10s full discovery): 9.6x fewer buffer hits, 2.8x faster — and 95.7x fewer hits than the pre-cursor poll. The bounded loop's remaining cost is almost entirely its two unbounded passes; everything between them is bounded by enqueue volume, not graveyard size.
The tradeoff
Rollback overshoot and late commits now heal within the full interval (plus the short cadence it piggybacks on) instead of within the short interval — roughly 10s instead of 1s at the defaults. Normal work, including strictly-higher-priority arrivals, keeps its ≤1s pickup latency. The healing latency is a fixed, configurable constant, independent of graveyard size — the property the incremental sweep could not offer. Raise the interval to spend less CPU while a horizon is pinned; lower it to heal faster;
0restores base behavior.The unbounded pass is still
O(graveyard)when it runs — its cost is amortized 10x and jitter-spread across the fleet, not eliminated.Review
Designed and reviewed in four rounds with a second model (GPT-5.6 "Sol", xhigh reasoning), which: confirmed the transaction-state lower bound is unsound, rejected the claim-following watermark with the counterexamples above, prescribed the sequence-watermark + timed-backstop shape, and in implementation review caught the PG-10 catalog dependency, the jitter-direction latency bug, the datastore key collision, the sequence-cache assumption, and the transaction-scope leak in the first version of the planner guard — all fixed and pinned by tests. The plan-stability defect itself was caught by the benchmark.
Verification
TARGET_DB=postgres bin/rails test test/models/solid_queue/claim_cursors_test.rb test/models/solid_queue/ready_execution_test.rb test/unit/worker_test.rb— 57 runs, 0 failuresTARGET_DB=sqlite bin/rails test test/models/solid_queue/ready_execution_test.rb— 19 runs, 0 failures (classic path untouched; cursors stay PostgreSQL-only)bundle exec rubocopon all changed files — no offensesClaimCursorsTesttests pass unchanged. Of the 13 new tests, 9+ fail with the production code reverted to base; the rest pin base-equivalent compatibility modes by construction.🤖 Generated with Claude Code