feat(cargo-cache): hardlink-clone a per-ref Cargo cache from a published snapshot
Replaces the phase-0 resolution probe with the real actions, merging the two independent per-branch Cargo cache implementations on this forge into the design neither of them had. ## The merge - zemyna seeds a PR branch by `cp -al` hardlink clone (near-free: cost scales with inode count, not bytes) from the base branch's LIVE target dir — a torn read waiting for a second job slot (its own #911). - emowheel seeds from a PUBLISHED IMMUTABLE SNAPSHOT (no race by construction) but with `cp -a`, duplicating ~35 GB per branch. This ships hardlink-clone FROM a published snapshot: zemyna's cost profile, emowheel's soundness, and #911 closed structurally rather than by the runner happening to have one execution slot. ## The bug both implementations have A build inside a `cp -al` clone DOES mutate the directory it was cloned from. Cargo replaces real artifacts, but writes its metadata — and build scripts write their OUT_DIR — with a plain truncating write, straight through the shared inode. Measured set: `.fingerprint/<unit>/dep-<target>` (under CARGO_UNSTABLE_CHECKSUM_FRESHNESS), `build/<pkg>/{output,root-output,out/**}`, `deps/*.d` and `<profile>/*.d`. The checksum-freshness case is a wrong answer, not a slow build: a PR clone rewrites the base's dep-info to describe the PR's sources while the base's cache still holds the artifact built from the base's; once the PR merges, the base's next run finds the checksums match, reports `Fresh`, and links a binary built from the pre-merge code. Reproduced end to end. Fix: hardlink the artifacts (the GB), real-copy the metadata (the MB) — about 3.7% of a 6.9 GB Bevy target dir, against 100% for a full copy. ## Contents - `cargo-cache/action.yml` — consume: resolve keys, seed from the base's snapshot via staging + one atomic rename, strip Cargo lock files, unshare the mutable paths, restore mtimes from git history, lock, prune. - `cargo-cache-publish/action.yml` — publish: record the build watermark, atomically republish the snapshot on a protected branch, release the lock (`mode: release-lock` for the `if: always()` step). - `scripts/` — all logic, so it is testable standalone; the YAML is wiring. - `scripts/*selftest.sh` + `selftest.sh` — five suites, 63 assertions, every fix paired with a control that reproduces the bug. All green locally. Eviction merges emowheel's liveness pass (dead branches pruned unconditionally, not gated on disk pressure) with LRU-under-pressure, but inverts the order within the pressure pass: `target-*` before `snapshot-*`, because a snapshot is hardlinked to everything cloned from it, so evicting one frees almost no real bytes while costing every future PR its warm start. restore-mtimes.sh is ported from emowheel (the watermark variant, which closes the merge hazard zemyna's copy still has) with its provenance de-projectised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sqh2vscfzisk83VuPVQX9L
This commit is contained in:
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for publish-snapshot.sh: the atomic swap, and the two
|
||||
# properties the swap exists to guarantee.
|
||||
#
|
||||
# 1. FIRST PUBLISH — with no prior snapshot, the target dir is published and
|
||||
# is a hardlink clone of it (cheap), with the mutable metadata privately
|
||||
# owned (sound: the publisher's NEXT build must not be able to mutate the
|
||||
# snapshot it just published).
|
||||
# 2. REPUBLISH REPLACES — a second publish replaces the snapshot's content
|
||||
# rather than merging into it, and leaves no scratch directories behind.
|
||||
# 3. A LIVE CONSUMER SURVIVES A REPUBLISH — a branch that already cloned the
|
||||
# previous generation keeps reading its own consistent copy. Removing the
|
||||
# old snapshot unlinks directory entries; the inodes stay alive through
|
||||
# the consumer's own links. This is why a republish can never pull data
|
||||
# out from under a running job.
|
||||
# 4. NO TARGET DIR — publishing when there is nothing to publish is a no-op,
|
||||
# not a failure.
|
||||
# 5. LOCKS AND MARKERS DO NOT RIDE ALONG — the publishing job's own cache
|
||||
# lock is still held while this runs, and must not be baked into the
|
||||
# snapshot: a lock timestamped at this run's start would look fresh to
|
||||
# the prune pass on every branch later seeded from it.
|
||||
set -euo pipefail
|
||||
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
. "$script_dir/cache-lib.sh"
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
root="$scratch/cache"; mkdir -p "$root"
|
||||
pass_count=0
|
||||
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
|
||||
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
|
||||
|
||||
# Cargo and rustc REPLACE an artifact (write elsewhere, rename over the path)
|
||||
# rather than truncating it in place, which is exactly why a snapshot may
|
||||
# share artifact inodes with the live target dir it was cloned from. The
|
||||
# fixtures here have to model that faithfully — a plain `>` redirect truncates
|
||||
# in place and would write straight through the shared inode into the
|
||||
# snapshot and every consumer, which is a property of the test fixture, not of
|
||||
# a real build. hardlink-clone-selftest.sh is what verifies the real thing
|
||||
# against a real compiler.
|
||||
replace_file() {
|
||||
printf '%s\n' "$2" > "$1.new"
|
||||
mv -f "$1.new" "$1"
|
||||
}
|
||||
|
||||
make_tree() {
|
||||
local d="$1" marker="$2"
|
||||
mkdir -p "$d/debug/deps" "$d/debug/.fingerprint/x"
|
||||
echo "$marker" > "$d/debug/deps/libx.rlib"
|
||||
echo "$marker" > "$d/debug/.fingerprint/x/dep-lib-x"
|
||||
: > "$d/debug/.cargo-lock"
|
||||
}
|
||||
|
||||
KEY=$(cache_key dev)
|
||||
TGT="$root/target-$KEY"
|
||||
SNAP="$root/snapshot-$KEY"
|
||||
publish() { bash "$script_dir/publish-snapshot.sh" "$KEY" "$root" "$1" > "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "publish-snapshot.sh exited non-zero"; }; }
|
||||
|
||||
echo "=== 4: nothing to publish is a no-op ==="
|
||||
publish job1
|
||||
[ -d "$SNAP" ] && fail "published a snapshot with no target dir present"
|
||||
grep -q 'nothing to snapshot' "$scratch/log" || fail "expected a 'nothing to snapshot' line"
|
||||
ok "no target dir: no-op, reported plainly"
|
||||
|
||||
echo
|
||||
echo "=== 1: first publish ==="
|
||||
make_tree "$TGT" gen1
|
||||
date +%s > "$TGT/.ci-lock-ci-42"
|
||||
touch "$TGT/.cache-last-used"
|
||||
publish job1
|
||||
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen1" ] || fail "snapshot content wrong"
|
||||
ok "snapshot published"
|
||||
[ "$(stat -c '%i' "$SNAP/debug/deps/libx.rlib")" = "$(stat -c '%i' "$TGT/debug/deps/libx.rlib")" ] \
|
||||
|| fail "snapshot artifact was copied, not hardlinked"
|
||||
ok "snapshot shares artifact inodes with the target dir (cheap)"
|
||||
[ "$(stat -c '%i' "$SNAP/debug/.fingerprint/x/dep-lib-x")" != "$(stat -c '%i' "$TGT/debug/.fingerprint/x/dep-lib-x")" ] \
|
||||
|| fail "snapshot fingerprint still aliases the live target dir"
|
||||
ok "snapshot owns its mutable metadata (publisher's next build cannot corrupt it)"
|
||||
|
||||
echo
|
||||
echo "=== 5: locks and LRU markers do not ride along ==="
|
||||
[ -e "$SNAP/.ci-lock-ci-42" ] && fail "the publishing job's lock was baked into the snapshot"
|
||||
ok "cache lock not published"
|
||||
[ -e "$SNAP/.cache-last-used" ] && fail "the LRU marker was baked into the snapshot"
|
||||
ok "LRU marker not published"
|
||||
[ -e "$SNAP/debug/.cargo-lock" ] && fail "a Cargo lock file was published"
|
||||
ok "Cargo lock file not published"
|
||||
|
||||
echo
|
||||
echo "=== 3: a consumer that cloned generation 1 ==="
|
||||
CONSUMER="$root/target-$(cache_key feat/consumer)"
|
||||
hardlink_clone_into "$SNAP" "$CONSUMER" consumer-tag || fail "consumer clone failed"
|
||||
ok "consumer cloned generation 1"
|
||||
|
||||
echo
|
||||
echo "=== 2: republish replaces, leaves no scratch behind ==="
|
||||
replace_file "$TGT/debug/deps/libx.rlib" gen2
|
||||
# The fingerprint IS written in place by Cargo — and the snapshot owns its own
|
||||
# copy precisely so that write cannot reach it. Truncating in place here is
|
||||
# the faithful model.
|
||||
echo gen2 > "$TGT/debug/.fingerprint/x/dep-lib-x"
|
||||
publish job1
|
||||
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen2" ] || fail "republish did not replace the snapshot"
|
||||
ok "republished snapshot carries generation 2"
|
||||
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.publish-*' \) -print)
|
||||
[ -z "$leftovers" ] || fail "scratch directories left behind: $leftovers"
|
||||
ok "no scratch directories left behind"
|
||||
[ "$(cat "$CONSUMER/debug/deps/libx.rlib")" = "gen1" ] \
|
||||
|| fail "the consumer's clone changed under it when the snapshot was replaced"
|
||||
ok "the live consumer still reads its own consistent generation-1 copy"
|
||||
|
||||
echo
|
||||
echo "publish-snapshot-selftest: ${pass_count} assertions passed"
|
||||
Reference in New Issue
Block a user