Partial Indexes for Job Queue Polling #
Twenty workers poll a jobs table every 200 ms with SELECT … FOR UPDATE SKIP LOCKED LIMIT 10. The table holds 40 million rows, of which a few hundred are pending at any moment. On Monday the poll took 0.3 ms; by Thursday it takes 45 ms and the database’s CPU is dominated by it, even though the pending count has not changed.
A partial index is the right structure for this shape — the principles are in partial index implementation — but a queue exposes a failure mode that most partial indexes never meet: extremely high churn through a tiny live set.
The Planner Condition #
The poll:
SELECT id, payload FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
The index:
CREATE INDEX CONCURRENTLY jobs_queued_run_at_idx
ON jobs (run_at) WHERE status = 'queued';
The planner uses it because the query’s status = 'queued' implies the index predicate — the matching rules are in partial index predicate matching rules. Because the index is ordered on run_at, the scan returns jobs in ORDER BY order and the Limit can stop after ten locked rows. No sort, no scan of finished jobs.
The complication is how rows leave the index. A worker finishes a job with UPDATE jobs SET status = 'done'. The new row version does not satisfy the index predicate, so no new index entry is made — but the old entry remains, pointing to the now-dead version, until vacuum removes it. Every poll that walks the index from the oldest run_at passes through those dead entries first, fetching each heap tuple to check visibility. Index entries can be marked dead during scans (“killed” once the scanner sees that the tuple is dead to everyone), which helps, but only after all older transactions have finished, and long-running transactions anywhere on the server prevent it.
Annotated EXPLAIN Evidence #
Healthy, right after vacuum:
Limit (actual time=0.05..0.28 rows=10 loops=1)
-> LockRows (actual time=0.05..0.27 rows=10 loops=1)
-> Index Scan using jobs_queued_run_at_idx on jobs
(actual time=0.03..0.16 rows=14 loops=1)
Index Cond: (run_at <= now())
Buffers: shared hit=22
-- rows=14 under LockRows → 4 rows were skipped because other workers had them locked
-- 22 buffers for 14 rows: no dead entries in the way
Execution Time: 0.31 ms
Degraded, three days of churn:
Limit (actual time=44.9..45.2 rows=10 loops=1)
-> LockRows (actual time=44.9..45.2 rows=10 loops=1)
-> Index Scan using jobs_queued_run_at_idx on jobs
(actual time=44.8..45.0 rows=13 loops=1)
Index Cond: (run_at <= now())
Buffers: shared hit=61204
-- still only 13 rows produced, but 61,204 buffers touched
-- that gap is dead index entries and their heap tuples
Execution Time: 45.3 ms
Metric to watch: buffers divided by rows produced under LockRows. It should stay in single digits.
Step-by-Step Resolution #
-
Index the ordering column inside the partial predicate as shown above. Include
priorityfirst if polls order bypriority, run_at. -
Confirm the plan shape:
Limit → LockRows → Index Scanon the partial index, with noSortnode. -
Measure dead entries:
SELECT n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count FROM pg_stat_user_tables WHERE relname = 'jobs'; -
Vacuum the queue table far more often than the global defaults allow:
ALTER TABLE jobs SET ( autovacuum_vacuum_scale_factor = 0, autovacuum_vacuum_threshold = 5000, autovacuum_vacuum_cost_limit = 2000 );A zero scale factor makes the trigger a fixed number of dead tuples regardless of table size. The mechanics are in autovacuum thresholds and dead-tuple drift.
-
Find transactions holding back cleanup. Vacuum cannot remove tuples still visible to an old snapshot:
SELECT pid, now() - xact_start AS age, state, left(query, 60) FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 5; -
Move finished jobs out when churn is too high for vacuum to keep up: delete completed rows in the same transaction that finishes them and archive elsewhere, or partition the table by time and drop old partitions.
Before and After #
-- BEFORE: default autovacuum, 3 days of churn
Index Scan using jobs_queued_run_at_idx rows=13 Buffers: shared hit=61204 Execution Time: 45.3 ms
-- AFTER: autovacuum_vacuum_scale_factor = 0, threshold = 5000
Index Scan using jobs_queued_run_at_idx rows=14 Buffers: shared hit=31 Execution Time: 0.34 ms
Why delete-on-completion behaves better than update-on-completion #
Both approaches leave a dead index entry behind, so the difference is not in the partial index itself. It is in the heap and in vacuum’s workload. Updating the row to done creates a new live version that stays in the table forever, so the table grows with every job and each autovacuum pass has more pages to consider. Deleting the row leaves only a dead version, which vacuum removes, and the table stays roughly the size of the live queue plus recent churn. Smaller tables are vacuumed faster, so dead index entries are cleared sooner and polls stay cheap.
Where completed jobs must be kept, write them to a separate history table in the same transaction that deletes them from the queue. The queue table then holds only work in progress, and the history table can be append-only, partitioned by time and indexed for reporting without affecting polling.
Priority queues and multiple partial indexes #
Queues that serve several job kinds or priorities often poll with different predicates: WHERE status = 'queued' AND queue = 'email', WHERE status = 'queued' AND priority >= 5. A single partial index on run_at still helps, but each poll filters out jobs of other kinds as it walks the index, and a busy kind can bury a quiet one. Two better shapes exist. Put the discriminating column first — (queue, run_at) WHERE status = 'queued' — so each poll starts at its own section of the index. Or create one partial index per hot queue, WHERE status = 'queued' AND queue = 'email', which keeps each index tiny. Either way, check that polls for the quietest queue do not show a large Rows Removed by Filter, which would mean they are paying for another queue’s backlog.
Common Pitfalls #
Indexing status instead of the ordering column. An index on (status) finds queued jobs but returns them unordered, adding a sort of every pending row per poll. Diagnostic signal: a Sort above the index scan. Fix: index run_at with WHERE status = 'queued'.
Omitting the predicate from the query. A poll that says status IN ('queued', 'retry') does not match WHERE status = 'queued'. Diagnostic signal: a sequential scan or a different index. Fix: align the index predicate with the query, or create one index per state.
A long transaction on a replica with hot_standby_feedback. It holds back vacuum on the primary. Diagnostic signal: n_dead_tup climbing despite frequent autovacuum runs. Fix: find and bound long transactions on replicas too.
Using LIMIT 1 with many workers. Workers contend for the same first row and skip past each other’s locks. Diagnostic signal: LockRows child producing many more rows than LIMIT. Fix: fetch a small batch per poll, or randomise among the oldest jobs.
Frequently Asked Questions #
Why does my queue poll get slower even though the queue is short? Completed jobs leave dead entries in the partial index until vacuum removes them, and each poll walks past them and checks their heap tuples. Polling cost tracks churn since the last vacuum, not queue length.
What does LockRows mean in the plan?
It applies FOR UPDATE to each row from its child. With SKIP LOCKED, rows locked by another worker are discarded, so the child may produce more rows than the LIMIT.
Should the partial index include the ORDER BY column?
Yes. Indexing run_at inside the partial index lets the scan return jobs in order and stop at the limit instead of sorting every pending job.
Related #
- Partial Index Implementation — parent guide: predicates, eligibility and sizing
- Partial Indexes for Soft Deletes — sibling: the low-churn version of the same idea
- Autovacuum Thresholds and Dead-Tuple Drift — tuning the vacuum trigger this workload depends on