perf: restructure poll query — filter due jobs first, fetch wide columns late - #139
Merged
Conversation
…mns late Observed under stress-testing load: poll mean of 320-374ms and climbing, on a ~1.5k-row table. The old shape computed 'eligible' (all pending jobs of the supported types, including future-scheduled ones) with a per-row NOT EXISTS anti-join against the running set, carried the wide execution_state_json through the materialized CTE (referenced twice) and the ROW_NUMBER window sort, and only then applied the execute_at <= now filter and LIMIT. All of that work happened on every poll even though only LIMIT rows are ever used. New shape: - 'due': due-only rows (execute_at <= now), index-ordered, bounded by LIMIT $1 * 4 overscan. The pending (job_type, execute_at) partial index serves this directly. - The queue anti-join and the queue-dedup window run only over that bounded due set. - execution_state_json is fetched by join-back only for the ~$1 selected rows. - min_wait is computed from the pending partial index without the anti-join. Slightly less precise (a queue-blocked nearest job can cause one early wake-up) but index-only. Trade-off to review: the LIMIT * 4 overscan means a poll can under-fill when many due jobs are queue-blocked; the poller loops immediately after a non-empty batch, so worst case is an extra cycle per poll. Note: test_await_completions_resolves_when_notifications_dropped is flaky under full-suite parallel load on main as well (verified locally — fails on unmodified main, passes standalone); not introduced here.
nicolasburtey
marked this pull request as ready for review
July 28, 2026 13:10
nicolasburtey
force-pushed
the
perf/poll-query-restructure
branch
from
July 28, 2026 13:11
f7173cc to
323d490
Compare
bodymindarts
approved these changes
Jul 28, 2026
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.
Motivation
Observed on a 10 loans/s lana-bank stress test (sandbox,
stress-testingpreset): thepoll_jobsquery (WITH eligible AS ...) had a window-mean execution time of 320–374ms — and climbing over a 15-minute soak — on ajob_executionstable with only ~1.5k live rows. It was the #2 DB-time consumer on the instance.Why the old shape is expensive at load:
eligiblescanned all pending rows of the supported job types — including future-scheduled jobs — and ran theNOT EXISTSanti-join against the running set for every one of them, before anyexecute_at <= nowfiltering.eligiblecarried the wideexecution_state_jsonJSONB; being referenced twice it was materialized, then scanned bymin_waitand fed into theROW_NUMBER()window sort — JSONB payloads included. Temp-file spill waits (BuffileWrite/Read) were observed inpg_wait_sampling.LIMIT $1rows are ever used.Change
Restructure so the cheap, indexable filters run first and the expensive machinery only touches a bounded candidate set:
due—state='pending' AND job_type = ANY($4) AND execute_at <= now, ordered byexecute_at, bounded byLIMIT $1 * 4overscan. Served directly by the existingidx_job_executions_pending_job_type_execute_at (job_type, execute_at) WHERE state='pending'partial index.candidates— the 1-at-a-time-per-queue anti-join and the queue-dedup window now run only over the bounded due set.selected_jobs—execution_state_jsonis fetched by join-back only for the ~$1 winners, never for the whole pending set.min_wait— computed straight from the pending partial index without the anti-join (index-only scan).Trade-offs to review
$1 * 4due jobs exist and many of the earliest are queue-blocked, a poll can return fewer than$1jobs even though eligible ones exist beyond the overscan. The poller loops immediately after a non-empty batch, so worst case is an extra poll cycle.* 4is a guess — happy to tune.min_waitless precise: a queue-blocked nearest future job now causes one early wake-up (the old query excluded blocked jobs frommin_wait). Only matters while that queue stays busy.Validation
.sqlxregenerated; full nextest suite passes locally.test_await_completions_resolves_when_notifications_droppedis flaky under full-suite parallel load — verified it also fails on unmodified main locally (passes standalone); not introduced by this change.Notes
FOR UPDATEline as the SKIP LOCKED PR — whichever lands second needs a trivial rebase.