[7/7] perf(control/pg): expand reusable callers and deferred matrices over their own rows

BN
9d ago
+1139-666

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.rs into pg/expansion.rs.
  • claim_expansion reads the expanding job's own rows, a NeedsView of the jobs its needs: reach (new NeedsView::outputs_by_base merges 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_expansion keeps the run lock, the expand_generation fence and the stale-claim discard. It writes the subtree's rows, then promotes only what the subtree can start through the existing promote_dependents (from #485's stack; not duplicated). The run's status is stamped by a new conclude_run (summarize_run_tx, then workflow-hold release and run.completed.v1 on the first completion). settle_unrun uses it too; its writes and outbox order are unchanged.
  • The splice of an expanded matrix parent into callers' inner_job_ids (and the persisted caller inner_job_ids) is kept; callers are found with one JSONB containment query instead of reading every spec row.
  • rebuild_jobs / child_ids are deleted with their last callers. The row-insert helpers take run id, namespace and event instead of a RunGraph.

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:

  1. Legs and callee jobs with an already-satisfied needs: never started. Registration set remaining_needs = needs.len(); a leg inherits its node's needs, 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.
  2. A failed expansion build left its dependents Pending. They are now decided (skipped) in the same apply. Test: failed_deferred_expansion_skips_its_dependents.
  3. 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).

CR-depR-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 ms1130 [920-1390]1128 [915-1472]1572 [1280-1913]
completion hold, mean ms15.415.815.4
completed / backlogall / 0all / 0all / 0
duplicate_inflight, names_running_twice, owner mismatch, sql_errors000

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/shard not found in workflow"): the node's plan is only stored when it has a reusable_call, so the build falls back to the root workflow.
  • Static matrix legs inside a callee fail apply_expansion (likely the parent_job_id FK: 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_id pointing 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: clean
  • cargo clippy --workspace --all-targets -- -D warnings: clean
  • cargo test --locked -p preloop-runner-server --lib -- control:: --nocapture with PRELOOP_TEST_POSTGRES_URL set: 375 passed, 0 failed, 0 skipping: lines (137 PG + 143 lite control::tests plus other control:: tests); the four new tests pass on both backends
  • just test-ci: passed (exit 0, conform: PASS, 50 test result: ok lines, server lib 1068 passed / 3 ignored, 0 skipping:). A first workspace run had one failure, control::tests::pg::prune_outbox_respects_slowest_consumer (attempt to subtract with overflow at tests.rs:4562, the xmin visibility 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.