Claim cursors: graveyard-resistant polling on PostgreSQL - #4
Open
jpcamara wants to merge 1 commit into
Open
Conversation
This was referenced Jul 29, 2026
|
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. |
jpcamara
force-pushed
the
claim-cursors
branch
5 times, most recently
from
July 30, 2026 06:06
9038edd to
78b1c4a
Compare
Long-running queries pin the xmin horizon, vacuum cannot reap claimed ready executions, and the poll query scans from the low end of the polling index -- exactly where claimed rows go to die -- so every poll burns CPU proportional to how long the horizon has been pinned. Each process now remembers one lexicographic (priority, id) position per queue key: fast polls make a single row-constructor index seek past the dead-tuple graveyard across all priorities, an empty seek clears the position and suppresses claim queries entirely, and cursor-free discovery seeds and heals the position. The cursor is not a lower bound for live work (a lower-id claim can roll back after we advance past it, sequences are not commit-ordered, and an empty SKIP LOCKED seek can mean rows were merely locked elsewhere), so discovery is fundamental to finding work rather than defensive healing. Discovery itself is bounded by a sequence watermark. The frequent pass (claim_cursors_discovery_interval, one second by default) claims only ids above the watermark recorded before the last full pass, so its cost tracks recent throughput instead of graveyard size while still finding all newly enqueued work. Only the full pass (claim_cursors_full_discovery_interval, ten seconds by default, zero disables the split) pays the whole scan, healing rollback overshoot and late commits within one full interval. The watermark is trusted only where its guarantee holds -- PostgreSQL 10+, an owned id sequence with cache 1 -- and degrades to full passes otherwise. Floored claims run with sorts disabled inside a savepoint so pinned-horizon statistics cannot cost-tie the planner onto a filtered legacy-index scan. Claim ordering within a priority changes from enqueue order (job_id) to readiness order (id), matching the cursor's index position; the new polling indexes are added alongside the old pair so a generated schema matches a migrated one, and the README includes the concurrent migration. Row-constructor seeks and the motivating pathology are PostgreSQL-specific, so other adapters keep the classic path; config.solid_queue.claim_cursors = false disables everything. Pinned-horizon bench (200k dead tuples): the frequent discovery pass drops from 49ms and 200k buffer hits to 9.8ms and 1k, fast polls touch single-digit buffers, and a 200-poll loop touches 95.7x fewer buffers than classic polling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Solves the PostgreSQL dead-tuple pathology: long-running queries pin the xmin horizon, vacuum can't reap
solid_queue_ready_executions, and every poll re-scans the graveyard at the low end of the polling index — CPU proportional to how long the horizon stays pinned. Claim cursors let polls seek past the graveyard instead of scanning through it.Design
One claim cursor per queue key: each process remembers the last
(priority, id)position it observed. Fast polls make a single lexicographic row-constructor seek —WHERE (priority, id) > (?, ?) ORDER BY priority, id— which descends the polling index past the entire graveyard in one shot, across all priorities. When a seek comes up empty, the position is cleared and polls skip the claim query entirely until discovery is due.Discovery is fundamental, not a safety net: the cursor is not a lower bound for live work — a competing claim of a lower id can roll back after this process advanced past it, sequences aren't commit-ordered so a row can allocate a lower id and commit late, and an empty
SKIP LOCKEDseek can mean the remaining rows were merely locked by a peer. Cursor-free discovery therefore runs on a time-based cadence and comes in two forms:claim_cursors_discovery_interval, 1s default): claims onlyid >the sequence watermark recorded before the last full pass, so its cost tracks recent throughput instead of graveyard size. Every newly enqueued row lands above the watermark, so pickup latency for fresh work — including strictly-higher-priority arrivals below the cursor — stays bounded by the short cadence at any polling interval.claim_cursors_full_discovery_interval, 10s default, jittered downward so co-booted workers don't scan in lockstep;0disables the split): the only pass that reaches rows at or below the watermark (rollback overshoot, late commits), and the only one allowed to move a cursor, healing stragglers within one full interval.The watermark is trusted only where its guarantee holds — PostgreSQL 10+, an owned id sequence with
CACHE 1— and degrades to all-full passes otherwise. Floored claims run with sorts disabled inside a savepoint (settings restored before release, reverted by rollback) because pinned-horizon statistics make every plan cost-tie and the tie can land on a filtered legacy-index scan that re-reads the graveyard the floor exists to skip.Honest framing: discovery still scans the graveyard once per cadence, so polling is graveyard-resistant, not dead-tuple-immune — the cost is amortized to O(graveyard/cadence) rather than eliminated. The other trade: a strictly-higher-priority arrival waits for the next discovery (≤ ~1s), inside the slack
FOR UPDATE SKIP LOCKEDalready implies.Ordering change (deliberate): within a priority, claims follow readiness order (
id) instead of enqueue order (job_id). Observable difference: a scheduled job coming due queues behind already-waiting same-priority jobs rather than jumping ahead of them. Polling indexes move to(priority, id)/(queue_name, priority, id); the README includes the migration for existing installs.PostgreSQL only: row-constructor index seeks and the motivating pathology are PostgreSQL-specific. Other adapters keep the classic path untouched.
config.solid_queue.claim_cursors = falsedisables.Benchmark
Pinned horizon, 200k unremovable dead tuples in
ready_executions(confirmed via VACUUM VERBOSE, heap decorrelated from id order), realReadyExecution.claimpath:The floored pass costs 204x fewer buffer hits than the scan it replaces; the whole polling loop touches 95.7x fewer buffers than pre-cursor polling. Because the frequent pass is cheap and sees all new work, this holds at any
polling_interval— including fleets that poll at 1s, where the short cadence coincides with every idle poll.Testing
ClaimCursorsTest(PG-gated) asserts query counts and shapes via asql.active_recordcapture: single-seek claiming across priorities, instant same-priority pickup, empty seek clearing the position and suppressing queries, higher-priority arrivals found once discovery is due (deterministicexpire_discovery!, no sleeps), overshoot healing via discovery, readiness-order semantics, flag-off classic behavior🤖 Generated with Claude Code