Follow-up tof57e2a6, addressing the reviewer's sharpest question: is the race "closed by construction", or merely detected and retried? The honest answer is "both, on different paths", and the README said only the first half. * README now states three claims separately instead of collapsing them: a publisher rotating a snapshot cannot tear a clone of it (by construction — the marker ordering prevents the unlink, and the consumer's verification is a redundant second check on that path); every OTHER way the source can change mid-clone is detected, not prevented (the eviction pass's reader check is check-then-delete, and a seed-fallback-dir has no interlock at all — there, verification plus a bounded retry and a loud failure is the whole guard); and disk reclamation is bounded rather than immediate. Overclaiming this property once was the finding; overclaiming it twice would be worse. * publish-snapshot-selftest.sh now covers the PUBLISHER's half of the race, where a reader of the swap belongs. Scenario 3 only ever covered a consumer that had already FINISHED cloning — safe for free, since its own hardlinks keep the inodes alive. New scenario 6 covers a reader still in flight past the grace period (the generation is left on disk, the deferral is warned about, and an earlier consumer is still unaffected); scenario 7 covers the sweep, so "we defer instead of forcing" cannot quietly become a disk leak. Red-proven against 248af306's scripts: ASSERTION FAILED: the previous generation was unlinked while a reader still held it The consumer's half stays in seed-target-dir-selftest.sh scenario 8, which still red-proves at 16693 of 48805 entries against the same scripts. * seed-target-dir-selftest.sh now asserts what happens when the retries are EXHAUSTED, not just what hardlink_clone_into returns: an unreadable source makes the seed script exit non-zero, name the reason, leave no target dir, and — the one that matters — not fall through to its cold-start branch. A corrupt-cache bug degrading into an invisible 4x-slower CI job is the failure mode worth pinning down. Skipped when running as root, where mode bits deny nothing. * usage_kb: a directory we cannot read measured as the empty string, which was then spliced into usage_gb's awk program and made it a syntax error at the exact moment something was already going wrong. Now measures 0. Verification: `bash scripts/selftest.sh` — 5 suites, exit 0, 82 assertions (was 75 afterf57e2a6, 63 before). shellcheck over scripts/: no new findings. Measured the cost the reviewer asked about, on ext4, warm cache, 78,554 entries: `cp -al` 3126 ms against 44 ms for one `find | wc -l`. Two counts per attempt is ~2.8% on top of the clone. Not measured on the CI runner's volume. Refs: daniel/gitdan#11, zemyna#911 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sqh2vscfzisk83VuPVQX9L
165 lines
8.3 KiB
Bash
Executable File
165 lines
8.3 KiB
Bash
Executable File
#!/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.
|
|
# 6. DEFERRED RECLAMATION — the publisher's half of the seed-vs-rotation
|
|
# race. Scenario 3 above covers a consumer that has ALREADY FINISHED
|
|
# cloning; that one is safe for free, because its own hardlinks keep the
|
|
# inodes alive. A consumer still WALKING the old generation is the case
|
|
# that actually tears, and unlinking underneath it is what produced a
|
|
# silently truncated clone. So when a reader is still in flight past the
|
|
# grace period, the swap leaves the rotated-away generation on disk
|
|
# instead of unlinking it.
|
|
# 7. AND THE SWEEP — a deferred generation is not leaked: the next publish
|
|
# of that snapshot reclaims it once no reader holds it. Without this the
|
|
# "we defer instead of forcing" answer would just be a disk leak with
|
|
# better manners.
|
|
#
|
|
# The consumer's half of the same race — a seed catching a rotation mid-clone
|
|
# — is in seed-target-dir-selftest.sh scenario 8.
|
|
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 "=== 6: a reader still in flight defers reclamation ==="
|
|
# A synthetic reader marker stands in for a consumer whose clone outlasts the
|
|
# grace period. Racing a real slow consumer would make the suite's runtime the
|
|
# thing under test; the marker IS the entire contract between the two sides,
|
|
# so holding one is being a reader.
|
|
SNAP_NAME=$(basename "$SNAP")
|
|
date +%s > "$root/.reading-${SNAP_NAME}-slowpoke"
|
|
replace_file "$TGT/debug/deps/libx.rlib" gen3
|
|
CACHE_READ_GRACE_SECONDS=1 bash "$script_dir/publish-snapshot.sh" "$KEY" "$root" jobDefer \
|
|
> "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "publish-snapshot.sh exited non-zero"; }
|
|
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen3" ] || fail "the new generation was not published"
|
|
ok "the new generation is published even while a reader holds the old one"
|
|
deferred=$(find "$root" -maxdepth 1 -name ".publish-old-${KEY}-*" -print -quit)
|
|
[ -n "$deferred" ] || fail "the previous generation was unlinked while a reader still held it"
|
|
ok "the rotated-away generation is left on disk rather than unlinked under a reader"
|
|
grep -q 'deferring reclamation' "$scratch/log" || fail "the deferral was not reported"
|
|
ok "the deferral is surfaced as a warning, not silent"
|
|
[ "$(cat "$CONSUMER/debug/deps/libx.rlib")" = "gen1" ] \
|
|
|| fail "the earlier consumer's clone changed under it"
|
|
ok "the generation-1 consumer is still unaffected"
|
|
|
|
echo
|
|
echo "=== 7: a later publish sweeps the deferred generation ==="
|
|
rm -f "$root/.reading-${SNAP_NAME}-slowpoke"
|
|
replace_file "$TGT/debug/deps/libx.rlib" gen4
|
|
publish jobSweep
|
|
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen4" ] || fail "the fourth generation was not published"
|
|
ok "publishing continues normally after a deferral"
|
|
[ -z "$(find "$root" -maxdepth 1 -name '.publish-old-*' -print -quit)" ] \
|
|
|| fail "the deferred generation was never reclaimed — this is a disk leak"
|
|
ok "the deferred generation is reclaimed once no reader holds it"
|
|
[ -z "$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print -quit)" ] \
|
|
|| fail "scratch left behind: $(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)"
|
|
ok "no staging or reader-marker scratch left behind"
|
|
|
|
echo
|
|
echo "publish-snapshot-selftest: ${pass_count} assertions passed"
|