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
6 times, most recently
from
July 30, 2026 14:31
78b1c4a to
50b15c2
Compare
jpcamara
force-pushed
the
claim-cursors
branch
5 times, most recently
from
July 30, 2026 17:08
483429a to
a6faa23
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 floor held in process memory. The frequent pass (claim_cursors_discovery_interval, one second by default) claims only ids above the floor recorded before the last full pass, so when the planner seeks its cost tracks recent throughput rather than graveyard size (a sequential-scan plan caps at one heap pass), 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 floor is process memory -- the highest id this process observed before its last full pass -- so nothing is read from catalogs, sequences, or settings; a cached or rewound sequence merely delays a row until the next full pass, like a late commit, and a fresh process runs full passes until it has observed the stream. A floored pass never asks the database to order by priority: it reads a bounded window of region rows in id order over a (queue_name, id) index (typically a range seek from the floor; a degenerate-statistics plan caps at one heap pass), orders the window in memory, and locks its picks by bare id equality with no ORDER BY, giving no plan an ordering reason to walk an index through the graveyard. A region larger than the window keeps priority order only within it until the window slides or the next full pass reaches past it: an explicit, bounded slack. 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): never asks the database to order by priority. It reads a bounded window of region rows (id >the floor: the highest id this process had observed before the last full pass) inidorder over the new(queue_name, id)index (typically a range seek from the floor; a degenerate-statistics plan caps at one heap pass, still far below the classic per-tuple scan), orders the window in memory, and locks its picks by bare id equality with noORDER BY, a shape that gives no plan an ordering reason to walk an index through the graveyard. When the planner seeks, cost tracks rows enqueued since the last full pass and a drained region costs single-digit buffers; a sequential-scan plan caps at one heap pass per short cadence, which scales with table size — validate withEXPLAIN (ANALYZE, BUFFERS)in production. Every newly enqueued row lands above the floor, so pickup latency for fresh work — including strictly-higher-priority arrivals below the cursor — stays bounded by the short cadence at any polling interval, for rows within the window. A sweep that fills its claim limit leaves discovery due, so a higher-priority backlog below the cursor drains ahead of lower-priority work above it. A region larger than the window (500 rows) keeps(priority, id)order only within the window until it slides or the full pass reaches past it — an explicit, bounded, tested slack.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 floor (rollback overshoot, late commits, anomalous ids), and the only one allowed to move a cursor, healing stragglers within one full interval.The floor is process memory — the highest id observed before the last full pass — so nothing is read from catalogs, sequences, or settings. A cached or rewound sequence merely delays the affected row until the next full pass, like a late commit; a freshly booted process runs full passes until it has observed the stream.
Honest framing: the full pass still scans the graveyard once per full cadence (10s default), so polling is graveyard-resistant, not dead-tuple-immune — that cost is amortized, not 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 gain(priority, id),(queue_name, priority, id), and(queue_name, id), added alongside the old pair; the README includes the concurrent 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:On the reproducible idle-poll benchmark (200k dead tuples, all discovery passes included), idle polls drop from 18.1 ms to 0.53 ms — 34x. Because the frequent pass is cheap and sees all new work, this holds at any
polling_intervalshorter than the full-discovery cadence — including fleets that poll at 1s, where the short cadence coincides with every idle poll. (Polling at or beyond the full interval makes most passes unbounded.) Validate on your own data withEXPLAIN (ANALYZE, BUFFERS).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