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
175 lines
8.0 KiB
Bash
Executable File
175 lines
8.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression test for prune-cache.sh. Builds a real scratch git repo standing
|
|
# in for `origin` and a real scratch directory standing in for the cache root,
|
|
# then runs the ACTUAL script against both — not a simulation of its logic.
|
|
#
|
|
# What each scenario demonstrates, and why the controls matter as much as the
|
|
# fixes (a scenario that always passes proves nothing):
|
|
#
|
|
# 1. DEAD BRANCH PRUNED, not gated on disk pressure — a cache whose branch
|
|
# no longer exists on origin is removed even with plenty of free space.
|
|
# Waiting for pressure to notice means paying for dead caches until then.
|
|
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
|
|
# not age, is what decides pass 1.
|
|
# 3. PROTECTED REFS NEVER EVICTED under forced disk pressure, even when
|
|
# their caches are the oldest on disk and would rank first for LRU.
|
|
# 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure.
|
|
# 5. STALE LOCK NOT HONOURED FOREVER — the same cache with a lock older than
|
|
# STALE_LOCK_SECONDS is evicted, so a crashed job cannot pin a directory
|
|
# permanently.
|
|
# 6. LIVENESS UNAVAILABLE FAILS SAFE — origin unreachable: a genuinely dead
|
|
# cache is NOT pruned, the log says so plainly, and the pressure fallback
|
|
# still works independently. "Unavailable" degrades to pressure-only, not
|
|
# to no eviction at all.
|
|
# 7. TARGET DIRS EVICTED BEFORE SNAPSHOTS — the ordering that differs from
|
|
# the obvious one. A snapshot is hardlinked to the caches cloned from it,
|
|
# so evicting it frees almost nothing while costing every future PR its
|
|
# warm start.
|
|
# 8. SELF-CLEAR REPORTS LOUDLY to the job summary, not just a log warning.
|
|
# 9. OWN CACHE NEVER EVICTED by a sibling pass.
|
|
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
|
|
# project's volume) is never touched.
|
|
set -euo pipefail
|
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
. "$script_dir/cache-lib.sh"
|
|
prune="$script_dir/prune-cache.sh"
|
|
|
|
scratch=$(mktemp -d)
|
|
trap 'rm -rf "$scratch"' EXIT
|
|
pass_count=0
|
|
fail() { echo "ASSERTION FAILED: $*" >&2; [ -n "${1:-}" ] && [ -f "$scratch/log" ] && { echo "--- log ---" >&2; cat "$scratch/log" >&2; }; exit 1; }
|
|
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
|
|
assert_gone() { [ -e "$1" ] && fail "expected gone: $1 ($2)"; ok "$2"; }
|
|
assert_kept() { [ -e "$1" ] || fail "expected kept: $1 ($2)"; ok "$2"; }
|
|
assert_log() { grep -q -- "$1" "$scratch/log" || fail "expected in log: $1 ($2)"; ok "$2"; }
|
|
|
|
echo "=== building a scratch origin with real branches ==="
|
|
origin="$scratch/origin.git"; git init -q --bare "$origin"
|
|
work="$scratch/work"; git init -q "$work"
|
|
(
|
|
cd "$work"
|
|
git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
|
|
git branch -M main
|
|
git checkout -q -b dev; git -c user.email=t@t -c user.name=t commit -q --allow-empty -m dev
|
|
git checkout -q -b feat/live; git -c user.email=t@t -c user.name=t commit -q --allow-empty -m live
|
|
git remote add origin "$origin"
|
|
git push -q origin main dev feat/live
|
|
)
|
|
cd "$work"
|
|
|
|
MAIN=$(cache_key main); DEV=$(cache_key dev); LIVE=$(cache_key feat/live)
|
|
DEAD=$(cache_key feat/dead); OWN=$(cache_key feat/own)
|
|
|
|
root="$scratch/cache"
|
|
mk() { mkdir -p "$root/$1"; head -c 4096 /dev/zero > "$root/$1/blob"; touch -d "$2" "$root/$1/.cache-last-used"; }
|
|
reset_cache() {
|
|
rm -rf "$root"; mkdir -p "$root"
|
|
mk "target-$MAIN" '2020-01-01'
|
|
mk "snapshot-$MAIN" '2020-01-01'
|
|
mk "target-$DEV" '2020-01-01'
|
|
mk "snapshot-$DEV" '2020-01-01'
|
|
mk "target-$LIVE" '2020-01-02' # older than the dead one, deliberately
|
|
mk "target-$DEAD" '2030-01-01' # newest on disk, but its branch is gone
|
|
mk "snapshot-$DEAD" '2030-01-01'
|
|
mk "target-$OWN" '2025-01-01'
|
|
}
|
|
run_prune() {
|
|
local free="${1:-}"
|
|
CACHE_DF_OVERRIDE="$free" GITHUB_STEP_SUMMARY="$scratch/summary" \
|
|
bash "$prune" "$root" "$root/target-$OWN" "dev main" 10 > "$scratch/log" 2>&1 \
|
|
|| { cat "$scratch/log"; fail "prune-cache.sh exited non-zero"; }
|
|
}
|
|
|
|
echo
|
|
echo "=== 1/2: dead pruned unconditionally; older-but-live survives ==="
|
|
reset_cache
|
|
run_prune "1000000 900000" # 90% free: no pressure at all
|
|
assert_gone "$root/target-$DEAD" "dead branch's target dir pruned with no disk pressure"
|
|
assert_gone "$root/snapshot-$DEAD" "dead branch's snapshot pruned too"
|
|
assert_kept "$root/target-$LIVE" "live branch survives despite an older marker than the dead one"
|
|
assert_log "no matching branch on origin" "eviction reason reported"
|
|
|
|
echo
|
|
echo "=== 3: protected refs never evicted under forced pressure ==="
|
|
reset_cache
|
|
run_prune "1000000 1000" # 0.1% free
|
|
assert_kept "$root/target-$DEV" "dev's target dir survives disk pressure"
|
|
assert_kept "$root/snapshot-$DEV" "dev's snapshot survives disk pressure"
|
|
assert_kept "$root/target-$MAIN" "main's target dir survives disk pressure"
|
|
assert_kept "$root/snapshot-$MAIN" "main's snapshot survives disk pressure"
|
|
|
|
echo
|
|
echo "=== 9: own cache never evicted by a sibling pass ==="
|
|
assert_kept "$root/target-$OWN" "this run's own cache survives"
|
|
|
|
echo
|
|
echo "=== 7: target dirs evicted before snapshots ==="
|
|
reset_cache
|
|
# Only the live branch is evictable; give it both a target dir and a snapshot
|
|
# with identical markers so ordering, not age, decides.
|
|
mk "snapshot-$LIVE" '2020-01-02'
|
|
# The df override is a fixed reading, so the pressure loop drains everything
|
|
# evictable — which is what makes the ORDER the observable property here, not
|
|
# what survives. Assert the eviction order directly from the log.
|
|
run_prune "1000000 1000"
|
|
order=$(grep -o "evicting \(target\|snapshot\)-$LIVE" "$scratch/log" | sed "s/evicting //")
|
|
[ "$(printf '%s\n' "$order" | head -1)" = "target-$LIVE" ] \
|
|
|| fail "expected target-$LIVE to be evicted before snapshot-$LIVE, got: $order"
|
|
ok "target dirs are evicted before snapshots"
|
|
|
|
echo
|
|
echo "=== 4: a fresh lock protects a dead, old, under-pressure cache ==="
|
|
reset_cache
|
|
date +%s > "$root/target-$DEAD/.ci-lock-ci-1"
|
|
run_prune "1000000 1000"
|
|
assert_kept "$root/target-$DEAD" "locked cache survives both passes"
|
|
assert_log "held open by" "lock reported in the log"
|
|
|
|
echo
|
|
echo "=== 5: a stale lock is not honoured forever ==="
|
|
reset_cache
|
|
echo 0 > "$root/target-$DEAD/.ci-lock-ci-1"
|
|
touch -d '2000-01-01' "$root/target-$DEAD/.ci-lock-ci-1"
|
|
run_prune "1000000 900000"
|
|
assert_gone "$root/target-$DEAD" "cache with an abandoned lock is evicted"
|
|
assert_log "treating as abandoned" "abandoned lock reported in the log"
|
|
|
|
echo
|
|
echo "=== 6: liveness unavailable fails safe, pressure fallback still works ==="
|
|
reset_cache
|
|
(
|
|
cd "$work" && git remote set-url origin "$scratch/nonexistent.git"
|
|
)
|
|
run_prune "1000000 900000" # no pressure
|
|
assert_kept "$root/target-$DEAD" "dead cache NOT pruned when liveness is unavailable"
|
|
assert_log "treating as UNAVAILABLE" "unavailability reported plainly, not folded into 'no branches'"
|
|
run_prune "1000000 1000" # now with pressure
|
|
if [ -e "$root/target-$DEAD" ] && [ -e "$root/target-$LIVE" ]; then
|
|
fail "pressure fallback did nothing when liveness was unavailable"
|
|
fi
|
|
ok "pressure fallback still evicts when liveness is unavailable"
|
|
(cd "$work" && git remote set-url origin "$origin")
|
|
|
|
echo
|
|
echo "=== 8: self-clear reports to the job summary ==="
|
|
reset_cache
|
|
rm -rf "$root/target-$DEAD" "$root/snapshot-$DEAD" "$root/target-$LIVE"
|
|
: > "$scratch/summary"
|
|
run_prune "1000000 1000" # nothing evictable left but the run's own cache
|
|
assert_log "clearing own" "self-clear reported in the log"
|
|
grep -q 'self-clear' "$scratch/summary" || fail "self-clear missing from the job summary"
|
|
ok "self-clear reported to the job summary, not only the log"
|
|
[ -d "$root/target-$OWN" ] || fail "self-clear left the own directory missing"
|
|
[ -z "$(ls -A "$root/target-$OWN")" ] || fail "self-clear did not actually empty the directory"
|
|
ok "own cache wiped and recreated empty"
|
|
|
|
echo
|
|
echo "=== 10: scoped to the cache root ==="
|
|
reset_cache
|
|
decoy="$scratch/other-project"; mkdir -p "$decoy/target-$DEAD"; touch "$decoy/target-$DEAD/blob"
|
|
run_prune "1000000 1000"
|
|
assert_kept "$decoy/target-$DEAD" "a cache outside the cache root is never touched"
|
|
|
|
echo
|
|
echo "prune-cache-selftest: ${pass_count} assertions passed"
|