Repository navigation
fix(driver): keep finalize plans stable under stale statistics - #77
Merged
Merged
Conversation
…dependency table maintenance
… on any connection
…l counters at once
Michael McQuade (giraffesyo)
force-pushed
the
fix/finalize-plans
branch
from
October 19, 2026 15:04
ebe26d9 to
e8e2948
Compare
…atistics every ten minutes
…cy analyze cadence
…the dependency analyze cap
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.
Summary
The
workflowhopperbench scenario was unstable: on an idle database at-jobs 100000, most runs drained in about 3 s and some took 20–48 s. Two problems in the finalize SQL (shared by hopperpgx and hoppersql) caused it.1. The finalize statement could scan every running row for each result. Its
retryandfinishedCTEs checkj.state = 'running', which matches the predicate of the partial indexhopper_jobs_running. The leader (or autovacuum) analyzes the backlog right after it is inserted, while nothing is running. With those statistics, scanning all ofhopper_jobs_runninglooks like a single page, so the planner reads the whole index and loops over the result list for every row. A slow finalize leaves more completed jobs sitting inrunning, so the next statement has even more rows to compare. This repeats until the next analyze, 30 s or more later.Fix: the running check compares the state as text (
j.state::text = 'running'). No partial index predicate matches that expression, so results always find their rows by primary key. The two progress statements and the cancel-running statement find one running job by ID, and use the same check throughisRunning(column). The cast is the standard form here: Postgres uses a partial index only for a condition on the identical expression, and checking the state after a primary-key match,= ANY ('{running}')or a subquery all still matched the index in EXPLAIN.2. The finalize tail and dependent promotion read whole tables. The tail joined
hopper_job_deps,hopper_jobsandhopper_batchesto its own CTEs. The planner guessed hundreds of rows for those CTEs (the recursive cascade alone was estimated at 180 rows, and it is usually empty).hopper_job_depsalso had no statistics, because nothing analyzed it during a short run. Under those estimates a sequential scan looked cheaper than index probes. The scan ran on every finalize and grew with the number of workflows in flight. The leader's promotion sweep had the same problem in a worse form: itsLIMIT $2makes a generic plan assume a tenth of the edges, so it scannedhopper_jobsonce per chunk.Fix:
workflowPromoteSQL) is now written as= ANY (ARRAY(SELECT ...)). The planner estimates ten keys for that form, so it always probes the index.readylooks each counted batch up by primary key in aLATERAL ... OFFSET 0subquery, because anANYthere rescanned the counts once per batch.JobsMaintainnow keepshopper_job_deps(vacuum at 100k dead rows; server statistics target). Churn re-analyzes it at most every ten minutes, and at once if it has never been analyzed, because what the planner reads from it changes only with the shape of the workflows and each analyze makes every connection replan the finalize statement. The recursive step of the cascade can't use theANYform, so it relies on these statistics.JobsMaintainreads all three tables' counters in one statement, by index.No schema change, so there is no migration. PLAN §8 (plan stability, live-table maintenance) is updated. Now that
workflowis stable, it is added to the perf gate, at 100,000 jobs (WORKFLOW_JOBS) so the gate's runtime stays reasonable.Plans (EXPLAIN ANALYZE, generic; custom is the same shape)
Captured from a slow run of canary's
workflowscenario, with 42,400 running rows and a batch of 100 results:Fixed, on the same statistics in a rebuilt state (40,000 running rows, 5,000 workflows in flight):
The leader's promotion sweep over a 1,000-edge chunk went from 543 ms to 4.3 ms (generic).
Testing
TestFinalizePlansIgnoreStaleStatistics(hopperpgx) builds 5,000 ten-step workflows, letsJobsMaintainanalyze them before anything runs, and then claims 2,000 jobs. It explains seven statements underforce_generic_planandforce_custom_plan: finalize with and without batches, follow-up and sweep promotion, both progress statements, and cancel-running. It asserts that none of them useshopper_jobs_runningand none sequentially scanshopper_jobs,hopper_job_depsorhopper_batches. On canary's SQL it fails with 3hopper_jobs_runningplans and whole-table scans in both modes.WorkflowsConcurrent(drivertest, both drivers) is a chaos test. It runs 100 fan-out/fan-in workflows (one workflow in five has a failing middle step) through 8 workers with small claims, so middle steps split across statements. Each worker follows up its own flushes, the leader's sweeps run alongside, and every statement is sent a second time. It asserts:Spot-checked against mutations. It fails if either promotion statement loses its live-dependency check, if the cascade misses pending jobs, or if edges of cascaded jobs are kept. It passed 15/15 runs under
-raceon both drivers.DepsTableMaintained(drivertest, both drivers):JobsMaintainanalyzeshopper_job_depsafter a burst of edges. Fails on canary.Existing finalize, fence, batch and fan-in concurrency tests pass.
make checkpasses.hopperbench
Base 8822336 against head, alternating (the side that goes first switches each round), 5 rounds,
-jobs 100000, latency with 1,000 samples. Each run started only when the 1-minute load average was under 8. The table shows the mean of the faster half of each side's runs (hopperbench -compare):workflowdrain time, median and spread:Checklist
make checkpassesGODEBUG=fips140=only)docs/PLAN.mdBench after review round 2 (workflow and throughput, alternating with 8822336, 5 rounds, load under 8)