Skip to content

Shard realm-server tests by duration instead of file count - #5966

Merged
backspace merged 3 commits into
mainfrom
cs-12731-realm-server-weighted-shards
Sep 3, 2026
Merged

Shard realm-server tests by duration instead of file count#5966
backspace merged 3 commits into
mainfrom
cs-12731-realm-server-weighted-shards

Conversation

@backspace

@backspace backspace commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This rebalances the realm server tests like the other sharded suites:

mainthis branch
s 2026-09-02 at 07 18 57@2x s 2026-09-02 at 07 20 08@2x

Claude: shard-test-modules.cjs dealt an alphabetical list round-robin, which balances how many files each shard gets rather than how long they take. Across four main runs that produced a 5x spread — 266s to 1544s — and the Realm Server job waits on the slowest. Because the order shifts whenever a file is added or renamed, the slow shard is not a fixed one: it moved from shard 1 to shard 2 between 08-31 and 09-01, which is why this reads as "shard N got slow" rather than as a balance problem.

Packing heaviest-first into the lightest shard flattens it. Against measured weights the projected spread is 10s, and the slowest shard's test time falls from ~1300s to ~637s. With the ~197s of per-shard setup that is roughly 25 minutes down to 14, at no machine-time cost — the same work, redistributed.

Two supporting changes.

The junit reporter recorded data.module, which QUnit 2.x's testEnd payload does not have — it provides name, suiteName, fullName, runtime and status. Every testcase therefore landed in a suite called "default", making the report useless for attribution. It now reads fullName[0], which is the outermost module, and every test file opens with
module(basename(import.meta.filename), …) — so a suite name is now a file name, exactly and without inference.

generate-test-module-timings.mjs turns a merged junit report into the weights file, keeping prior values for files a run did not cover and refusing to write when attribution drops below 90% — which is what a regression back to "default" would look like.

The committed weights were derived from run 33535224316 by differencing the timestamps on its TAP lines, since no attributed junit exists until the reporter fix has run. They cover 183 of 189 files; the remaining six take DEFAULT_WEIGHT. Regenerate them from junit once this has landed.

With no weights file present the old round-robin split is used unchanged — verified to produce byte-identical assignments for all six shards.

`shard-test-modules.cjs` dealt an alphabetical list round-robin, which
balances how many files each shard gets rather than how long they take.
Across four main runs that produced a 5x spread — 266s to 1544s — and the
Realm Server job waits on the slowest. Because the order shifts whenever a
file is added or renamed, the slow shard is not a fixed one: it moved from
shard 1 to shard 2 between 08-31 and 09-01, which is why this reads as
"shard N got slow" rather than as a balance problem.

Packing heaviest-first into the lightest shard flattens it. Against measured
weights the projected spread is 10s, and the slowest shard's test time falls
from ~1300s to ~637s. With the ~197s of per-shard setup that is roughly 25
minutes down to 14, at no machine-time cost — the same work, redistributed.

Two supporting changes.

The junit reporter recorded `data.module`, which QUnit 2.x's testEnd payload
does not have — it provides name, suiteName, fullName, runtime and status.
Every testcase therefore landed in a suite called "default", making the
report useless for attribution. It now reads `fullName[0]`, which is the
outermost module, and every test file opens with
`module(basename(import.meta.filename), …)` — so a suite name is now a file
name, exactly and without inference.

`generate-test-module-timings.mjs` turns a merged junit report into the
weights file, keeping prior values for files a run did not cover and
refusing to write when attribution drops below 90% — which is what a
regression back to "default" would look like.

The committed weights were derived from run 33535224316 by differencing the
timestamps on its TAP lines, since no attributed junit exists until the
reporter fix has run. They cover 183 of 189 files; the remaining six take
DEFAULT_WEIGHT. Regenerate them from junit once this has landed.

With no weights file present the old round-robin split is used unchanged —
verified to produce byte-identical assignments for all six shards.

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

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 6m 54s ⏱️ + 2m 16s
4 718 tests +37  4 703 ✅ +36  14 💤 ±0  0 ❌ ±0  1 🔥 +1 
4 733 runs  +37  4 717 ✅ +35  14 💤 ±0  1 ❌ +1  1 🔥 +1 

Results for commit 971fa52. ± Comparison against earlier commit fd078e5.

For more details on these errors, see this check.

Realm Server Test Results

    1 files  ± 0    190 suites  +4   1h 8m 49s ⏱️ -50s
2 505 tests +45  2 505 ✅ +45  0 💤 ±0  0 ❌ ±0 
2 544 runs  +45  2 544 ✅ +45  0 💤 ±0  0 ❌ ±0 

Results for commit 971fa52. ± Comparison against earlier commit fd078e5.

@backspace
backspace marked this pull request as ready for review September 2, 2026 11:21
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 2, 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-09-02T11:25:21.449793Z eeaa178 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eeaa178630

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/realm-server/scripts/generate-test-module-timings.mjs Outdated
Codex caught that the generator maps suite names to files through a
basename-only table, while the 44 nested test files declare path-qualified
titles — `realm-endpoints/info-test.ts`, not `info-test.ts`. Every one of
them missed. That is 1,280s of the 3,794s the weights file accounts for, so
attribution landed at 66.2%, under the generator's own 90% floor: the
regeneration command documented at the top of the file would have exited 1
without ever writing a weight.

Three shapes are in use, and now all three resolve — a bare basename, a
path-qualified name, and either followed by ` | qualifier` where one file
declares more than one top-level module (`node-realm-test.ts | file stat
probing`). Dropping a qualifier is a retry rather than a guess: the
shortened name still has to name a file on disk before it is accepted.

Four files had top-level modules named after nothing in particular
("prerender artifact-sink gates"), which no lookup can resolve. They are
retitled to name their file, so the convention the reporter and the
generator both document is now true of every file rather than of 141 of
189. Their 34 tests pass unchanged.

The generator also now lists files it has never measured. Those are packed
at DEFAULT_WEIGHT, so a genuinely slow one distorts a shard for as long as
it stays invisible, and coverage staying above the floor makes that easy to
miss — six files are in that position today.

Verified by building a junit report from the actual module titles of all 189
files, with each file's committed weight split across its suites: 100.0%
attributed, 189 measured, and all 183 known weights reproduced exactly,
which also exercises the summing of a file's several suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@backspace
backspace requested a review from a team September 2, 2026 15:50

@habdelra habdelra 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.

[Claude Code 🤖] Reviewed the packing algorithm, the attribution chain from testEnd through the junit report to the weights file, and the failure modes of each — not the six retitled modules' test bodies beyond confirming they still name their files.

No blocking issues. The headline claims all check out against the branch: the pack is an exact partition (189 files, each assigned once), the projected spread is 10.0s with a 637.3s slowest shard, and with the weights file removed the assignment is byte-identical to main's round-robin for all six shards. Every finding below is about what happens when this machinery degrades, not about whether it works.

The three shapes of suite name from the resolved thread are handled — I rebuilt the resolution against the actual module titles of all 189 files and every one resolves to its own file, including the | qualifier retry.

Recommendations, in the order I'd act on them:

  1. Pin the "every top-level module is named after its file" convention with a test — it is what makes attribution work at all, and it drifts (see the generator comment).
  2. Add the partition test the matrix twin already has for the same algorithm (comment on assignByWeight).
  3. Fail on an unparseable weights file instead of silently reverting to round-robin (comment on loadTimings).
  4. DEFAULT_WEIGHT's "roughly the median" is off by ~5x, and > 0 conflates measured-zero with unmeasured (two comments on those lines).
  5. moduleNameFor's suiteName fallback is unreachable for the case it documents (comment on the reporter).

CI is green across all six shards; observed wall clocks were 13m10s–15m47s, so the improvement is real and the residual spread is setup jitter rather than packing.

Adjacent, out of scope: nothing regenerates these weights. The matrix suite commits its spec-timings.json from CI behind a staleness guard and a drift gate (--min-drift-seconds, --shard-count); here regeneration is a manual gh run download plus a script run, so the weights decay as 189 files churn and each new file packs at DEFAULT_WEIGHT. Not this PR's job, but it is what decides whether this stays flat six months from now.


Generated by Claude Code

Comment thread packages/realm-server/scripts/shard-test-modules.cjs Outdated
Comment thread packages/realm-server/scripts/shard-test-modules.cjs Outdated
Comment thread packages/realm-server/scripts/shard-test-modules.cjs Outdated
Comment thread packages/realm-server/scripts/junit-reporter.cjs
Comment thread packages/realm-server/scripts/generate-test-module-timings.mjs
… no measurement

Review follow-ups on the duration-weighted split. All five findings held up
against the branch; the two tests are the substance.

`tests/shard-assignment-test.ts` pins the property CI silently depends on:
every shard computes the assignment for all shards and keeps its own bucket,
so nothing at runtime checks that the buckets agree. `index % totalShards`
was self-evidently a partition; a bin-pack recomputed on six machines is not,
and the cost of it being wrong is a file that runs twice or not at all. It
covers shard counts 1, 2, 3, 4, 6, 7 and 30, the round-robin bootstrap, and
reproducibility under a reordered discovery.

The same file pins the module-title convention, which is what makes a junit
suite name resolvable to a file and which drifts on its own — four files had
free-form titles when weighting was introduced, and the only signal was a
console.warn in a manual run. Both scripts assert it in prose; now it fails a
test. Checked by mutation: a free-form title, a nested file dropping its
directory prefix, a pack returning one bucket for every shard, and a dropped
sort tiebreak each fail exactly one test with a legible message.

The weight rule now distinguishes a file measured at zero from one never
measured. The seed run resolved whole seconds, so a recorded 0.0 means "under
the resolution of the clock", not "free" — and it also stands in for the
per-file import and hook cost a sum of per-test runtimes cannot see. Charging
both cases DEFAULT_WEIGHT put 270s of load that does not exist onto the pack:
54 of 183 files. Measured weights now take a 1s floor, and only an absent
measurement takes the default. Projected per-shard load drops from 677.3s to
645.0-646.0s, and the numbers now describe the real suite.

DEFAULT_WEIGHT's comment claimed it was roughly the median. The median of the
committed weights is 1.1s; 5 is about the 60th percentile. The value is right
— an unmeasured file is unknown rather than small — but the wrong reason
invites someone to "correct" it down and make new tests nearly free.

A weights file that exists but does not parse now throws instead of falling
back to round-robin. The file is committed and generated, which is what a
merge resolves badly, and the fallback made that invisible: green CI, back to
a 5x spread, nothing on stderr. An absent file still bootstraps.

The reporter's `suiteName` fallback could not fire for the case it documented.
A test declared outside any module has fullName `[testName]` and no suiteName
at all (verified against qunit 2.26.0), so `fullName[0]` was truthy and such a
test got a testsuite named after itself — unresolvable, eroding the
generator's attribution budget under a name that reads like a module. It now
lands in "default", which is what makes it visible.

Two smaller things. A coverage failure caused by colliding basenames printed
an empty "Unattributed suites:" list and then pointed at the reporter, the
wrong place to look; ambiguous names are now listed there too. And suite-name
resolution moved to scripts/test-module-names.mjs, shared with the test and
re-exporting discovery from the splitter so the two cannot disagree about
which files exist.

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

Copy link
Copy Markdown
Contributor Author

[Claude Code 🤖] All five taken, in 971fa52 — replies on each thread, all resolved.

Two things that belong here rather than on a thread.

A correction to the PR description. The "projected spread is 10.0s, slowest shard ~637s" was computed with a weight rule the script never implemented (zeros staying zero). Under the rule as written the pack was perfectly balanced — 677.3s on all six shards — but 270s of that was phantom, because 54 files measured at 0.0 were each charged DEFAULT_WEIGHT. With a measured zero now floored at 1s and the default reserved for files with no measurement at all, the projection is 645.0–646.0s per shard, spread 1.0s. Better numbers, and this time they describe the suite that exists. The description above is stale on this point.

The adjacent item is filed: CS-12753 — Realm-server shard weights have no automated refresh. Agreed it is what decides whether this stays flat; realm-server's version should be simpler than the host's, since with no Percy and no index cache a main push run and a PR run are the same shape, so there is no equivalent of CS-12582's run-shape filtering to get right.

New in this push: packages/realm-server/tests/shard-assignment-test.ts (7 tests, mutation-checked), scripts/test-module-names.mjs sharing suite-name resolution between the generator and that test, and the three script fixes.

@backspace
backspace merged commit 1336649 into main Sep 3, 2026
61 of 64 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.

2 participants