Skip to content

perf(ci): make the test-lane planner fast on a very large diff - #10786

Merged
kyle-sexton merged 2 commits into
mainfrom
fix/plan-test-lanes-large-diff
Oct 28, 2026
Merged

kyle-sexton merged 2 commits into
mainfrom
fix/plan-test-lanes-large-diff

Conversation

@kyle-sexton

Copy link
Copy Markdown
Member

No related issue: a CI defect found while driving #10719; fixed in place (no new issues, per the owner's standing direction)

Summary

select-tests (pr-require-checks.yml, timeout-minutes: 3) and select-tests-windows (pr-test-windows.yml, timeout-minutes: 3) were cancelled at their timeout on #10719, whose diff changes 1041 files (runs 40775980620 and 40775980621). Locally, scripts/plan-test-lanes.sh --base origin/main took 5m25s on that diff, so any very large change fails ci-status on every run. A normal PR's select-tests takes about 30 s.

Fix

Profiled with timestamped xtrace:

  • plan-test-lanes.sh ran the selector twice, once for the whole change and again for its Windows subset: 162 s each, almost all of the time. It now selects the Windows files and the inert files once each and unions the results, which is exact because the selector walks each file independently. unmapped is the sum of both runs.
  • affected-tests.sh paid a fixed cost per walk level about 1100 times:
    • It drops --untracked from the tree git grep when no untracked code file exists (always the case on a CI checkout): about 94 ms against 37 ms a call.
    • token_hits no longer reloads index files a level does not read.
    • Co-located suites are read through a file instead of a fork per frontier path.
    • After 16 tree greps (AFFECTED_TESTS_TREE_GREPS overrides), it reads every code line that names a file once and greps those lines on later levels, falling back to git grep when a level's names are not covered.

The plan output is unchanged. A whole-tree short-circuit was rejected because it changes windows_steps on #10719 (38 steps against 27).

Verification

Diff Old New
#10719, 1041 files, 24 cores 5m25s 36.4s
#10719, 4 cores (taskset -c 0-3) not measured 36.9s
b6ddec7, 3 files 0.88s 0.73s
e915037, 25 files 6.86s 2.96s
c437366, 4 files 1.66s 1.13s
  • Byte-identical stdout and stderr against the old scripts on docs(contracts): move plugin-author contracts out of docs/conventions #10719's diff (24 and 4 cores) and on 7 merged main commits with --base <parent>.
  • affected-tests.sh --replay dce531e~30..dce531e --against dce531e: 30 commits, zero suites differ.
  • scripts/affected-tests.test.sh: PASS=139 FAIL=0, with a new case that counts tree greps through a git shim and requires identical output.
  • scripts/plan-test-lanes.test.sh: PASS=52 FAIL=0, with a new case for unmapped files on both sides of the inert split. Each new case fails against a deliberate mutation of the code it covers.
  • Not verified: wall time on the GitHub-hosted runner; the 4-core run stands in for it.

Related

🤖 Generated with Claude Code

kyle-sexton and others added 2 commits October 28, 2026 18:55
On a 1041-file change (#10719) plan-test-lanes.sh --base took 5m25s and
both select-tests jobs hit their 3-minute timeout. The planner ran the
selector twice over almost the same files (once for the change, once
for its Windows subset), and each selector run spent about 170 s on a
fixed cost per walk level: a `git grep --untracked` of the whole tree,
a token filter that reloaded the tree index and every Python import
line, and a fork per frontier path.

- plan-test-lanes.sh selects the Windows files and the inert files once
  each and unions them, since the selector walks each file on its own.
- affected-tests.sh drops --untracked when no untracked code file
  exists, hands the token filter only the index files a level reads,
  reads co-located suites through a file instead of a fork, and after
  16 tree greps reads every code line naming a file once and greps
  those lines for later levels.

The plan is byte-identical: #10719 and seven merged main commits, and a
30-commit --replay --against the old selector shows no difference.
#10719 now plans in 37 s, on 4 cores too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@kyle-sexton
kyle-sexton marked this pull request as ready for review October 28, 2026 23:01
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-28T23:04:20.741647Z 6405cd9 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

claude-security-review reviewed 6405cd945144eeb8a387dd069efa0805a462121d: 0 finding(s), 0 as inline comments.

No security issues found in this pull request.

critical-findings: 0

Scope read: all four changed files in .claude-lane/pr.diff: scripts/affected-tests.sh, scripts/affected-tests.test.sh, scripts/plan-test-lanes.sh and scripts/plan-test-lanes.test.sh. None matches a TRUSTED PATHS glob. No workflow, action, token, kill-switch, merge-hold, manifest or instruction file changed, so no trust control is widened.

What the change does: it speeds up the test-lane planner on very large diffs.

  • It runs the selector once per side of the Windows-inert split and unions the results.
  • It drops --untracked from the tree git grep when no untracked code file exists.
  • It stops reloading index files a level doesn't read.
  • It reads co-located suites through a file instead of a process substitution.
  • After 16 tree greps it builds a cache of the code lines that name a known basename and greps that cache instead.

Checks made:

  • Injection: the new code passes no user-controlled text to eval or to an unquoted expansion that would execute. AFFECTED_TESTS_TREE_GREPS is only compared in an arithmetic context (((TREE_GREPS == ${AFFECTED_TESTS_TREE_GREPS:-16}))). It comes from the caller's environment, not from pull-request content. A pull request cannot set it in CI. Even a hostile value could only trigger a bash arithmetic evaluation inside a runner that already runs the repo's own scripts.
  • Cache coverage: build_name_lines takes its names from every tracked file, every changed path and every sync-source copy. covered is cleared when a level's pattern isn't in that set, and that level then falls back to the tree grep. I found no path where a pull request's file names slip past the cache and so under-select tests. Empty names are filtered out, so grep -F -f can't match everything. The path/line re-attribution via git grep -c is aligned with git grep -h over the same tree and file specs.
  • Fail-closed behaviour: every new stage (untracked-code listing, resolvable-files, build_name_lines) exits 2 on a failed read, and the existing "refusing to continue" error for a failed reverse lookup remains. The git grep rc>1 check also covers the cached grep path.
  • Inert split in plan-test-lanes.sh: the guard !whole || !whole_windows is unchanged. unmapped is now the sum of both selector runs, and the *) branch still dies on any exit code other than 0 or 4. The two selections are concatenated into selection. I found no change that drops a changed file from selection.

The PR description reports byte-identical output against the old scripts and zero differing suites on a 30-commit replay. I did not re-run any of that.

Spend: the change reduces CI wall time and adds no paid resource, so there is no cost hold.

@github-actions

Copy link
Copy Markdown
Contributor

claude-review reviewed 6405cd945144eeb8a387dd069efa0805a462121d: 0 finding(s), 0 as inline comments.

No findings. I read all four changed files in the diff, plus the surrounding code in scripts/affected-tests.sh and scripts/plan-test-lanes.sh. I ran nothing, because this lane has no shell. The pull request body has no closing issue and no stated criteria. No changed file matches a trusted path, and there are no manifest changes or cost-bearing changes.

What I checked by reading the code:

  • Union of the two selector runs (scripts/plan-test-lanes.sh:198-218): I found no difference from the old output.
    • Selections: The old code did one selector run for all changed files, then a second for the Windows subset. The new code runs the Windows files and the inert files once each and concatenates the results. Both feed selection and WIN_SELECTION the same content as before in every branch. The no-inert case, the all-inert case and whole_windows all behave as before.
    • unmapped: It now sums both runs. The awk in select_into defaults to 0 with END { print n + 0 }.
    • stderr: The UNMAPPED: block can now print twice, once per run. That is cosmetic, and only stderr is affected.
  • --untracked drop (scripts/affected-tests.sh:547-557): Dropping it when git ls-files --others --exclude-standard finds no untracked code file reads the same files. Both sides use the same pathspecs, and ignored files are excluded either way.
  • Index files passed to token_hits:
    • Resolvable files: nrt holds only resolve-set names (ambiguous or structural basenames) and __init__.py. resolvable-files carries every file of exactly those names. nsame and same are only read for names in nrt, so the filtered list gives the same results.
    • py-imports: It is read only when a .py name is in the plain or resolve set, which matches the *.py$'\n'* gate in select_for. The /dev/null placeholders are harmless.
  • build_name_lines (scripts/affected-tests.sh:61-83): It looks correct.
    • Concatenated copies: The printf '%s' over SYNC_SRC_COPIES values concatenates correctly, because each value is newline-terminated.
    • Line alignment: Lines from git grep -h are mapped back to paths using the -c counts. Both use -I, so binary files drop out of both.
    • Output order: It matches the order of the tree grep.
    • Path matches: A pattern that matches only a file's path can add a line, but token_hits reads only the text after the path, so that line yields no hit.
    • Uncovered levels: A level whose basenames are not all in NAMED falls back to git grep.
    • State and errors: NAMED and TREE_GREPS persist across seeds because select_for runs in the main shell. Every stage fails loud with exit 2.
  • Tests: The new tree-grep-counting case and the unmapped-on-both-sides case target the changed behaviour.

Author-claimed and unverified by me: the timings in the table, the byte-identical output comparisons, the --replay result and the PASS= counts.

@kyle-sexton
kyle-sexton added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit da17647 Oct 28, 2026
60 of 72 checks passed
@kyle-sexton
kyle-sexton deleted the fix/plan-test-lanes-large-diff branch October 28, 2026 23:23
@github-actions

Copy link
Copy Markdown
Contributor

Post-merge mutation testing

Killed 0, survived 0, timeout 0, no coverage 0, invalid 0, not run 7.
Covered-code score n/a; mutation score n/a.

File Killed Survived Timeout No coverage Invalid Not run
scripts/affected-tests.sh 0 0 0 0 0 4
scripts/plan-test-lanes.sh 0 0 0 0 0 3

The run's summary has the full report.

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