Skip to content

perf: restructure poll query — filter due jobs first, fetch wide columns late - #139

Merged
bodymindarts merged 1 commit into
mainfrom
perf/poll-query-restructure
Jul 28, 2026
Merged

perf: restructure poll query — filter due jobs first, fetch wide columns late#139
bodymindarts merged 1 commit into
mainfrom
perf/poll-query-restructure

Conversation

@nicolasburtey

Copy link
Copy Markdown
Member

Motivation

Observed on a 10 loans/s lana-bank stress test (sandbox, stress-testing preset): the poll_jobs query (WITH eligible AS ...) had a window-mean execution time of 320–374ms — and climbing over a 15-minute soak — on a job_executions table with only ~1.5k live rows. It was the #2 DB-time consumer on the instance.

Why the old shape is expensive at load:

  1. eligible scanned all pending rows of the supported job types — including future-scheduled jobs — and ran the NOT EXISTS anti-join against the running set for every one of them, before any execute_at <= now filtering.
  2. eligible carried the wide execution_state_json JSONB; being referenced twice it was materialized, then scanned by min_wait and fed into the ROW_NUMBER() window sort — JSONB payloads included. Temp-file spill waits (BuffileWrite/Read) were observed in pg_wait_sampling.
  3. All of the above ran on every poll even though only LIMIT $1 rows are ever used.

Change

Restructure so the cheap, indexable filters run first and the expensive machinery only touches a bounded candidate set:

  • duestate='pending' AND job_type = ANY($4) AND execute_at <= now, ordered by execute_at, bounded by LIMIT $1 * 4 overscan. Served directly by the existing idx_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_jobsexecution_state_json is 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

  • Overscan under-fill: if more than $1 * 4 due jobs exist and many of the earliest are queue-blocked, a poll can return fewer than $1 jobs 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. * 4 is a guess — happy to tune.
  • min_wait less precise: a queue-blocked nearest future job now causes one early wake-up (the old query excluded blocked jobs from min_wait). Only matters while that queue stays busy.

Validation

  • .sqlx regenerated; full nextest suite passes locally.
  • test_await_completions_resolves_when_notifications_dropped is flaky under full-suite parallel load — verified it also fails on unmodified main locally (passes standalone); not introduced by this change.
  • Recommend an A/B sandbox run (custom-image pipeline) against the current baseline: poll mean 320→374ms, ~1 poll/s at ~10 jobs/s load.

Notes

  • Touches the same FOR UPDATE line as the SKIP LOCKED PR — whichever lands second needs a trivial rebase.

…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
nicolasburtey marked this pull request as ready for review July 28, 2026 13:10
@nicolasburtey
nicolasburtey force-pushed the perf/poll-query-restructure branch from f7173cc to 323d490 Compare July 28, 2026 13:11
@bodymindarts
bodymindarts merged commit fc3d1d3 into main Jul 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants