Skip to content

ci: shard Test Core six ways to break the runner contention - #10376

Merged
qq9340100 merged 4 commits into
mainfrom
claude/issue-10227-testcore-shard-contention
Aug 20, 2026
Merged

ci: shard Test Core six ways to break the runner contention#10376
qq9340100 merged 4 commits into
mainfrom
claude/issue-10227-testcore-shard-contention

Conversation

@qq9340100

@qq9340100qq9340100 commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Fixes#10227

Implements the maintainer's option C ruling of 2026-08-20 (recorded on the card): attack the runner contention by raising the Test Core shard count, with a like-for-like measurement on real GitHub 4-vCPU runners as a mandatory pre-step. Option B (rebasing the threshold) is rejected on that ruling; scripts/partition-test-shards.mjs semantics are untouched and there are no manual per-package exemption lists.

Result: the measurement clears the line.@objectstack/cli drops from 464.37s to 302.21s, and @objectstack/spec — alone on its own runner — finishes its whole shard in 283s, cold Turbo cache included. Numbers and method below.

The change

test job matrix [1, 2, 3][1, 2, 3, 4, 5, 6], plus the counts mechanically coupled to it and checked statically by check:shard-attestation: the job name:, the --shard N/M argument, the attestation --total, the artifact name, and the gate's declared leg.

--concurrency=4 is unchanged. Narrower shards may well want a lower value, but moving both knobs in one run makes the resulting measurement unreadable. That is a follow-up, not this PR.

Why six, not the four or five the card's arithmetic suggested. Sharding is BY PACKAGE, so no shard can finish faster than its single heaviest package, and the weight function is test-file count. @objectstack/spec carries 415 of the workspace's ~2360 test files. At five shards or fewer the partitioner must co-schedule it; at six it is alone in its bin. Bins from partition-test-shards.mjs against the full 76-package list, unchanged script:

shardsbins (test-file weight)@objectstack/spec@objectstack/cli
3787 / 786 / 786with 22 otherswith 23 others
4590 / 590 / 590 / 589with 15 otherswith 23 others
5472 ×4 / 471with 9 otherswith 15 others
6415 / 389 ×4 / 388alonewith 13 others
7415 / 324 ×6alonewith 11 others

Six is the smallest count that isolates the heaviest indivisible suite. Past six, spec's shard cannot improve, only the others can.

A second effect that only shows up on the affected-set path: on a small affected set — the common PR — six shards give cli a runner to itself. Verified locally against a three-package affected set: shard 1 spec, shard 2 cli, shard 3 client, shards 4–6 empty and short-circuiting on the existing [ ! -s ] guard.

Pre-step: the measurement

The instrument named in the dispatch does not work as stated, and that is finding #1. A .github/**-only PR has an affected set of exactly 0 packages — measured locally against this branch with turbo ls --affected from merge-base(origin/main, HEAD):

affected count (ci.yml-only diff): 0
affected count (global-dep diff): 77

So an ordinary run of this PR schedules six shards, each of which prints "No packages on this shard — nothing to test", attests, and measures nothing. (.github/workflows/ci.yml is in the core paths-filter, so the job does start; it simply has no work.)

This PR therefore carried a temporary measurement scaffold — one comment appended to tsup.config.ts, a turbo globalDependencies entry — which made the affected set the full 77 packages and invalidated every task hash. The scaffold has been reverted; the diff under review is three files. The scaffold's effect is confirmed in the run itself: Cached: 0 cached, 68 total on cli's shard.

Baseline — 3 shards, real runners

push-to-main run 32392712980, full package list, ubuntu-latest 4-vCPU:

shardfixed overheadRun this shard's testsjob total
1/357s398s481s
2/365s341s431s
3/361s645s726s

Measured — 6 shards, real runners

Run 32396909164, full 77-package list, cold Turbo cache, all six shards green:

shardfixed overheadRun this shard's testscontents
1/6194s283s@objectstack/specalone — 1 of 1 pkg, 11059 tests
2/648s474s
3/654s586s
4/650s614s@objectstack/cli + 13 — 14 of 14 pkgs, 7129 tests
5/659s511s
6/6346s536s

The number that decides it

The step totals above include a cold dependency build, because the scaffold invalidates every hash — on shard 4/6, 312s elapsed before cli's suite even started. So the step column is an upper bound, not the steady-state shard time. The metric that is immune to that, and that isolates exactly the variable this card is about, is the per-package vitest Duration:

3 shards, run 32392712980, shard 3/3 — @objectstack/cli
Duration 464.37s (transform 47.25s, setup 0ms, import 260.48s, tests 1102.42s)
6 shards, run 32396909164, shard 4/6 — @objectstack/cli
Duration 302.21s (transform 24.63s, setup 0ms, import 168.42s, tests 716.55s)
Tasks: 68 successful, 68 total
Cached: 0 cached, 68 total

Identical work both legs — 137 files, 1500 tests — and wall clock falls 35%, from 464.37s to 302.21s, now 118s under the 420s line. The CPU total falls with it, ~1410 CPU-s to ~909 CPU-s. That second number is the point: the same tests do not become cheaper, they stop paying for cache-thrash and context switching against three co-tenant suites. It is a direct confirmation that the durations this job's logs print are contention, not cost — the card's central claim, now measured on the runners rather than on a dev box.

spec's side is settled even more simply: alone on shard 1/6 it finished the entire step, cold build included, in 283s against 496.4s contended.

This closes both of the card's confidence gaps. Gap 1 (dev box vs real runner): cli costs ~1410 CPU-s on a runner against 1291 CPU-s on the dev box in #10152, so runner cores are only ~9% slower — the margin survives the environment change. Gap 2 (per-shard fixed overhead): at the median it is ~50-60s, far too small to eat the margin.

⚠ But gap 2 has a tail, and it is a real cost of this change

Fixed overhead is contended, not constant. Checkout repository across the six shards of one run: 155s, 14s, 21s, 13s, 13s, 309s. Same workflow, same commit, same runner label — a 24x spread, because six shards plus three dogfood legs plus Build Core, Build Docs, Console Pin Gate and Temporal Conformance all pull fetch-depth: 0 simultaneously. Two shards paid 194s and 346s of overhead, straight off the budget. The 3-shard baseline was tight by comparison (57s / 65s / 61s).

That is filed separately as #10383 with the numbers — it is out of this card's region and wants its own measurement across several runs before anyone changes the checkout.

Coupled changes

scripts/check-shard-attestation.mjs and scripts/check-required-contexts.mjs each carry self-test fixtures that mutate ci.yml by string replacement and assert the mutation matched rather than silently judging the pristine file. They did their job: both went red on this change and named the dead anchor. Three anchors moved with the matrix.

One is worth naming, because it would have rotted quietly. The grow the matrix fixture anchored on shard: [1, 2, 3] — which the dogfood job also spells. With the test matrix now six wide, String.replace would have taken dogfood's match first: still red, still for a plausible-looking reason, and no longer the assertion written there. The anchor now carries the full matrix including its closing bracket, with a comment saying why.

Verification

  • All six Test Core shards green on run 32396909164, and the aggregate Test Core gate concluded success counting the six shard-attest-test-N-of-6 credentials — so the coupled --total / --leg / artifact-name edits are proven on a real run, not only by the static drift guard.
  • Gates re-derived at the final commit 3ce316f8 with node scripts/pm/dispatch-gates.mjs (no args, three-dot semantics against merge base 0bfbeb70c) and run there. Their own verdict lines:
    • check-node-version: OK (29 setup-node step(s) across 26 workflow(s), all on Node 22).
    • ✓ check-shard-attestation: 2 aggregate gate(s) count 3 declared leg(s) across 3 attesting job(s).
    • check-workflow-status-functions: OK (scanned 26 workflow file(s), 45 job(s) …)
    • OK: 12 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
    • check-nul-bytes: OK (scanned 6115 text file(s) … no raw ASCII control bytes).
    • check:required-contexts green (full registry printed).
  • pnpm lint green repo-wide.
  • .github/** and scripts/** only, no published package changes, so skip-changeset is applied.

Notes for the neighbours

Cost, stated plainly

Per-shard fixed overhead is paid once per shard and the merge queue multiplies it by the speculative build count. Six shards trade roughly 1.7x the billed runner-minutes for a shorter critical path. This buys latency, not cheaper CI, and that trade is written into the ci.yml comment so the next reader does not have to rediscover it.


Generated by Claude Code

Test Core's per-package durations are contended wall clock, not package
cost: `--concurrency=4` on a 4-vCPU runner puts up to four suites on four
cores. Measured like-for-like against the same suites run alone on an idle
4-core box (#10152 / PR #10258), neither long pole is inherently over
#4859's 420s line -- cli 548.6s contended vs 337.13s alone, spec 496.4s vs
325.31s. What is over the line is the co-tenancy.
Six is the smallest shard count that puts `@objectstack/spec` -- 415 of the
workspace's ~2360 test files, and indivisible because sharding is BY
PACKAGE -- alone in its bin (415/389/389/389/389/388). Past six its shard
cannot improve, only the others can.
`--concurrency=4` is deliberately unchanged: narrower shards may want a
lower value, but moving both at once makes the next measurement unreadable.
The two gate self-tests that anchor on ci.yml's literal text move with it.
The `grow the matrix` anchor gains the full matrix including its closing
bracket -- the dogfood job also spells `shard: [1, 2, 3]` and
`String.replace` takes the first match, so the short anchor would have
silently started mutating dogfood's matrix instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5fGRTUHXFevsEdMEFDadM
Not for merge. Reverted in this PR before it leaves draft.
Touching a turbo globalDependencies entry is what forces this PR's Test
Core run to (a) schedule the FULL package list instead of the empty
affected set a `.github/**`-only diff produces, and (b) miss every turbo
cache entry, so the six shards report real cold-cache wall clock rather
than ">>> FULL TURBO".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5fGRTUHXFevsEdMEFDadM
The measurement it existed for is done: run 32396909164 scheduled the full
77-package list across six shards with `Cached: 0 cached, 68 total` on
cli's shard, and the numbers are in the PR body.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C5fGRTUHXFevsEdMEFDadM
@qq9340100
qq9340100 marked this pull request as ready for review August 20, 2026 17:53
@qq9340100
qq9340100 enabled auto-merge August 20, 2026 17:53
@qq9340100
qq9340100 added this pull request to the merge queueAug 20, 2026
Merged via the queue into main with commit b9a5c46Aug 20, 2026
31 checks passed
@qq9340100
qq9340100 deleted the claude/issue-10227-testcore-shard-contention branch August 20, 2026 18:11
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cdsize/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Decide: Test Core 超 #4859 阈值的是 runner 争用,不是包的固有成本 —— 加分片 / 劈开套件 / 重定基线

2 participants

@qq9340100@claude