perf(SDK-6463): prune excluded dirs from zip/md5 walks — minutes-long 'Creating tests.zip' stall on monorepos#1142
perf(SDK-6463): prune excluded dirs from zip/md5 walks — minutes-long 'Creating tests.zip' stall on monorepos#1142Bhargavi-BS wants to merge 3 commits into
Conversation
…emetry size walk (SDK-6463) On large monorepos the CLI stalled for minutes before uploading: 1. archiver.glob and the spec-md5 walk (hashUtil via checkUploaded) pass excludes as readdir-glob 'ignore', which filters entries only AFTER the walker has descended into and lstat'ed every file under node_modules/.git/dist/etc. The customer's log showed ~86s in 'Creating tests.zip' and a similar hidden cost in the md5 step. 2. utils.fetchFolderSize(node_modules) — telemetry only — recursively stats the entire node_modules between archiving and uploading. Fix: reuse every ignore pattern ending in '/**' as readdir-glob's 'skip' option (getDirectorySkipPatterns), which prevents descending into matching directories. This is provably safe: a directory matching '<x>/**' means every descendant also matches, so pruning cannot change the archive contents or the md5. Verified on a synthetic 144k-file monorepo with the customer's exact 130 exclude patterns: zip 7.5s -> 0.1s and md5 5.5s -> 0.0s, with byte-identical zip entry lists and an UNCHANGED md5 (upload cache keys unaffected). Also adds a 5s cooperative deadline to the folder-size telemetry walk so it can never stall a run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| // between archiving and uploading for tens of seconds. Stop descending once the | ||
| // deadline passes — folder size is best-effort telemetry, never worth stalling a run. | ||
| if (deadline && Date.now() > deadline) { | ||
| return 0; |
There was a problem hiding this comment.
Any downstream effects from this?
There was a problem hiding this comment.
Verified the full flow — no functional downstream effects:
fetchFolderSizehas exactly one caller (runs.js→node_modules_size), and that value has exactly one sink: thedataToSendinstrumentation object, merged intobuildReportDataand sent viasendUsageReport. It is telemetry-only — nothing branches on it (no upload decisions, no timeouts, no user-facing output).getDirectorySizeis module-private (not exported), so there are no other consumers.
Behavior when the 5s deadline fires: the reported node_modules_size is partial (under-reported) for that run, with a debug log noting it. Worth noting the field was already best-effort — the existing catch returns 0 on any readdir/stat error, so consumers already tolerate inaccurate values. On repos where the walk finishes within 5s (the common case) the value is unchanged.
The trade: bounded, slightly-lossy telemetry vs. blocking the archive→upload pipeline for tens of seconds on large monorepos (the reported case). Happy to raise the cap or make it env-configurable if instrumentation accuracy is a concern.
There was a problem hiding this comment.
Pushed an improvement on this in 609d50d that removes the concern entirely: the walk no longer blocks anything. fetchFolderSize is now started before the upload (not awaited) and awaited only after the upload completes, right before the value is consumed for dataToSend. In the common case the walk resolves while the upload is in flight, so it adds zero wall-clock and reports the full, accurate size. The 5s deadline stays as a backstop for pathological trees whose walk outlives the upload (worst case: partial size in the usage report, never a stalled run). fetchFolderSize never rejects, so the floating promise is safe.
There was a problem hiding this comment.
Verified both modes, structurally and with real runs.
Structure — the await sits on the shared path before the modes ever diverge:
runs.js:286 walk starts (not awaited) ─┐
runs.js:314 await nodeModulesSizePromise ├─ shared path, both modes
runs.js:341 createBuild(...) ─┘
runs.js:378 if (args.sync) → pollBuildStatus ← first sync/async divergence
runs.js:437 if (!args.sync) → exit message
runs.js:450 dataToSend.node_modules_size ← plain resolved local, both modes
The value is awaited once, at a single point common to both modes, before createBuild — neither mode can observe an unresolved value, and neither mode's control flow changed. (fetchFolderSize never rejects, so the floating promise can't produce an unhandled rejection either.)
Empirical — real runs on a 144k-file synthetic monorepo (customer-style home_directory: "./" + their 130 exclude patterns), this branch @ 609d50d:
| Mode | Zip creation | Upload | Build created | After build |
|---|---|---|---|---|
| async (default) | <1s | 6s | ✅ 5df4c606… |
usage report sent, clean exit 0 |
--sync |
<1s | 4s | ✅ c45b0044… |
polling started ("BrowserStack machines are now setting up…") and followed the build ✓ |
For reference, published 1.36.12 on the same repo: 5s zip creation and 13s in the pre-zip md5 phase — this branch reduces both to ~0 (and on the reporting customer's Windows machine with a real NX tree, the zip phase alone was 86s).
Same review convention as requested on #1131. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he upload The node_modules size is instrumentation-only, so there is no reason to block the upload on walking the tree. Start the walk before the upload and await it only after the upload completes — in the common case it resolves while the upload is in flight, adding zero wall-clock. The 5s deadline in fetchFolderSize remains as a backstop for trees whose walk outlives the upload. fetchFolderSize never rejects, so the floating promise is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDK-6463 (follow-up) — minutes-long stall creating tests.zip on large monorepos
Customer (Sapiens, NX monorepo, ~120 exclude globs,
home_directory: "./") reported very slow test-setup upload. Their log shows the time is NOT the upload (3s, small zip) — it's spent before it:plus a similar hidden cost earlier in the spec-md5 cache check.
Root cause (verified)
Three full-tree filesystem walks of the monorepo root, none of which prune excluded directories:
archiver.glob(pattern, { ignore })(bin/helpers/archiver.js). archiver delegates toreaddir-glob, whoseignorefilters entries after the walker has alreadyreaddir+lstat-ed them. It therefore descends intonode_modules,.git,dist, … visiting every file just to discard it. The library's separateskipoption is the pruning mechanism — the CLI never used it.checkSpecsMd5→hashUtilusesreaddir-globthe same way: a second identical full-tree walk.utils.fetchFolderSize(node_modules)(runs.js) recursively stats the entirenode_modules, blocking between archive and upload, only to report a folder size.On the customer's Windows machine (per-file stat + AV scanning) with a real NX monorepo this is minutes per run.
Fix
utils.getDirectorySkipPatterns(ignoreFiles)— reuse every ignore pattern ending in/**as areaddir-globskippattern; wired into both the archive glob and the md5 walk.Safety proof:
skipprunes a directory whose relative path matches the pattern. A directory matching<x>/**is inside<x>, so every one of its descendants also matches<x>/**and was going to be ignored anyway — pruning cannot change which files are included. (Pruning engages one level below each excluded root: e.g.node_modules/**skips every child ofnode_modules, paying a single readdir ofnode_modulesitself.)fetchFolderSize— cooperative 5s deadline; folder size is best-effort telemetry and must never stall a run.Verification (real CLI code, synthetic 144k-file monorepo, customer's exact 130 exclude patterns)
archiveSpecs(tests.zip)checkSpecsMd5aeceb90f…) → upload-cache keys unaffected, no forced re-uploadsDirect
readdir-globmeasurement on the same tree: no-skip walk 3.04s / 140,883 entries visited vs skip 0.08s / 2,083 — the excluded trees are simply never entered.Unit tests added for
getDirectorySkipPatterns; suite: 675→677 passing, 0 new failures (16 pre-existing on master).Scope
Separate from PR #1131 (config/a11y) and #1139 (a11y fail-guard): this addresses the customer's "test setup zip upload taking a lot of time" report independently.
🤖 Generated with Claude Code