Skip to content

perf(ci): plan the unit shards before the static-analysis gate instead of behind it - #23743

Merged
nwparker merged 1 commit into
mainfrom
nwparker/ci-next-round
Sep 29, 2026
Merged

nwparker merged 1 commit into
mainfrom
nwparker/ci-next-round

Conversation

@nwparker

@nwparker nwparker commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 2 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​14 $\color{#cf222e}{\Huge{\mathbf{−}}}$​3 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​11
Prod 4 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​71 $\color{#cf222e}{\Huge{\mathbf{−}}}$​32 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​39

ELI5

Before running tests, CI works out how to split them across 8 machines. That planning step was sitting behind the lint-and-typecheck gate, even though it doesn't use anything those checks produce — so the tests waited for lint, then waited for planning, one after the other. Planning now happens at the same time as lint, so the tests start about a minute and a half sooner.

What Changed

The plan job moves out of unit-tests.yml into a new unit-plan.yml reusable workflow, called from pr.yml against code_paths alone.

Why it was serial. A caller job's needs/if gate the entire called workflow. pr.yml's test job gates on static_analysis and typecheck, so plan — living inside unit-tests.yml — couldn't begin until both finished and passed. The shards then waited on needs: plan. Two hops in sequence.

Why it didn't need to be. ci-unit-plan.mjs's only inputs are GITHUB_EVENT_PATH, a git diff against HEAD^1, the import graph (ci-unit-dependency-graph.mjs), the file list (ci-unit-files.mjs), and a checked-in timing baseline — readTimingBaseline reads config/scripts/ci-shard-timings.json off disk (ci-shard-assignment.mjs:45-49), not from a prior job. It consumes nothing from static_analysis, typecheck, or test_native_cache.

Measured across 99 completed runs (592 shard jobs, 2026-09-28 10:41Z–20:56Z), the shard matrix is created earlier by:

seconds
min 32
p25 47
median 93
p75 179
p90 241
max 376

Never later. In 3 of 53 runs the hoisted plan became the new binding dependency (its x86 queue spiked to p90 202s), and the saving shrinks there but stays positive.

Why the gate itself stays

I went in intending to remove the static_analysis/typecheck gate on test entirely — dropping both would save a median 218s — and the data says don't:

  • It fires on 22% of runs, not the ~2% I assumed: static_analysis fails on 20.2%, typecheck on 4.0%. (Two caveats that cut the other way: 12 of those 20 failures are one branch iterating, and the rate is falling — 34.7% in the older half of the window against 10.0% in the newer half. Even at 10% it is 5× my hypothesis.)

  • The shard pool is capacity-bound. Shard queue wait against concurrent ARM jobs at the moment of queueing:

    ARM jobs running n median wait p90
    0–4 216 4s 6s
    10–14 104 134s 514s
    15–19 120 218s 505s

    Flat to ~9 concurrent, then it knees. Measured mean is 6.9 with peak 41 from pr.yml alone. Removing the gate would add ~50 doomed runs/day × 8 shards × 402s = +2,680 ARM job-minutes/day (+29%), pushing the mean up ~1.9 into the steep part of that curve. docs/reference/ci-runner-efficiency.md:253 is explicit that ARM labels grant no extra concurrency — they share the account limit.

So the gate is load-bearing, and this PR captures the latency that was available without touching it. Hoisting plan costs one 37s ubuntu-latest job (max 67s) — zero on the ARM pool the shards contend for, and 1.1% of the 3,216 ARM job-seconds the gate prevents per doomed run.

Also worth recording: the shard-start staircase is pure queueing

While measuring I found the 8 "parallel" shards spread their starts over a median 319s. It is not a matrix setting — there is no max-parallel anywhere in unit-tests.yml (only fail-fast: false), and within a run the 8 shards' created_at values are identical (spread median 0s, max 1s). They are dispatched simultaneously and then wait for slots. That staircase is nearly as large as the gate's 233s and is entirely account concurrency, so the only levers on it are running fewer jobs or a higher concurrency allowance — not a workflow change. Noting it rather than acting on it.

Mechanism, for review

  • New .github/workflows/unit-plan.yml — today's plan job verbatim, with a workflow_call output for shards and the selection_mode input (which only plan ever used).
  • unit-tests.yml — plan removed; selection_mode input replaced by a required shards input; the matrix reads fromJSON(inputs.shards); test no longer has needs.
  • pr.yml — new unit_plan job on needs: [code_paths] with the same classifier gate; test gains unit_plan in needs, needs.unit_plan.result == 'success' in its if, and passes shards through.
  • node-next-compat.yml — the second caller, updated the same way.

selection_evidence is unaffected: it downloads unit-shard-node-*-attempt-N (uploaded by the shards), and plan's unit-selection-attempt-N artifact is run-scoped so it still resolves from a sibling called workflow in the same run.

Linked Issue

N/A — internal CI performance work, no pre-existing issue.

Visual Proof

N/A — CI configuration only, no user-facing surface.

Testing

  • 150 contract tests pass across the seven affected suites (pr-code-change-scope, pr-workflow-parallelism, package-electron-runtime-contract, pr-e2e-gate-contract, ci-background-step-barriers, ci-cache-warmup-workflow, pr-test-loc-summary).
  • Four assertions needed updating for the new shape, and I added coverage for the new invariant rather than just relaxing the old one: unit_plan must gate on code_paths only, test must still require needs.unit_plan.result == 'success', unit-tests.yml must no longer have a plan job, and its shards input must be required: true (so a caller cannot silently expand an empty matrix).
  • Both callers verified to parse and pass shards.

Failure behavior, checked explicitly: if planning fails, the shards are skipped, and verify's check_job requires success whenever the classifier says tests should run — so it fails with test: expected success, got skipped. I initially added unit_plan to verify's own needs for a more direct message, then backed it out: every verify dependency is required by contract to have a matching code_paths.outputs.<job> gate, and unit_plan deliberately shares test's gate rather than inventing a classifier output. The existing behavior already fails closed, so the indirection is worth more than breaking that convention.

  • I manually tested these changes locally
  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • Reviewer's attention: the shards input being required: true is the safety property — it means neither caller can reach the matrix without a real assignment.
  • Backwards compatibility / Security / SSH / Mobile: CI wiring only; no runtime code, no artifact or wire format change.
  • Concurrency: adds one short ubuntu-latest job per run and removes none, but takes nothing from the ARM pool, which is the constrained one.

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…d of behind it

A caller's `needs` gate the whole called workflow, so while the plan job lived in
unit-tests.yml it could not start until static analysis and typecheck had both
finished and passed -- and the shard matrix then waited on it. The two hops were
serial when they did not need to be: planning reads the checkout, a git diff
against HEAD^1, the import graph and the checked-in timing baseline in
config/scripts/ci-shard-timings.json, and consumes nothing that static analysis,
typecheck or the native-cache primer produce.

Planning moves to its own reusable workflow so pr.yml can run it against
code_paths alone, overlapping it with the gate. Measured across 99 runs, the
shard matrix is created a median 93s earlier (p25 47s, p90 241s, never later).
Planning stays a required predecessor of the shards, so an empty assignment
cannot expand the matrix.

The gate itself is deliberately left in place. It fires on 22% of runs, and the
shard queue wait knees hard above ~9 concurrent ARM jobs -- 4s median below that
against 218s at 15-19 -- so admitting 8 doomed shards per failed run would cost
more in queue pressure than it returns in latency.

Cost is one 37s ubuntu-latest job, which does not touch the ARM pool the shards
contend for.

A planning failure still fails the PR: the shards are skipped, and verify's
check_job requires success whenever the classifier says tests should run, so it
reports `test: expected success, got skipped`.
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[High risk] Restructures CI workflow job dependencies and timing.

The PR appears safe to merge; the uncovered workflow-output wiring is a non-blocking test-maintenance concern.

Findings

  1. P2 Shard wiring lacks coverage ▶

Summary

The PR moves unit-shard planning into a reusable workflow so it can run alongside the PR’s static-analysis gate, then passes its shard assignment to the existing test workflow.

  • The PR and Node-next callers now depend on the separate plan job.
  • The new output-to-input wiring is not covered by the updated contract tests.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart LR
  C[code_paths] --> P[unit_plan]
  C --> G[static_analysis and typecheck]
  P --> T[test: unit-tests.yml shard matrix]
  G --> T
  T --> V[verify]
Loading

Reviews (1) · Last reviewed commit: "perf(ci): plan the unit shards before th..."

// unit-plan.yml so it can run before the static-analysis gate clears.
expect(sharedTest.needs).toBeUndefined()
expect(unitTestWorkflow.jobs.plan).toBeUndefined()
expect(unitTestWorkflow.on.workflow_call.inputs.shards.required).toBe(true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Shard wiring lacks coverage The new checks confirm that shards is required and that test depends on unit_plan, but they do not check that the planner exposes the output or that either caller passes it to the test workflow. A broken connection could leave these contract tests passing while the shard matrix fails to expand. Please assert the planner output and both callers’ with.shards expressions.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 36e6605c-36bb-4f99-b088-2cccdb150482

📥 Commits

Reviewing files that changed from the base of the PR and between bd5dca4 and ffc85c6.

📒 Files selected for processing (6)
  • .github/workflows/node-next-compat.yml
  • .github/workflows/pr.yml
  • .github/workflows/unit-plan.yml
  • .github/workflows/unit-tests.yml
  • config/scripts/pr-code-change-scope.test.mjs
  • config/scripts/pr-workflow-parallelism.test.mjs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.


📝 Walkthrough

Walkthrough

The change moves unit-test shard planning into a reusable workflow. The PR and Node-next workflows pass the planner’s shard assignments to the reusable unit-test workflow. The unit-test workflow uses those assignments in its test matrix. Workflow assertions now cover the planner dependencies and shard input.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to ffc85

Shard planning and artifact handoff remain connected to unit tests; no actionable merge-blocking risk remains.

Architecture Summary

Architecture risk: 🔵 Low · up to ffc85

The change affects 1 system.

Changed systems: config

Architecture concerns
No architecture-level concerns identified.

Review details

Systems and components

  • observed — config (service) was modified; 2 changed files map to changed impact.

Before / after behavior

  • observed — Modified behavior in config/scripts/pr-code-change-scope.test.mjs: The wiring assertions now include unit_plan in test.needs. They also verify that unit_plan depends only on code_paths, is enabled by the test classifier output, and that test requires successful planning.
  • observed — Modified behavior in config/scripts/pr-workflow-parallelism.test.mjs: The test now expects the shard matrix to use the shards input, the reusable unit-test workflow to have no needs dependency or plan job, and shards to be a required workflow-call input. The accompanying comment records that planning moved to unit-plan.yml.
  • observed — Modified behavior in config/scripts/pr-workflow-parallelism.test.mjs: The Node-next workflow test now expects the test job to depend on unit_plan as well as test_native_cache.
  • observed — Modified behavior in .github/workflows/node-next-compat.yml: Adds the unit_plan reusable workflow and makes test wait for both it and test_native_cache. The test workflow now receives the planned shard count; its Node version matrix remains unchanged.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: moving unit-shard planning ahead of the static-analysis gate.
Description check ✅ Passed The description is complete and well aligned with the template. It explains the change, rationale, implementation, testing, failure behavior, compatibility considerations, and checklist status. The N/…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 2…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — hoists unit-shard planning out of the static_analysis/typecheck gate so it overlaps the gate instead of queuing behind it.

  • New unit-plan.yml — the plan job moved verbatim out of unit-tests.yml, exposed via workflow_call with a selection_mode input and a shards output. Still writes/upload the unit-selection-attempt-<attempt> artifact, which the shards resolve run-scoped.
  • unit-tests.yml — plan removed; selection_mode replaced by a required shards input; the matrix reads fromJSON(inputs.shards) and test has no needs. The required input is the real safety property: no caller can expand the matrix without an assignment.
  • pr.yml — new unit_plan job gated on code_paths only, with test gaining it in needs, needs.unit_plan.result == 'success' in if, and passing shards through. verify deliberately omits unit_plan because it shares test's classifier — and that path still fails closed: a plan failure skips test, and verify's check_job test requires success whenever the classifier says tests should run.
  • node-next-compat.yml — second caller updated the same way; selection_mode still defaults to shadow, matching prior behavior.
  • Contract tests — exact assertions for the new shape (unit_plan.needs, its if, test.if containing needs.unit_plan.result == 'success', unit-tests.yml having no plan job, shards.required === true), rather than relaxing the old ones.

I verified ci-unit-plan.mjs's inputs are independent of the gate (event payload, git diff HEAD^1, import graph, file list, checked-in timing baseline via readTimingBaseline), that the plan job installs with the default native-runtime: none, that selection_mode has no stale caller pointing at unit-tests.yml, and that both — and only — callers of unit-tests.yml are updated. config/scripts/ (275 files / 2506 tests), including the every-workflow census sweep, passes.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@nwparker
nwparker merged commit ec9f35e into main Sep 29, 2026
37 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.

1 participant