[7/7] perf(control/pg): expand reusable callers and deferred matrices over their own rows
Stacked on #485 (PR 6/7). Merge in order; this PR targets pr/6-dependents-and-job-waiters-over-rows.
What changed
Reusable-workflow callers and deferred (fromJSON) matrices go through claim_expansion -> apply_expansion. Both used to load the whole run graph; apply_expansion also registered the new jobs into it, ran Sweep::sweep over the whole run, and flushed it. They no longer do.
- Expansion moves from
pg/dispatch.rsintopg/expansion.rs. claim_expansionreads the expanding job's own rows, aNeedsViewof the jobs itsneeds:reach (newNeedsView::outputs_by_basemerges outputs per base id, as the run record did), and one read of the run's planning inputs (submission, GitHub context, workspace snapshot, workflow identity).apply_expansionkeeps the run lock, theexpand_generationfence and the stale-claim discard. It writes the subtree's rows, then promotes only what the subtree can start through the existingpromote_dependents(from #485's stack; not duplicated). The run's status is stamped by a newconclude_run(summarize_run_tx, then workflow-hold release andrun.completed.v1on the first completion).settle_unrunuses it too; its writes and outbox order are unchanged.- The splice of an expanded matrix parent into callers'
inner_job_ids(and the persisted callerinner_job_ids) is kept; callers are found with one JSONB containment query instead of reading every spec row. rebuild_jobs/child_idsare deleted with their last callers. The row-insert helpers take run id, namespace and event instead of aRunGraph.
Wire and outbox output are unchanged.
Bug fixes this PR contains
Each is covered by a new shared-suite test (PG and lite) that fails on the old PG code:
- Legs and callee jobs with an already-satisfied
needs:never started. Registration setremaining_needs = needs.len(); a leg inherits its node'sneeds, which settled before it expanded, so it stayed blocked forever. Now recounted from the rows once the whole subtree exists. Test:expanded_legs_with_settled_needs_dispatch. - A failed expansion build left its dependents
Pending. They are now decided (skipped) in the same apply. Test:failed_deferred_expansion_skips_its_dependents. - An unhostable leg (no registered runner for its platform) tripped a debug assertion at registration and settled before the rest of its subtree existed. It now settles after, so later jobs of the subtree see it as terminal. Test:
unhostable_leg_fails_alone_at_expansion.
Also pinned: empty_deferred_matrix_skips_the_node_and_its_dependents (an empty matrix concludes the node skipped, once; this one already passed on the old code).
Behaviour change
A reusable-caller expansion now stamps the run through summarize_run_tx, so the run reads in_progress there rather than keeping its prior status.
Measurements
Plainly: all benchmark rounds were contaminated (laptop start load1 13-21 against a CLEAN limit of 10, from external Spotlight indexing; CPU was ~97% idle). Numbers are indicative only. Workload: a reusable caller whose callee has 200 independent jobs (one caller expansion per run), 3 rounds per arm, arms C / R-dep (#485 lineage) / R-rs (this PR, with the lock-hold instrumentation).
| C | R-dep | R-rs | |
|---|---|---|---|
graph loads in expansion_claim (per round) | 6.0 [5-8] | 8.3 [3-13] | 0 |
graph loads in expansion_apply (per round) | 6.0 [5-8] | 8.3 [3-13] | 0 |
expansion_apply run-lock hold, mean ms | 1130 [920-1390] | 1128 [915-1472] | 1572 [1280-1913] |
| completion hold, mean ms | 15.4 | 15.8 | 15.4 |
| completed / backlog | all / 0 | all / 0 | all / 0 |
| duplicate_inflight, names_running_twice, owner mismatch, sql_errors | 0 | 0 | 0 |
Graph loads on expansion went from 6-8 per round to 0. The apply run-lock hold was not better: 1572 ms vs ~1130 ms (within R-dep's range, contaminated, unprofiled; likely per-job promotion reads replacing the in-memory sweep, not confirmed). The whole-run load was ~15 ms of total per round in this workload, so the benefit is unmeasured: it should show where a small caller expands inside a large live run (wide-caller200, built but not measured). The deferred-matrix200 workload could not be compared on the old code, where the legs never start (bug 1).
Harness profiles reusable-jobs, deferred-matrix, wide-caller and reusable-matrix are local to the benchmark lab and not part of this PR.
NOT included
Four engine bugs found while measuring, identical on every arm, to be fixed in follow-up PRs:
- A deferred matrix inside a reusable callee fails ("job
call/shardnot found in workflow"): the node's plan is only stored when it has areusable_call, so the build falls back to the root workflow. - Static matrix legs inside a callee fail
apply_expansion(likely theparent_job_idFK: lite creates the missing parent row, PG does not; cause not confirmed). - Skipped plain jobs keep their request open, and an expanded matrix parent keeps a
jobs.request_idpointing at a purged request. - A callee job that settles during promotion, or an expansion that fails, does not fold its caller.
Test plan
Run on macstudio (own Postgres 17, max_connections=2000, ulimit -n 65536, PRELOOP_PG_WRITERS=3 PRELOOP_PG_READERS=3) at tip 40b26618:
cargo fmt --all --check: cleancargo clippy --workspace --all-targets -- -D warnings: cleancargo test --locked -p preloop-runner-server --lib -- control:: --nocapturewithPRELOOP_TEST_POSTGRES_URLset: 375 passed, 0 failed, 0skipping:lines (137 PG + 143 litecontrol::testsplus othercontrol::tests); the four new tests pass on both backendsjust test-ci: passed (exit 0,conform: PASS, 50test result: oklines, server lib 1068 passed / 3 ignored, 0skipping:). A first workspace run had one failure,control::tests::pg::prune_outbox_respects_slowest_consumer(attempt to subtract with overflowat tests.rs:4562, thexminvisibility race the test's own comment describes); it passed in the isolated control run and in the full rerun, and this PR does not touch outbox pruning.
Summary by cubic
Deferred expansion in Postgres no longer loads the run graph. Reusable-caller and fromJSON matrix expansions now read only the node's own rows, a NeedsView of the jobs its needs: reach, and the run's planning inputs; applying a built subtree writes the rows and promotes only the jobs the subtree can start (plus what their skips and failures cascade to) instead of sweeping the whole run. The run's status is stamped from the job rows after the apply.
Fixes three expansion bugs, each pinned by a new shared-suite test (PG and lite):
- Matrix legs and callee jobs with an already-satisfied
needs:now start; registration previously counted the need as outstanding, so they stayed blocked forever. - A failed expansion build now settles its dependents (skipped) in the same apply instead of leaving them pending; an empty matrix is reported skipped once.
- An unhostable leg no longer trips a debug assertion at registration and settles after the rest of its subtree exists, so later subtree jobs see it as terminal.
Behavior change
A reusable-caller expansion now stamps the run through summarize_run_tx, so the run reads in_progress there rather than keeping its prior status, and a run that newly completes releases its concurrency hold and emits run.completed.v1 exactly once.
Summary by CodeRabbit
- New Features
- PostgreSQL-backed runs now support deferred matrix and reusable workflow expansion. Newly generated jobs are scheduled as they become eligible, while dependencies continue to wait for all required jobs.
- Bug Fixes
- Jobs can now proceed when their dependencies were already satisfied before expansion.
- Failed or empty expansions now settle their dependents correctly.
- An unhostable job no longer prevents eligible sibling jobs from running; dependents settle after the remaining work finishes.
- Run completion events and concurrency release are handled when a run newly reaches a completed state.