Skip to content

Build zip entry paths by prefix instead of relative_path_from - #18

Open
tas50 wants to merge 1 commit into
chef:mainfrom
tas50:perf/tmpzip-entry-paths
Open

tas50 wants to merge 1 commit into
chef:mainfrom
tas50:perf/tmpzip-entry-paths

Conversation

@tas50

@tas50 tas50 commented Aug 28, 2026

Copy link
Copy Markdown

Summary

Zipping a directory is the expensive half of a folder upload, and a fifth of it was going into Pathname#relative_path_from.

Profiling TmpZip.new over a 5,003-file cookbook tree (Ruby 4.0.6, macOS arm64):

TmpZip.new whole      : 1140.7 ms
  of which entries    :   97.5 ms
  relative_path_from  :  245.8 ms   (5003 calls)
  string-slice equiv  :    1.7 ms   <- same 5003 entries

relative_path_from splits both paths into components and walks them looking for a common root. But every entry is by construction a descendant of the glob root, so the relative path is just the entry with the base prefix stripped.

#entries also did more work than needed:

  • globbed the tree twice into Pathname objects, including the directories it was about to delete_if
  • push(*array) splats the entire second glob across the stack
  • Array#sort over Pathnames re-runs Pathname#<=>, which tr-translates "/" in both operands on every comparison — sort_by builds that key once per entry instead

Ordering is preserved exactly — this is the risky part

Entry order determines the archive bytes, and therefore its SHA1, which is precisely the value the dirty check compares. So this deliberately does not switch to a plain String sort, which is not the same ordering:

names = ["a/b", "a-b", "a.b", "a0b", "ab"]
names.sort                                  #=> ["a-b", "a.b", "a/b", "a0b", "ab"]
names.map { Pathname.new(_1) }.sort         #=> ["a/b", "a-b", "a.b", "a0b", "ab"]
names.sort_by { _1.tr("/", "\0") }          #=> ["a/b", "a-b", "a.b", "a0b", "ab"]  <- used here

I also left the two-glob (**/.* then **/*) structure alone. Collapsing it to a single File::FNM_DOTMATCH glob is tempting and faster, but it is not equivalent — **/ descends into hidden directories under FNM_DOTMATCH and does not without it:

current (2 globs)      : [".dotfile", "normal/.ndot", "normal/n.txt", "plain.txt"]
single glob + DOTMATCH : [".dotfile", ".hiddendir/inside.txt", ".hiddendir/nested/deep.txt", ...]

That would change what gets uploaded, so it is out of scope here. (Whether the current behaviour of silently skipping files under .chef/, .delivery/ etc. is desirable is a separate question I'm happy to open an issue for.)

Verification

Built archives with main and this branch and compared the archive SHA1 and the entry-name ordering, across three trees:

tree entries archive SHA1 identical ordering identical
5,003-file cookbook tree 5003 yes yes
unicode / spaces / 25-level nesting / ordering stressors 13 yes yes
hidden dirs + dotfiles 4 yes yes

Checked both with Zip.unicode_names at its default and set to true as FileTransporter#initialize sets it during a real upload.

Also confirmed prefix-stripping matches relative_path_from for base directories given as dir, dir/, dir//, dir///, a nested subdirectory, . and ./ — hence the sub(%r{/*\z}, "/") normalisation rather than a plain chomp("/"), which gets dir// wrong.

New specs lock in the ordering rule and the dot-directory behaviour. Both pass against unmodified main too — they describe existing behaviour rather than the new implementation — and the ordering spec fails if the sort is naively switched to String#sort.

  • bundle exec rspec spec/unit — 5 examples, 0 failures
  • bundle exec cookstyle --chefstyle -c .rubocop.yml — 16 files, no offenses

Results

TmpZip.new over 5,003 files, median of 7 after 3 warmup iterations, repeated 3 times:

run    main       branch
 1     931.0 ms   603.7 ms
 2     865.5 ms   601.3 ms
 3     816.3 ms   606.6 ms

median ~865 ms -> ~604 ms   (~30% faster)

The branch timings are notably steadier than main's, which is consistent with dropping ~5,000 short-lived Pathname/String allocations per archive.

Zipping a 5,003-file tree spent 245.6 ms of an 831 ms TmpZip.new inside
Pathname#relative_path_from, which splits both paths into components and walks
them. Every entry is by construction a descendant of the glob root, so the
relative path is just the entry with the base prefix removed -- 1.7 ms for the
same 5,003 entries.

#entries also did more work than it needed to:

  * it globbed the tree twice into Pathname objects, including the directories
    it was about to discard;
  * push(*array) splats the second glob across the stack;
  * Array#sort over Pathnames re-runs Pathname#<=>, which translates "/" in
    both operands on every comparison. Building that key once per entry
    (sort_by) gives the identical ordering for 1/3 the cost.

Entry order decides the archive bytes and therefore its SHA1, which is what the
dirty check compares, so the ordering is preserved exactly rather than switched
to a plain String sort -- those differ ("a/b" sorts before "a-b" under
Pathname, after it under String). Added specs cover that ordering and the
existing "dotfiles yes, dot-directories no" glob behaviour.

Verified byte-identical: same archive SHA1 and same entry ordering across a
5,003-file cookbook tree, a tree with unicode/spaced/25-level-deep paths, and a
tree with hidden directories.

    TmpZip.new over 5,003 files, median of 7 (Ruby 4.0.6, macOS arm64):
      main    865.5 ms
      branch  603.7 ms      ~30% faster

Signed-off-by: Tim Smith <tsmith84@proton.me>
@tas50

tas50 commented Aug 28, 2026

Copy link
Copy Markdown
Author

Two things a reviewer will reasonably want to know, since this touches path handling on a Windows-targeting gem:

Separators. Prefix-stripping assumes entries share a literal prefix with dir. In the real upload path they always do: make_files_hash passes File.expand_path(local) (file_transporter.rb:331) into add_directory_hash!TmpZip.new, and File.expand_path normalises to forward slashes on Windows too. clean_dirname then round-trips that through Pathname.glob, and Dir.glob returns results under the same literal prefix it was given. So dir and its entries are consistently forward-slash on every platform.

The one shape that would break prefix-stripping is a dir containing literal backslashes, since relative_path_from splits on both separators on Windows while delete_prefix does not. That path is already non-functional on main though — Dir.glob treats \ as an escape character in patterns, so Pathname.glob(dir.join("**/*")) for a backslashed dir returns nothing today, before and after this change.

CI. The lint and unit workflows do not appear to have been triggered on this fork PR — I think they need maintainer approval. So the Windows / Ruby 3.1 matrix has not actually run against this. Locally I have only Ruby 4.0.6 on macOS, where rspec spec/unit is 5 examples / 0 failures and cookstyle is clean. Every method introduced (delete_prefix, sort_by!, Array#concat) is Ruby 2.5+, and all files parse under Ruby 2.6. Worth approving the workflow run before merging rather than taking my word for the matrix.

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.

1 participant