Skip to content

CI only: the saucal release branch (do not merge) - #6

Open
msaggiorato wants to merge 29 commits into
saucal-ci-basefrom
release/saucal
Open

msaggiorato wants to merge 29 commits into
saucal-ci-basefrom
release/saucal

Conversation

@msaggiorato

Copy link
Copy Markdown
Member

Exists only to run the full test matrix against release/saucal, the branch the maintenance action installs on WordPress hosts. The workflow runs on pull requests, so without an open PR this branch gets no CI at all — the old PR that did this was closed, and SAUCAL.md still pointed at it.

Do not merge. master tracks upstream and is never committed to; release/saucal is an upstream release tag plus our patches.

🤖 Generated with Claude Code

https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z

msaggiorato and others added 18 commits September 17, 2026 12:01
A backup the kernel kills leaves nothing behind to explain itself: SIGKILL cannot
be caught, so there is no error to report and no summary to print. The only
account of such a run is one written while it happens.

Setting RESTIC_DIAG_LOG makes restic append a line every few seconds with its own
heap figures and, where a cgroup says so, the usage and limit the kernel actually
decides on. Each line is flushed as it is written, so the last one says what
restic was using when it died. Panics are recorded the same way before they are
re-raised.

Nothing is opened, sampled or written unless that variable is set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
(cherry picked from commit 032e3d9)
The restorer reserves space for each file before filling it, so that writing its
blobs out of order does not fragment it. Some sandboxed filesystems, seen on
managed WordPress hosting, accept that syscall and never answer it, and the
consequences run deeper than a slow restore: the kernel holds the inode lock
while it waits, so the file can no longer be written, shortened or unlinked —
unlinking it blocks in turn, and takes its directory with it — and the waiting
thread cannot be interrupted, not even by SIGKILL, so the process it belongs to
can never exit. A restore stopped at the first file that needed data, left it
empty, and printed nothing.

Making the call is therefore an irreversible commitment, and no deadline around
it can take one back: by the time the deadline passes, that file and that
process are already lost. Ask the filesystem beforehand instead, with a
throwaway file whose name is unlinked before the call, from a process that
exists only to ask — so a filesystem that never answers strands that process
instead of the restore. The answer is kept per filesystem. Whether the call
succeeded is not the question, only whether it came back at all: one that fails
does so promptly, and the caller tolerates a failure anyway.

This is the v0.18.0 branch a fleet build is cut from; the same change sits in
internal/fileio on the master-based branch.

Ported from upstream/preallocation-guard commit 58b941c, rather than
cherry-picked from it: upstream moved these files to internal/fileio after 0.19.1
was branched, so the same change has to sit in internal/fs here. The two differ
only in the package name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The hosts install restic from SAUCAL_RESTIC_RELEASE_BASE, which until now meant
building and uploading the assets by hand. Add the script that builds them, the
workflow that publishes them when a v*-saucal.* tag is pushed, and the notes for
whoever changes this branch next.

The script refuses a dirty working tree: Go stamps the build with the tree's
state, so the same commit built with stray files around yields a different binary
than one built clean, and a release has to be reproducible from its tag.

Also fix this branch's own CI the way master's was fixed, so changes here can be
tested: the open source MinIO server is archived and dl.minio.io answers every
download with "410 Gone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
Upstream turned the version into a constant, so build.go's -X injection no longer
reaches it: on 0.19.x a stock binary and ours both report "restic 0.19.1", and an
operator reading a job log cannot tell which one is running. Add the suffix that
says. It also lands in the ProgramVersion recorded in each snapshot, so a
repository shows which build wrote what.

The VERSION file is left alone: the release asset names come from there, and the
maintenance action derives the same names from its own RESTIC_VERSION.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
restic no longer hangs forever on filesystems that accept the preallocation
syscall and never answer it, which is what Pressable does. Reproduction and the
evidence for the approach are on repro/preallocation-hang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
RESTIC_DIAG_LOG records what a run costs in memory as it goes, so a backup the
kernel kills still says what it was using. Added for elka, which cannot be backed
up on Pressable.

The probe check in main stays ahead of it: a probe process exists only to ask the
filesystem one question, and has no business writing diagnostics.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z

# Conflicts:
#	cmd/restic/main.go
A change meant for upstream cannot be merged from its master-based branch into
release/saucal: that would bring all of master with it, and release/saucal sits on
a release tag. Cherry-pick it instead; only upstream's release tags get merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
An integration branch cut from the release tag, merged with --no-ff, rather than
the cherry-pick the previous wording described. The merge commit is where two
changes touching the same place get reconciled in the open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
Deleting and republishing a release takes its download URLs offline while the
rebuild runs, and anything fetching in that window gets a 404 — which happened
today to someone who had been given the base URL. Fix forward with a new tag
instead; rewrite history only before the first tag is pushed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
restic reports that an item changed, never what about it changed. rsync's
--itemize-changes answers that in eleven columns, and the restorer already holds
most of the comparisons it would take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The throwaway branch that existed for this is gone; the technique should not have
to be rediscovered from upstream's red matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
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
From saucal/wide-directory-memory, cut from v0.19.1 and cherry-picked from
upstream/bound-wide-directory-memory so the fleet build and the upstream
diff stay the same change.

A directory with very many entries cost about a kilobyte of memory per
entry, because saveDir held every finished entry until the whole directory
had been walked. That is what makes elka -- one uploads directory with
hundreds of thousands of files -- unbackupable under Pressable's memory
watchdog, which kills restic with SIGTERM at roughly 680 MB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
SAUCAL.md still claimed this branch carried one change, from before the
diagnostics were merged. List all three, and record the measured memory
per directory entry along with the limit that no tuning gets past, so the
next person does not have to rediscover where the wall is.

Also ignore /.worktrees/, which is where this work was done.

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
Two more commits from saucal/wide-directory-memory, cherry-picked from
upstream/bound-wide-directory-memory: verification no longer holds a
second copy of a large blob, and the tree of a directory of 50000 entries
or more is written to a temp file and streamed into the repository instead
of being held in memory.

Together with the earlier bounding of pending nodes, one directory of a
million entries now peaks at 208 MB rather than 1248 MB and succeeds under
a 250 MiB cap, which is what elka's uploads/2021/06 needs to be backed up
at all on Pressable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
…eeds

The numbers in this file were from before the tree was streamed. Replace
them, say which of the three changes saved what, and note the new
operational dependency: the temp file holding a wide directory's tree is a
few hundred megabytes, so a host whose /tmp is small or memory-backed
needs TMPDIR pointed at real disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The file named pull request #1, which has been closed for a while, so the
branch the fleet installs was getting no test matrix at all — pushes to
anything but master are ignored by the workflow. Name the replacement and
say what goes wrong if it is closed too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato
msaggiorato changed the base branch from master to saucal-ci-base September 17, 2026 22:32
@msaggiorato msaggiorato reopened this Sep 17, 2026
msaggiorato and others added 3 commits September 17, 2026 19:57
…eat it

A pull request that conflicts with its base gets no pull_request workflow
runs at all, because GitHub cannot build a merge commit for it. Based on
master, the CI pull request for this branch therefore showed only
size-label and looked healthy while running nothing -- which is how three
patches landed here without the test matrix ever having run. Park a base at
v0.19.1 and target that instead.

Also record that the release workflow's Test step covers three packages
that have nothing to do with the archiver or the repository, so it never
substituted for the matrix.

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
From saucal/wide-directory-memory. Four paths leaked the descriptor of the
temp file holding a wide directory's tree -- both error returns in
treeSaver.save, a cancellation that dropped the job, and any error out of
saveDir once the builder existed. Also adds the corruption tests the
streaming verification was missing, since it is the last check before a
blob is uploaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato msaggiorato reopened this Sep 17, 2026
msaggiorato and others added 2 commits September 17, 2026 22:58
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
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
Two fixes from saucal/wide-directory-memory, each with a test that fails
without it:

- A cancelled backup closed the temp file holding a wide directory's tree
  while the repository was still reading it, inventing a "file already
  closed" error on an already failing path.
- The streaming path compressed in order to hash, so the tree of an
  unchanged wide directory was compressed on every backup and then thrown
  away when the index reported the blob known. Hashing first restores the
  order saveBlob uses; on a realistic 47 MB tree, hashing costs 43 ms
  against 275 ms to hash and compress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato msaggiorato reopened this Sep 18, 2026
msaggiorato and others added 2 commits September 17, 2026 23:13
golangci-lint's revive rule wants context.Context as a function's first
parameter, and the benchmark helper added with the previous commit had it
third. Caught by the lint job rather than locally; golangci-lint v2.12.2
is now installed here to check before pushing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
@msaggiorato msaggiorato reopened this Sep 18, 2026
msaggiorato and others added 2 commits September 18, 2026 13:34
… leaves

The temp file holding a wide directory's tree was the one thing about this
build nobody could check without a shell on the host. elka's backup on
2026-09-18 settles it: the action unpacks into /tmp/issh.XXX and TMPDIR is
the same filesystem, so there is room and nothing to configure.

Also note what testing a memory limit runs into: a killed backup cannot
release its own lock, and a lock from another host counts as stale only
after 30 minutes, so a retry inside that window is refused with
"restic check failed". That cost a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
The memory-ceiling idea was written before the diagnostics had said where
the memory went. They have: directory width, not index size, and that is
fixed and measured on the real host. Rewrite it around what is actually
left, which is setting GOMEMLIMIT.

Add the two things this work surfaced and did not do. Reading a wide
directory's tree still materialises the whole blob, so elka's backup now
fits in 325 MB while its restore would not -- that asymmetry is the larger
half of the problem now. And the entry names are the one term still scaling
with width, which rsync's slab-and-offsets approach would cut by about a
fifth, worth doing only if a site turns up that needs it.

Also record the state of the upstream series: six commits, placeholder
changelog numbers, and the argument for splitting it across two pull
requests rather than one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017d5y4KNmm2N5ySPPH1H49Z
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