Skip to content

perf(ci): balance the Windows pytest shards by time, not test count (#88) - #419

Merged
KbWen merged 3 commits into
mainfrom
perf/test-durations-shard-balance-88
Aug 23, 2026
Merged

perf(ci): balance the Windows pytest shards by time, not test count (#88)#419
KbWen merged 3 commits into
mainfrom
perf/test-durations-shard-balance-88

Conversation

@KbWen

@KbWen KbWen commented Aug 23, 2026

Copy link
Copy Markdown
Owner

perf(ci): balance the Windows pytest shards by time, not test count (#88)

--splits 3 --group N ran with no committed .test_durations, so pytest-split
fell back to an even COUNT split and clustered every subprocess-shelling deploy
test onto one shard. Real job times on PR #417: 21m57s / 3m19s / 4m14s -- the
worst shard carried 74% of all the work while two runners sat idle.

A 897-entry durations file is now committed. Predicted shards 19.6 / 19.5 /
18.2 min against a 19.1-min ideal: worst shard drops from 74% to 34% of total
work (33.3% is perfect). Scaled to the runner that is ~21m57s -> ~10.1 min, a
~2.2x CI wall-clock cut. No test, source, or workflow-behaviour change -- CI
already passes --splits/--group and pytest-split picks up the default path.

The row's own measurement was stale by ~3x and is corrected in place: #88
recorded "measured wall-clock 7:14"; the real numbers are above.

Verified at zero cost rather than by three real CI runs: each group was collected
with CI's exact invocation via --collect-only and its members' measured durations
summed. --splitting-algorithm least_duration was also measured and is better
(19.1 / 19.1 / 19.1, exactly ideal) -- and deliberately rejected, because it
reorders tests across groups and 2.6% is not worth that risk.

The ceiling is named rather than implied: two tests are 25.8% of the whole suite
(test_validator_count_parity_on_framework at 8.0 min,
test_170_underscore_meta_specs_no_status_warn at 6.8 min), and 87 of 897 tests
(9.7%) carry 90% of the runtime. A perfect split can never beat the heaviest
single test, so ~10 min/shard is the practical floor until those two are
addressed -- a separate unit with real fidelity risk. Two paths stay closed:
pytest-xdist was measured slower here, and deselecting slow in CI is explicitly
rejected in pytest.ini (subprocess fidelity is the design).

Honest provenance: the durations were generated on a Windows workstation
(57.3 min total), not on windows-latest (~29.5 min total). pytest-split consumes
RELATIVE weights and the dominant cost -- subprocess spawn -- is the same class on
both, but absolute times differ ~1.9x. The real verification is this PR's own job
times against the #417 baseline. Unknown test ids added later degrade to
count-splitting, so the file goes stale gracefully; no guard detects staleness and
none is claimed.

Also corrects two comments that asserted the file does not exist
(validate.yml:332, requirements-ci.txt:8).

Tests:

  • validate.ps1 exit 0 - pass=118 warn=3 fail=0 skip=2 (unqualified pass)
  • validate.sh exit 0 - pass=118 warn=3 fail=0 skip=2 (unqualified pass; twin WARN
    sets identical -- the run finished inside the 60-min lock window)
  • full CI-equivalent suite, no -m filter: 896 passed, 1 skipped, exit 0 (47:19)
  • test_ci_hardening.py (reads validate.yml) -> 13 passed
  • scope check: .test_durations is not referenced by deploy.sh, so it is not
    shipped downstream

This improves CI wall-clock only. A local single-process run is unaffected by
shard configuration and no claim is made otherwise.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

🤖 Generated with Claude Code

KbWen added a commit that referenced this pull request Aug 23, 2026
PR #419 CI job times: 7m18s / 8m28s / 13m7s. Worst shard 21m57s -> 13m7s
= 1.67x, not the 2.2x this branch predicted from workstation-generated
durations. The Known Risk caveat is confirmed: the runner weights
many-small-tests relatively higher, so the 640-test shard became the
slowest. Backlog row, Ship History entry and archived Work Log now state
the measured numbers with the runner-regeneration follow-up recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
KbWen added a commit that referenced this pull request Aug 23, 2026
#171) (#420)

fix(security): stop the secret scanner failing on ordinary identifiers
(#171)

TruffleHog's Lob detector matches a word boundary, `live` or `test`, an
underscore, then exactly 35 characters from the word class --
underscores
included -- so a plain snake_case name of the right length satisfies it,
and its
verifier returns verified. That is why `--only-verified` never bounded
this
class. `--exclude-detectors=lob` is now set.

This was latent until it wasn't. Backlog #171 had recorded 35 same-shape
identifiers already sitting in the tree, unflagged only because the scan
is
range-scoped. #88's committed pytest durations file -- 897 node ids in
one
commit -- turned that latency into an outright job failure on PR #419.

The narrowing is scoped, and the scope is now machine-enforced. Exactly
one
detector is dropped, for a service this repo has no integration with.
Every other
detector still runs, and the `credential-scan` job plus the pre-commit
credential
floor are untouched second and third layers. A new test,
test_detector_exclusions_stay_scoped_to_lob, asserts that lob is the
ONLY
permitted exclusion and that `--only-verified` cannot be dropped
alongside it --
without it, a future "just exclude one more" edit is a one-token diff
that reads
as noise in review.

Mutation-verified in both directions before the green was trusted:
- exclusion broadened to two detectors -> test fails
- `--only-verified` removed          -> test fails
- clean state                        -> 42 passed
The workflow file was restored and byte-compared after the mutation run.

repo-gotchas gains section 15 with the two operational facts that cost
the
sessions, so they stop living in a backlog row nobody re-reads:
- the action walks each commit's diff across the range, so a follow-up
rename
does NOT clear a finding and neither does add-then-remove; only removing
the
  introducing commit does, in practice a squash;
- commit messages are in scope too, so documenting the incident can
re-create the
  pattern inside the commit that documents it.

Both were obeyed here: the shape is described in prose only, and the
staged diff
was self-checked against the detector's own compiled pattern -> 0
matches.

This is option (a) from #171's own recorded options, not a fresh
judgement made
under merge pressure. Option (b) "absorb the occasional block" is what
just cost
a merge; option (c) "upstream a bug report" is worth doing but fixes
nothing in
this repo's CI.

Tests:
- full CI-equivalent suite, no -m filter: 897 passed, 1 skipped, exit 0
(1:16:00)
-- the +1 against the prior 896 is exactly the new scope test, a
verifiable
  delta rather than an unchanged number asserted to have changed
- validate.ps1 exit 0 - pass=118 warn=3 fail=0 skip=2 (unqualified pass)
- validate.sh exit 0 - pass=118 warn=4 fail=0 skip=2 (unqualified pass;
the 4th
  WARN is this session's own stale advisory lock across a 76-minute run)
- test_security_workflow.py: 41 -> 42 passed

Unblocks #88 (PR #419), which needs this on main before its own scan can
clear --
a rebase alone would not, per the range-walk fact above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
KbWen and others added 2 commits August 23, 2026 23:45
)

`--splits 3 --group N` ran with no committed `.test_durations`, so pytest-split
fell back to an even COUNT split and clustered every subprocess-shelling deploy
test onto one shard. Real job times on PR #417: 21m57s / 3m19s / 4m14s -- the
worst shard carried 74% of all the work while two runners sat idle.

A 897-entry durations file is now committed. Predicted shards 19.6 / 19.5 /
18.2 min against a 19.1-min ideal: worst shard drops from 74% to 34% of total
work (33.3% is perfect). Scaled to the runner that is ~21m57s -> ~10.1 min, a
~2.2x CI wall-clock cut. No test, source, or workflow-behaviour change -- CI
already passes --splits/--group and pytest-split picks up the default path.

The row's own measurement was stale by ~3x and is corrected in place: #88
recorded "measured wall-clock 7:14"; the real numbers are above.

Verified at zero cost rather than by three real CI runs: each group was collected
with CI's exact invocation via --collect-only and its members' measured durations
summed. `--splitting-algorithm least_duration` was also measured and is better
(19.1 / 19.1 / 19.1, exactly ideal) -- and deliberately rejected, because it
reorders tests across groups and 2.6% is not worth that risk.

The ceiling is named rather than implied: two tests are 25.8% of the whole suite
(test_validator_count_parity_on_framework at 8.0 min,
test_170_underscore_meta_specs_no_status_warn at 6.8 min), and 87 of 897 tests
(9.7%) carry 90% of the runtime. A perfect split can never beat the heaviest
single test, so ~10 min/shard is the practical floor until those two are
addressed -- a separate unit with real fidelity risk. Two paths stay closed:
pytest-xdist was measured slower here, and deselecting `slow` in CI is explicitly
rejected in pytest.ini (subprocess fidelity is the design).

Honest provenance: the durations were generated on a Windows workstation
(57.3 min total), not on windows-latest (~29.5 min total). pytest-split consumes
RELATIVE weights and the dominant cost -- subprocess spawn -- is the same class on
both, but absolute times differ ~1.9x. The real verification is this PR's own job
times against the #417 baseline. Unknown test ids added later degrade to
count-splitting, so the file goes stale gracefully; no guard detects staleness and
none is claimed.

Also corrects two comments that asserted the file does not exist
(validate.yml:332, requirements-ci.txt:8).

Tests:
- validate.ps1 exit 0 - pass=118 warn=3 fail=0 skip=2 (unqualified pass)
- validate.sh  exit 0 - pass=118 warn=3 fail=0 skip=2 (unqualified pass; twin WARN
  sets identical -- the run finished inside the 60-min lock window)
- full CI-equivalent suite, no -m filter: 896 passed, 1 skipped, exit 0 (47:19)
- test_ci_hardening.py (reads validate.yml) -> 13 passed
- scope check: .test_durations is not referenced by deploy.sh, so it is not
  shipped downstream

This improves CI wall-clock only. A local single-process run is unaffected by
shard configuration and no claim is made otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR #419 CI job times: 7m18s / 8m28s / 13m7s. Worst shard 21m57s -> 13m7s
= 1.67x, not the 2.2x this branch predicted from workstation-generated
durations. The Known Risk caveat is confirmed: the runner weights
many-small-tests relatively higher, so the 640-test shard became the
slowest. Backlog row, Ship History entry and archived Work Log now state
the measured numbers with the runner-regeneration follow-up recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KbWen
KbWen force-pushed the perf/test-durations-shard-balance-88 branch from 23df93a to f62f6fc Compare August 23, 2026 15:47
The rebase onto #171 re-ran the same tree and produced 6m56s / 6m43s /
11m24s against the first run 7m18s / 8m28s / 13m7s. Two runs of the same
tree differ by ~13%, so the win is recorded as a range rather than as the
better number. Backlog row, Ship History entry and archived Work Log all
cite both runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@KbWen
KbWen merged commit 8b08ab6 into main Aug 23, 2026
19 checks passed
@KbWen
KbWen deleted the perf/test-durations-shard-balance-88 branch August 23, 2026 16:13
KbWen added a commit that referenced this pull request Aug 23, 2026
chore(release): v1.8.23 — the downstream-report residue, finished

Packages the four units this wave shipped, and the interesting part is
that
each one unblocked the next rather than being four independent chores:

- #175 (PR #417) validate.ps1 stopped mojibaking its own output on a
non-UTF-8
  console. Its bootstrap reproduced #178 live.
- #178 (PR #418) AGENTS.md stopped forbidding the backlog advance
bootstrap.md
  requires. That legalised the step #88's bootstrap needed.
- #88  (PR #419) Windows CI shards balance by time, not test count. Its
  committed durations file turned #171 from latent into a hard block.
- #171 (PR #420) the secret scanner stopped failing on ordinary
identifiers,
  which had to land before anything else could merge.

Every headline number is measured, and two were revised DOWNWARD on
measurement rather than published as predicted:

- #88's rebalance was predicted at 2.2x from workstation-generated
weights; it
measured 13m7s then 11m24s against a 21m57s baseline = 1.67x-1.93x,
recorded
  as a RANGE because two runs of the same tree differed ~13%.
- #175's backlog row claimed the bug does not reproduce on pwsh 7;
measured, 5.1
  and 7 emit identical bytes, and the row was corrected in place.
- #88's own row recorded 7:14 for an imbalance that had grown to 21m57s.

Three greens were mutation-verified before being trusted: the
directive-count
ratchet, the new detector-scope test, and the source-only call-site
guard --
whose mutation run REFUTED a review panel's headline objection.

Version banners bumped on seven surfaces, enumerated by grepping the
previous
version on disk rather than trusting the last release's record. Each
replacement
asserted its own anchor; residual grep over all seven returns 0.

Files #182: nothing pins version consistency across those seven
surfaces. This
release's own banners were bumped by hand -- same failure class as the
tag step
in repo-gotchas 12, which has been forgotten twice.

Tests:
- full CI-equivalent suite, no -m filter: 897 passed, 1 skipped, exit 0
(1:06:46)
- validate.ps1 exit 0 - pass=118 warn=4 fail=0 skip=2 (unqualified pass)
- validate.sh exit 0 - pass=118 warn=4 fail=0 skip=2 (unqualified pass,
twins
  identical)
- check_audit_chain.py: audit chain intact

NOT done by this merge (repo-gotchas 12):
  git tag v1.8.23 && git push origin v1.8.23
  gh release create v1.8.23 --latest

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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