Skip to content

CI only: wide-directory memory work (do not merge) - #5

Open
msaggiorato wants to merge 8 commits into
masterfrom
ci-check/wide-directory-memory
Open

msaggiorato wants to merge 8 commits into
masterfrom
ci-check/wide-directory-memory

Conversation

@msaggiorato

Copy link
Copy Markdown
Member

Exists only to run the full test matrix against upstream/bound-wide-directory-memory, which cannot get CI on its own (the workflow runs on pull requests, and pushes to non-master branches are ignored).

Carries the minio-from-source fix on top so the Get programs step does not die on dl.minio.io returning 410 Gone. That extra commit is why this is a throwaway branch rather than the real one.

Do not merge. The reviewable branch is upstream/bound-wide-directory-memory.

🤖 Generated with Claude Code

https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z

msaggiorato and others added 3 commits September 17, 2026 18:31
saveDir submitted every entry of a directory and only folded the results
into the directory's tree once the whole directory had been walked, so a
finished entry was held -- its node, the two paths naming it and its
channel, about a kilobyte in all -- until then. A directory of a million
entries therefore needed roughly a gigabyte on top of the tree itself,
which is enough for a host with a memory cap to kill the backup.

The entries of a directory are folded into its tree in the order they
appear, so a finished entry need not be kept. Fold them in as the walk
goes, holding only enough to let the entries ahead of a slow one finish
out of order. The file saver's queue is unbuffered, so the walk was
already paced by the file readers and consuming results earlier costs no
concurrency. Directories no wider than the limit behave exactly as
before.

Two smaller costs went with it: each entry's name is released once the
paths have been built from it, and the buffer holding the tree is sized
from the directory's entry count rather than being grown and copied
repeatedly, which held two copies of a large tree at once.

Backing up one directory of 600000 entries under a 600 MiB cap failed
before and now succeeds; the same backup uncapped uses 39% less memory
at a million entries. The tree written is byte for byte unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
verifyCiphertext decrypted and decompressed the blob it had just sealed
and rehashed it, holding the whole plaintext a second time to do so. For
the tree of a very wide directory -- several hundred megabytes -- that
doubled the cost of saving it at exactly the moment the first copy was
still live.

Hash the plaintext as it decompresses instead, for blobs large enough that
setting up a streaming decoder is worth it. What gets verified is
unchanged; only the copy is gone.

Measured on a backup of one directory of a million entries, peak RSS falls
from 773 MB to 603 MB. Disabling verification entirely reaches 592 MB, so
almost all of the difference was the copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
A directory's tree is one blob listing every entry, a few hundred bytes
each, and it was built in memory: held while the directory was walked,
then compressed and encrypted from that same copy. For a directory of a
million entries that is several hundred megabytes, and it was by then the
largest single cost of the backup -- enough that a host which caps process
memory kills the backup outright.

Write the tree of a wide directory to a temp file as the walk goes, and
hand the repository the file rather than a buffer. saveBlobFromReader
hashes and compresses in one pass as it reads, declaring the plaintext
size in the zstd frame header exactly as EncodeAll does, so whoever reads
the blob back can still size its buffer in one go. The plaintext never
exists in one piece anywhere.

The temp file comes from fs.TempFile, which returns a file already
unlinked, so closing it is the whole of the cleanup. Creating it is a
nicety rather than a requirement: if it fails, the tree is built in memory
as before. Directories below the threshold are untouched.

This is what rsync does with its file list -- stream the metadata rather
than materialise it -- adapted to a format that needs each directory's
listing addressed as one blob.

One directory of a million entries: peak RSS 208 MB, against 1248 MB
before any of this work and 603 MB after bounding the pending nodes.
1.2M entries succeeds under a 400 MiB cap. The tree written is byte for
byte unchanged, a restore of it matches the source exactly, and
check --read-data passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The temp file holding a wide directory's tree was only closed once the
tree had been handed to the repository, so four paths leaked the
descriptor: either error return in treeSaver.save, a cancellation that
made Save drop the job before a worker took it, and any error out of
saveDir once the builder existed. os.File's finalizer closes an abandoned
descriptor eventually, but an error affecting many wide directories could
exhaust the table before the garbage collector got to them.

Release in a defer as soon as ownership is known, hand ownership over
explicitly where it passes to the tree saver, and make release safe to
call twice and on a builder that never spilled.

Also add the corruption tests the streaming verification was missing. It
is the last check before a blob is uploaded, so a version of it that
always passed would be worse than no check at all: prove it rejects a
wrong id, a bit flipped at three different offsets, a truncated stream and
input that was never compressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato
msaggiorato force-pushed the ci-check/wide-directory-memory branch from 657b106 to 97d813c Compare September 17, 2026 23:28
A cancelled backup returns from treeSaver.save while the repository is
still reading the temp file holding a wide directory's tree, so releasing
that file in a defer there closed it underneath the upload, which then
failed with "file already closed". The failure was confined to a path that
was already failing, and the size check in saveBlobFromReader means no
short blob could result, but the error was one we invented rather than one
reported by anything real.

Hold the file until the repository is finished with it, which the callback
signals, and keep the defer for the paths where it was never handed over.

TestTreeSaverSpilledTreeSurvivesCancel reproduces it: it reaches the
spilling path by asking for a directory as wide as the threshold rather
than by feeding the builder that many nodes, and holds the mock
repository's read until after the cancellation, so the ordering is
deterministic rather than raced. Without this change it fails with exactly
the error above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato
msaggiorato force-pushed the ci-check/wide-directory-memory branch from 65edf3e to 871c545 Compare September 18, 2026 01:59
msaggiorato and others added 3 commits September 17, 2026 23:13
saveBlob hashes a blob, asks the index whether the repository already has
it, and compresses only if it does not. saveBlobFromReader could not follow
that order, because it hashed and compressed in a single pass, so the tree
of an unchanged wide directory was compressed on every backup and then
thrown away when the index said the blob was known.

Read the stream twice instead: once to hash, and again to compress only
when the blob turns out to be new. That restores saveBlob's order, and
costs a second pass over a temp file whose pages the kernel has just
written and still holds.

Measured on a 47 MB tree shaped like a real WordPress uploads directory --
varied names and times, and a content id per node, so it compresses like
the real thing rather than like a repeated string:

    hash only          43 ms
    compress only     230 ms
    hash + compress   275 ms

So recognising a known tree cost six times what it needed to. On the
committed benchmark, whose tree is more repetitive and therefore kinder to
the compressor, saving a known blob goes from 33 ms to 15 ms and saving a
new one from 70 ms to 59 ms. For a directory of a million entries this is
seconds per backup rather than a bounded cost, since the tree grows with
the directory.

The stream therefore has to be seekable, which the interface now says. Its
one caller hands over a temp file. Also stop asking the encoder to close a
stream it has already closed, and size the compressed buffer at a quarter
of the plaintext rather than an eighth, which is nearer what a real tree
achieves and so avoids a resize that would hold two buffers at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The test jobs no longer get past "Get programs": the open source MinIO
server is archived and dl.minio.io answers every download with "410 Gone",
so the wget fails and the whole matrix stops before a single test runs.

Install it with go install from the last release instead, which also drops
the per-OS branch and picks up the runner's own architecture rather than
the amd64 build. The version is pinned next to latest_go because the
repository is archived and its master branch has moved on since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhQ5vniAMEFx1V4PtwEuWD
A same-repo pull request satisfies the condition guarding those tests, so
they run and then fail for want of secrets. This branch exists only to run
the matrix, so drop them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato
msaggiorato force-pushed the ci-check/wide-directory-memory branch from 871c545 to 0680b40 Compare September 18, 2026 02:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant