Skip to content

fix(bundle): collect each junit report once, not once per path to it - #1186

Merged
trunk-io[bot] merged 4 commits into
mainfrom
dedupe-symlinked-junit-files
Sep 4, 2026
Merged

fix(bundle): collect each junit report once, not once per path to it#1186
trunk-io[bot] merged 4 commits into
mainfrom
dedupe-symlinked-junit-files

Conversation

@dfrankland

@dfrankland dfrankland commented Sep 3, 2026

Copy link
Copy Markdown
Member

Problem

scan_from_glob relies on the glob crate, whose ** deliberately resolves symlinked directories:

// glob 0.3.3, PathWrapper::from_dir_entry
if file_type.is_symlink() { None } else { Some(file_type.is_dir()) }
// "We need to use fs::metadata to resolve the actual path if it's a symlink."
.or_else(|| fs::metadata(&path).map(|m| m.is_dir()).ok())

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/*.xml then 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.bin was 410MB:

report paths collected 1,658
distinct files on disk 19
paths reached via a symlinked directory 1,639 (98.9%)
test_case_runs 1,717,605
distinct tests 9,471
redundant runs 99.4%

84% of that 410MB is repeated name/classname/file strings.

The duplicates are one file reached many ways, not many files that happen to match. last_modified_epoch_ns is 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.rs does File::open(path) + tar.append_file(...), so every duplicate route ships its own full copy of the bytes.

Downstream consequence. The CLI leaves attempt_number unset, 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_glob expands 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.

RepoRoot holds the invariant that made this subtle. Reported paths are canonical, so the root they are made relative to must be canonical too; otherwise strip_prefix misses (/var vs /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::Handle

It 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 exhausts RLIMIT_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-run and asserting on the produced meta.json and internal.bin together:

  • 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
  • a symlinked artifacts directory inside the repo is reported at the real location
  • a symlink that leaves the repo keeps its repo-relative route

The fixture builds a five-package workspace whose packages are symlinked into each other's dependency directories, which a ** glob reaches by 31 routes. Reverting bundle/src/files.rs fails 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-targets is 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_tests now 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.
  • A FileSet can 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.
  • A symlinked directory inside the repo now reports the real location of its files rather than the route used to reach them.

The meta.json wire format is unchanged: Vec<FileSet> still expresses this, just as a partition rather than overlapping bags. No new BundleMetaV0_* variant.

Follow-up, not in this PR

defect.rs reconstructs a test_runner_report from file_sets by matching test_case_runs[].file (a repo-relative source file, empty when the runner omits it) against files[].original_path (an absolute path to a report file). Those are different categories of value, so the lookup always yields None.

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 from internal.bin now. 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 off file_sets, and the likely fix is to delete it. Worth confirming against a BEP bundle first.

🤖 Generated with Claude Code

@trunk-io

trunk-io Bot commented Sep 3, 2026

Copy link
Copy Markdown

😎 Merged successfully - details.

Comment thread bundle/src/files.rs Outdated
Comment thread bundle/src/files.rs Outdated
Comment thread bundle/src/files.rs Outdated
Comment thread bundle/src/files.rs Outdated
Comment thread bundle/src/files.rs Outdated
Comment thread bundle/src/files.rs Outdated
Comment thread cli/tests/common/utils.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
Comment thread cli/tests/upload.rs Outdated
`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.
@trunk-io
trunk-io Bot merged commit c2578d9 into main Sep 4, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants