perf(test): parallelize the unit suite (~17.5 min → ~6.6 min) and retire duplicate CI workflow - #4372
Conversation
|
Claude finished @Yeraze's task —— View job PR Review: perf(test): parallelize unit suite and retire duplicate CI workflowThis is a well-executed, thoroughly analyzed performance improvement. The investigation is solid, the root causes are identified with evidence, and the tradeoffs are explicitly documented. Here's my detailed review:
|
The unit suite took ~17.5 min in CI. Profiling showed only ~5.9 min of that was actually running tests; the rest was startup paid one file at a time. Measured on identical hardware, the "Run tests" step of Test Suite (24.x): before (main, 3 runs): 1056s / 1034s / 1061s (~17.5 min) after (this branch): 395s (~6.6 min) a 2.7x speedup, with a provably identical test set. Suite changes: - vitest.config.ts: drop `fileParallelism: false`. It was added in #295 (2025-10-24) as a drive-by in an unrelated feature PR, when the suite was 47 files. At ~790 files it meant each file's ~1s import/transform cost was paid serially. The adjacent `poolOptions.forks.singleFork` was already dead — Vitest 4 removed `poolOptions`, so it had silently stopped applying. - vitest.config.ts: add a serial `shared-db` project for the 15 files that use the shared PostgreSQL/MySQL `meshmonitor_test` database. They `DROP TABLE ... CASCADE` at import and genuinely overlap (`users` x3, `nodes` x3), so they would race under parallelism — and would do so only in CI, since they skip locally when the containers are down. Serial tail is ~18s. - vitest.config.ts: exclude `.claude/worktrees/**`. Locally, leftover agent worktrees were re-running the whole suite once per worktree (+11 min, +22k phantom tests, plus phantom failures from their own stale deps). - userTestHelper.ts: SALT_ROUNDS 12 -> 4. This test-only helper hashed at a HIGHER cost than production (10), from `beforeEach`, so it was paid per test: ~209ms/hash vs ~2ms. It alone accounted for ~185s of the ~352s total test execution time across the 5 files that use it. Nothing asserts the cost factor; production hashing is set separately in localAuth.ts / mfa.ts. Verified no coverage loss by diffing the full before/after test set: 11,808 tests, 0 missing, 0 added, 0 status changes. Green with and without the PG/MySQL containers (the containers add 570 multi-backend tests). CI changes (full suite ran 4x per PR -> 3x): - Delete pr-tests.yml. Every job in it was a strict subset of ci.yml: its `quick-test` duplicated the Node 24 matrix leg exactly (same lint, typecheck, test:run, same service containers), and `focused-tests` then re-ran parts of that suite again plus a build already covered unconditionally by Build Check. - Move its one piece of unique coverage, the VitePress docs build, into ci.yml. deploy-docs.yml only runs on push to main, so without this a docs break would be caught by the deploy rather than the PR. - Stop filtering ci.yml's pull_request by target branch. pr-tests.yml had no branch filter, so keeping ci.yml at main/develop would have left PRs targeting release/feature branches with no test run at all. - Carry over pr-tests.yml's concurrency group; ci.yml had none, so superseded runs kept burning runners to completion. - Update the dependabot wait-on-check regexp, README badge, and CONTRIBUTING. Combined, suite compute per PR drops from ~70 min (4 runs x ~17.5 min) to ~20 min (3 runs x ~6.6 min). The Node 22/24/25 matrix is kept: those are the runtimes actually shipped (armv7 image is 22, main image and LXC are 24). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB
9b672d2 to
2cefadd
Compare
|
Claude finished @Yeraze's task —— View job PR Review: perf(test): parallelize unit suite and retire duplicate CI workflowTasks:
This is a well-investigated and well-executed performance PR. The root-cause analysis is thorough (the
|
Correction: measured CI numbers (claimed ~3 min, actually ~6.6 min)The original title/description claimed ~20 min → ~3 min. That ~3 min came from a local run with Measured on identical hardware — the
~2.7x faster, not the ~5.9x I projected. Title and commit message corrected. Per-PR compute drops further because the duplicate 4th run is gone: ~70 min (4 x ~17.5) → ~20 min (3 x ~6.6). Everything else holds — CI is green, and the test-set diff (11,808 tests, 0 missing / 0 added / 0 status changes) was verified locally and is independent of hardware. Note on the first (red) runThe initial run failed and was not caused by these changes: Node 25.x died in That is an argument for |
Summary
The unit suite took ~20 min in CI. Profiling showed only ~5.9 min of that was actually running tests — the rest was per-file startup paid one file at a time.
Result: ~20 min → ~3 min on a 4-core runner, with a provably identical test set.
Verification
Diffed the complete before/after test set, keyed by file + full test name:
Green with and without the PostgreSQL/MySQL containers (containers add 570 multi-backend tests that skip locally).
npm run typecheckandnpm run lint:ciboth clean.npm run docs:buildverified before adding it as a gating job.Suite changes
fileParallelism: falsewas the single largest cost. Added in #295 (2025-10-24) as a drive-by in an unrelated feature PR, when this suite was 47 files. It is now ~790, and each file's ~1s import/transform cost was being paid serially.The adjacent
poolOptions.forks.singleForkwas already dead code — Vitest 4 removedpoolOptions, so it had silently stopped applying and only emitted a deprecation warning.One constant cost 53% of all test execution time.
userTestHelper.tsusedSALT_ROUNDS = 12— higher than production's 10 — and is called frombeforeEach, so it was paid per test. bcrypt at 12 is ~209ms/hash; at 4 it is ~2ms. The fingerprint was unmistakable: 206 of 211meshcoreRoutestests took exactly ~425ms, and all 34auditRoutestests exactly ~500ms. The five files importing this helper were precisely the five slowest files (185s of 352s). Nothing asserts the cost factor, and production hashing is set separately inlocalAuth.ts/mfa.ts.A real race had to be fixed for parallelism to be safe. 15 files share one
meshmonitor_testPostgreSQL/MySQL database and issueDROP TABLE ... CASCADEat import. The overlaps are genuine —users(3 files) andnodes(3 files). Naive parallelism would have raced in CI only, since those tests skip silently when the containers are down. They now run in a serialshared-dbproject; the serialized tail is only ~18s.Local-only: Vitest was collecting
.claude/worktrees/, re-running the entire suite once per leftover worktree (+11 min, +22k phantom tests, plus phantom failures from those trees' own stale dependencies). Now excluded — this is the Vitest half of the gotcha already documented in CLAUDE.md for the lint ratchet.CI changes — the full suite ran 4× per PR
ci.ymlruns a 3-way Node matrix (22/24/25), andpr-tests.ymlran it a fourth time. Every job inpr-tests.ymlwas a strict subset:quick-testduplicated the Node 24 matrix leg exactly — same lint ratchet, typecheck,test:run, same PG/MySQL service containers.focused-teststhen re-ran parts of that same suite again (src/components/, two backend files), plus a build already covered unconditionally byBuild Check.changed-filesexisted only to gate those two.So
pr-tests.ymlis deleted. Three things it provided were not redundant, and are preserved:ci.yml.deploy-docs.ymlonly runs on push to main, i.e. after merge, so without this a docs break is caught by the deploy rather than the PR.pr-tests.ymlhad no target-branch filter;ci.ymlwas limited tomain/develop. That filter is removed, otherwise PRs targeting release/feature branches would have had no test run at all.ci.ymlhad none, so superseded pushes kept burning runners to completion.Also updated the dependabot
wait-on-checkregexp (it waited on the now-goneQuick Tests), the README badge, and CONTRIBUTING.The Node 22/24/25 matrix is kept — those are the runtimes actually shipped (armv7 image is 22; main image and LXC are 24). Cutting it would be real coverage loss, not deduplication.
Notes
maxWorkersrather than re-serializing; the config says so.channelRoutes.test.tsstill has real 3.5–5.5s timer waits worth converting to fake timers (no longer on the critical path now that files run in parallel), and the knownEnvironmentTeardownErrorteardown flake appeared once in five local runs —LOG_LEVEL=warnsilences the migration log spam that drives it, but it is no faster and would strip log context from CI failures, so it is not included here.🤖 Generated with Claude Code
https://claude.ai/code/session_01L9NzRtqE8eSMS8tvAeodUB