fix(bundle): collect each junit report once, not once per path to it - #1186
Merged
Conversation
|
😎 Merged successfully - details. |
dfrankland
commented
Sep 3, 2026
dfrankland
commented
Sep 4, 2026
`scan_from_glob` relies on the `glob` crate, whose `**` deliberately resolves
symlinked directories. In a workspace that links its packages into each other's
dependency directories, every package is reachable through every dependency edge,
so a glob like `packages/**/test-results/*.xml` yields one match per route through
the dependency graph rather than one per file on disk.
One reported bundle collected 1658 paths that resolve to 19 files: a 410MB
`internal.bin` holding 1,717,605 test case runs of 9,471 distinct tests, 84% of
it repeated name/classname/file strings. Since the CLI leaves `attempt_number`
unset, the ingestion side then derives it from a per-test positional counter, so
a test that ran once is recorded as having been retried tens of thousands of
times.
Split discovery from grouping. `discover_routes` expands every glob and reduces
the matches to one `Route` per physical file, keyed by `FileId` -- `(dev, ino)`
on unix, which also collapses hardlinks, and the resolved path elsewhere, since
`file_index()` is still unstable in std. Grouping then rebuilds the file sets in
caller order with each file appearing exactly once, which also fixes the
double-count when two globs match the same file.
Two independent choices, deliberately decoupled:
- the path reported is the route crossing fewest symlinks, so a file resolves
back to its own package directory rather than a linked-in alias
- the file set owning it is the first glob to match, which respects the order
the caller listed and matches how the services side already resolves a
file's test runner report (first match wins)
Symlink following is unchanged. Ranking only chooses among several routes to the
same file, so a report reachable only through a symlink -- a single hop that is a
literal component of the glob rather than part of a `**` -- is still collected
and still reported at its symlinked path.
`num_files` and `num_tests` now count distinct files and runs. A `FileSet` whose
matches were all claimed by an earlier glob is kept but empty, preserving the
record that the glob was specified.
Canonicalizing already yields the most direct route to a file, so the symlink ranking was doing by hand what the filesystem can answer directly: the canonical path of a `node_modules` alias *is* the package's own path. That removes the `FileId` enum and its `#[cfg(unix)]` split, the `symlink_depth` walk and its memo cache, and the route ranking -- one `canonicalize` per match replaces an `lstat` per path component, and Windows stops being a separate code path. Reported paths are now canonical, so `repo_root` is canonicalized once and used for both the glob join and the `strip_prefix` that makes paths repo-relative. Without that the prefix misses on macOS (`/var` vs `/private/var`) and every path silently falls back to an absolute one, which codeowners cannot match. Canonicalizing can also leave the repo, when an artifacts directory is a symlink to somewhere outside the tree. The globbed route is kept in that case so the reported path stays repo-relative, covered by `keeps_the_globbed_path_when_canonical_escapes_the_repo`. Discovery now returns the files each glob owns, which lets the file sets be built by folding over `junit_paths.zip(files_per_glob)` instead of regrouping claimed routes through a scratch vec. `collect_files_per_glob`, `bundle_files` and `file_set_type` split the work so no single function nests two folds. Hardlinks no longer collapse, since two hardlinks have distinct canonical paths. These reports are test-time outputs rather than content-addressed store entries, so hardlinking them is not a case worth the platform split; that test is dropped.
Moves the dedup coverage out of a `#[cfg(test)]` module in `bundle` and into the
upload e2e suite, so it exercises the whole path -- glob expansion, `meta.json`,
and `internal.bin` together -- rather than `build_file_sets` alone.
`generate_mock_linked_workspace` builds a JS-monorepo-shaped fixture whose five
packages are symlinked into each other's `node_modules`, which a `**` glob reaches
by 31 routes. Against the previous implementation the first test fails 31 vs 5,
and it is the bundled file count that is wrong, not an internal detail.
Four cases, all unix-gated since the fixtures need symlinks:
- reports are collected once per package, with `internal.bin` holding one run
per test rather than one per route, and no synthesized attempt numbers
- two overlapping globs do not each contribute a copy, and the glob that owns
nothing keeps its empty file set
- an artifacts directory that is itself a symlink is still collected, reported
where the reports actually live
- a symlink that leaves the repo keeps its repo-relative route, since resolving
it would produce an absolute path outside the tree that codeowners cannot match
Two explanatory comments were carrying an invariant the code left implicit: the root a path is made relative to has to be canonical too, or `strip_prefix` misses (`/var` vs `/private/var`) and every path silently falls back to an absolute one that codeowners cannot match. `RepoRoot` holds that invariant instead -- it canonicalizes on construction, and `within_or` names the rule for a file linked in from outside the repo, so both comments are gone. `canonicalize` and `within_or` stay separate calls because the dedup key must be the canonical path even for a file outside the repo. Collapsing them into one `resolve` would key on the reported path, and two symlinks to the same external file would stop deduplicating. The fixtures no longer describe any particular repository. Packages are `packages/<name>` holding reports under `test-results/` -- the layout this CLI's own `--junit-paths` help text uses -- with generic package names and the reserved `@example` scope. The behavior under test is unchanged: the workspace still resolves to 31 routes over 5 files, and reverting `files.rs` still fails the count assertion 31 vs 5.
dfrankland
force-pushed
the
dedupe-symlinked-junit-files
branch
from
September 4, 2026 06:32
55b02f1 to
f6de36c
Compare
max-trunk
approved these changes
Sep 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
scan_from_globrelies on theglobcrate, whose**deliberately resolves symlinked directories:A workspace that links its packages into each other's dependency directories makes every package reachable through every dependency edge. A glob such as
packages/**/test-results/*.xmlthen yields one match per route through the dependency graph rather than one per file on disk, and the same report is bundled once per route.The reported bundle was 3.9MB compressed and expanded to 880MB, of which
internal.binwas 410MB:test_case_runs84% of that 410MB is repeated
name/classname/filestrings.The duplicates are one file reached many ways, not many files that happen to match.
last_modified_epoch_nsis a perfect function of the terminal package — 878 paths sharing one nanosecond-identical mtime and one content hash, with zero violations — and every package directory is byte-and-nanosecond identical to its alias under another package's dependency directory.Contents are physically duplicated, not linked:
bundler.rsdoesFile::open(path)+tar.append_file(...), so every duplicate route ships its own full copy of the bytes.Downstream consequence. The CLI leaves
attempt_numberunset, so the services side derives it from a per-test positional counter. Duplicate copies of one test are therefore recorded as retries of it — in this bundle, up to 35,616 attempts of a test that ran once.This is not a recent regression: the glob code is unchanged since #276 (2025-01-10), and the reporting org's maximum bundle size has been flat for three months. A dependency graph grew into a latent bug.
Fix
Split discovery from grouping.
collect_files_per_globexpands every glob and returns the files each one owns, keyed by canonical path, so a file is bundled once no matter how many globs reach it or how many routes lead to it. That also fixes double-counting when two globs match the same file, which the previous per-glob structure could not express.Canonicalizing is what makes this simple: the canonical path of a symlinked alias is the real file's path, so there is no ranking of candidate routes to do — the filesystem answers it directly.
RepoRootholds the invariant that made this subtle. Reported paths are canonical, so the root they are made relative to must be canonical too; otherwisestrip_prefixmisses (/varvs/private/var) and every path silently falls back to an absolute one that codeowners cannot match.Ownership is first glob to match, which respects the order the caller listed and matches how the services side already resolves a file's test runner report (first match wins).
Symlink following is unchanged
Deduplicating is orthogonal to following. A report reachable only through a symlink — a single hop that is a literal component of the glob rather than part of a
**— is still collected. Two cases are covered explicitly: a symlinked artifacts directory inside the repo is reported where the reports actually live, and one that leaves the repo keeps its repo-relative route, since resolving it would produce an absolute path outside the tree that codeowners could not match.Note on
same_file::HandleIt is the obvious primitive for file identity and is already in the lock file, but it retains an open descriptor for the handle's lifetime (
file: Option<File>). A set over a large glob exhaustsRLIMIT_NOFILE— 1,658 handles is past the 1024 Linux default and far past macOS's 256. Canonical paths avoid the problem entirely and need no platform split, unlike(dev, ino), whose Windows equivalent (file_index()) is still unstable in std.Verification
Four e2e tests in the upload suite, driving a real
trunk upload --dry-runand asserting on the producedmeta.jsonandinternal.bintogether:internal.binholding one run per test rather than one per route, and no synthesized attempt numbersThe fixture builds a five-package workspace whose packages are symlinked into each other's dependency directories, which a
**glob reaches by 31 routes. Revertingbundle/src/files.rsfails the first test 31 vs 5, and the failing assertion is the bundled file count — the user-visible outcome, not an internal detail.All 58 upload tests pass.
cargo check --workspace --all-targetsis clean, with no new clippy warnings. All fixtures are unix-gated, since creating symlinks on Windows needs elevation; the canonical-path logic itself is cross-platform.Behavior changes to be aware of
num_files/num_testsnow count distinct files and runs. For the reported bundle that is 1,658 → 19 and 1.7M → ~9.5k. Correct, but a visible discontinuity in anything trending them.FileSetcan now be empty if an earlier glob claimed everything it matched. Kept rather than dropped, so the meta still records that the glob was specified.The
meta.jsonwire format is unchanged:Vec<FileSet>still expresses this, just as a partition rather than overlapping bags. No newBundleMetaV0_*variant.Follow-up, not in this PR
defect.rsreconstructs atest_runner_reportfromfile_setsby matchingtest_case_runs[].file(a repo-relative source file, empty when the runner omits it) againstfiles[].original_path(an absolute path to a report file). Those are different categories of value, so the lookup always yieldsNone.Its only consumer is
junit_validate, and none of the file-set runner report's fields (resolved_status,resolved_start_time_epoch_ms,resolved_end_time_epoch_ms) are read anywhere on the services side — label, timing and status all come frominternal.binnow. So no test data is affected; the impact is confined to validation warnings that never fire. It looks like a straggler from the migration that moved label parsing offfile_sets, and the likely fix is to delete it. Worth confirming against a BEP bundle first.🤖 Generated with Claude Code