fix(prune-cache): close the reader-marker check-then-delete window

Both eviction sites checked for a consumer's `.reading-` marker and then,
seconds later — `usage_gb` runs `du -sk` over a multi-GB tree between the
two — unlinked the directory. A consumer that started a clone inside that
gap had its source removed mid-walk, which `cp -al` does not report: a
subtree unlinked before its parent is listed is silently omitted.

Unreachable today, and only by policy: snapshots belong to protected refs
and protected refs never reach the marker check. `cargo-cache` and
`cargo-cache-publish` take that ref list as two independent inputs, so a
workflow listing a publisher in one and not the other arms this with no
code change at all.

Closed structurally, with publish-snapshot.sh's rotation rather than a new
mechanism: the candidate is renamed aside and only then re-examined, so the
scan the unlink rests on happens strictly after the rename. A consumer that
resolved the directory published its marker before that scan and cannot be
missed; one arriving after cannot resolve the path and starts cold, the same
degrade the publisher's swap window already produces. Renaming disturbs no
clone in flight — no entry is unlinked and the inode is unchanged — so a
declined eviction costs a deferred eviction and nothing else. A reprieved
cache is put back under its own name; one whose name a concurrent seed has
retaken is left aside and swept by a later pass once its readers drain,
since nothing else globs a dotted name.

prune-cache-selftest gains three scenarios (21 -> 31 assertions). Scenario
12 is the one that bites: the `du` the pass runs on its candidate publishes
the marker, placing it strictly after the check and strictly before the
unlink. Against check-then-delete, 1-11 pass and 12 fails; breaking only
the second look and leaving the rename fails it too.
This commit is contained in:
2026-08-23 23:09:35 -05:00
parent 3b2dec6a50
commit 88ab063b64
3 changed files with 177 additions and 20 deletions
+19 -8
View File
@@ -165,7 +165,7 @@ mechanisms, both required:
walk each: measured on ext4 with a warm cache over a 78,554-entry tree, 44 ms walk each: measured on ext4 with a warm cache over a 78,554-entry tree, 44 ms
per walk against 3,126 ms for the `cp -al` they guard — about 2.8%. per walk against 3,126 ms for the `cp -al` they guard — about 2.8%.
**What this does and does not guarantee.** Three separate claims, deliberately **What this does and does not guarantee.** Four separate claims, deliberately
not collapsed into one: not collapsed into one:
- **A publisher rotating a snapshot cannot tear a clone of it — by - **A publisher rotating a snapshot cannot tear a clone of it — by
@@ -174,12 +174,20 @@ not collapsed into one:
consumer that resolved the old generation, and on timeout it defers the consumer that resolved the old generation, and on timeout it defers the
unlink rather than forcing it. On this path the consumer's own verification unlink rather than forcing it. On this path the consumer's own verification
is a redundant second check, not the thing holding the guarantee up. is a redundant second check, not the thing holding the guarantee up.
- **Nor can the eviction pass — by the same construction.** A cache chosen for
eviction is renamed aside and only then re-examined for readers, so the scan
the unlink rests on happens strictly after that rename, exactly as the
publisher's does. A consumer that resolved the directory published its
marker before the scan and so cannot be missed; one arriving after the
rename cannot resolve the path at all and starts cold instead. A cache
claimed inside that window is put back under its own name, and one whose
name a concurrent seed has taken in the meantime is left aside and reclaimed
by a later pass once its readers drain. Until this was structural it was
merely policy — snapshots belong to protected refs, protected refs are never
eviction candidates — which is a property held by vigilance rather than by
construction.
- **Every other way the source can change mid-clone is detected, not - **Every other way the source can change mid-clone is detected, not
prevented.** The eviction pass's reader check is check-then-delete, so a prevented.** A `seed-fallback-dir` pointing at a directory something else
consumer publishing its marker inside that gap is narrowed but not excluded
— unreachable today only because snapshots belong to protected refs and
protected refs are never eviction candidates, which is policy rather than
structure. A `seed-fallback-dir` pointing at a directory something else
writes has no interlock at all. There, the per-attempt verification is what writes has no interlock at all. There, the per-attempt verification is what
stands between a torn read and a corrupt cache: the clone is retried stands between a torn read and a corrupt cache: the clone is retried
(`CACHE_CLONE_ATTEMPTS`, default 4) and then **fails the job loudly** (`CACHE_CLONE_ATTEMPTS`, default 4) and then **fails the job loudly**
@@ -190,7 +198,10 @@ not collapsed into one:
outright holds it until its marker passes `reader-stale-seconds`. The outright holds it until its marker passes `reader-stale-seconds`. The
residual is capped at one deferred generation per publisher ref, and its residual is capped at one deferred generation per publisher ref, and its
real cost is close to inode count rather than byte count, since the real cost is close to inode count rather than byte count, since the
artifacts are hardlinked to whatever cloned them. artifacts are hardlinked to whatever cloned them. A declined eviction is
bounded the same way: the cache stays, either under its own name or aside
awaiting the next pass, and is never unlinked under the job that claimed
it.
**Eviction** runs three passes: caches for branches that no longer exist on **Eviction** runs three passes: caches for branches that no longer exist on
origin are removed unconditionally; then, only if free space is under the origin are removed unconditionally; then, only if free space is under the
@@ -308,7 +319,7 @@ bash scripts/selftest.sh --fast # fixture-only suites, no compiler
| `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler. | | `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler. |
| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and a seed racing a publisher's rotation of the source it is reading** — the race that actually truncates a tree | | `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and a seed racing a publisher's rotation of the source it is reading** — the race that actually truncates a tree |
| `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone | | `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone |
| `prune-cache-selftest.sh` | liveness, protection, locking, eviction order, self-clear — against a real scratch `origin` | | `prune-cache-selftest.sh` | liveness, protection, locking, eviction order, self-clear, **and that a cache a job claims *inside* the check-to-unlink window survives it** — against a real scratch `origin` |
| `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. | | `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. |
Every suite runs the actual script, not a reimplementation of its logic, and Every suite runs the actual script, not a reimplementation of its logic, and
+64 -1
View File
@@ -29,6 +29,15 @@
# 9. OWN CACHE NEVER EVICTED by a sibling pass. # 9. OWN CACHE NEVER EVICTED by a sibling pass.
# 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another # 10. SCOPED TO THE CACHE ROOT — a decoy outside it (standing in for another
# project's volume) is never touched. # project's volume) is never touched.
# 11. A LIVE READER MARKER PROTECTS A CACHE the same way a lock file does — a
# directory somebody is hardlink-cloning this instant is not a candidate,
# however dead and however tight the disk.
# 12. AND SO DOES ONE PUBLISHED INSIDE THE CHECK-TO-UNLINK WINDOW, which is
# the property a check-then-delete eviction does NOT have. This is the
# one that fails against the pre-fix script.
# 13. A DEFERRED EVICTION IS RECLAIMED, but not while its reader is live.
# Nothing else globs a dotted name, so an unswept one is disk lost for
# good on the volume whose whole problem is disk.
set -euo pipefail set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh" . "$script_dir/cache-lib.sh"
@@ -112,7 +121,7 @@ mk "snapshot-$LIVE" '2020-01-02'
# evictable — which is what makes the ORDER the observable property here, not # evictable — which is what makes the ORDER the observable property here, not
# what survives. Assert the eviction order directly from the log. # what survives. Assert the eviction order directly from the log.
run_prune "1000000 1000" run_prune "1000000 1000"
order=$(grep -o "evicting \(target\|snapshot\)-$LIVE" "$scratch/log" | sed "s/evicting //") order=$(grep -o "evicted \(target\|snapshot\)-$LIVE" "$scratch/log" | sed "s/evicted //")
[ "$(printf '%s\n' "$order" | head -1)" = "target-$LIVE" ] \ [ "$(printf '%s\n' "$order" | head -1)" = "target-$LIVE" ] \
|| fail "expected target-$LIVE to be evicted before snapshot-$LIVE, got: $order" || fail "expected target-$LIVE to be evicted before snapshot-$LIVE, got: $order"
ok "target dirs are evicted before snapshots" ok "target dirs are evicted before snapshots"
@@ -170,5 +179,59 @@ decoy="$scratch/other-project"; mkdir -p "$decoy/target-$DEAD"; touch "$decoy/ta
run_prune "1000000 1000" run_prune "1000000 1000"
assert_kept "$decoy/target-$DEAD" "a cache outside the cache root is never touched" assert_kept "$decoy/target-$DEAD" "a cache outside the cache root is never touched"
echo
echo "=== 11: a live reader marker protects a cache, like a lock file does ==="
reset_cache
date +%s > "$root/.reading-target-$DEAD-job1"
run_prune "1000000 1000"
assert_kept "$root/target-$DEAD" "a cache being hardlink-cloned right now survives both passes"
assert_log "currently cloning it" "the reader is named in the log, not silently honoured"
rm -f "$root/.reading-target-$DEAD-job1"
echo
echo "=== 12: a reader marker published INSIDE the check-to-unlink window ==="
reset_cache
# A consumer publishes its marker whenever it starts a clone, which can be at
# any instant — including after the pass has checked for markers and before it
# unlinks. That window is real time, not a theoretical interleaving: `du -sk`
# on a multi-GB cache runs for seconds, and the pass runs one on every
# candidate. It is reproduced deterministically here by making that very `du`
# publish the marker, which places it strictly after the check and strictly
# before the unlink — exactly where a check-then-delete eviction cannot see
# it. The candidate must still be standing afterwards, with its contents
# intact and nothing left renamed aside.
mkdir -p "$scratch/bin"
real_du=$(command -v du)
cat > "$scratch/bin/du" <<EOF
#!/usr/bin/env bash
for arg; do
case "\$arg" in */target-$DEAD) date +%s > "$root/.reading-target-$DEAD-racer" ;; esac
done
exec "$real_du" "\$@"
EOF
chmod +x "$scratch/bin/du"
( PATH="$scratch/bin:$PATH"; run_prune "1000000 900000" )
[ -e "$root/.reading-target-$DEAD-racer" ] || fail "the racing marker was never published — scenario 12 proves nothing"
assert_kept "$root/target-$DEAD" "a cache claimed inside the eviction window is not unlinked"
assert_kept "$root/target-$DEAD/blob" "the reprieved cache still has its contents"
assert_log "restored, not evicted" "the reprieve is reported, not silent"
[ -z "$(ls -d "$root"/.evicting-* 2>/dev/null)" ] || fail "an aside directory was left behind after the reprieve"
ok "nothing left renamed aside once the eviction is declined"
rm -f "$root/.reading-target-$DEAD-racer"
echo
echo "=== 13: a deferred eviction is reclaimed, but not under a live reader ==="
reset_cache
aside="$root/.evicting-target-$DEAD-9999"
mkdir -p "$aside"; head -c 4096 /dev/zero > "$aside/blob"
date +%s > "$root/.reading-target-$DEAD-job1"
run_prune "1000000 900000"
assert_kept "$aside" "a deferred eviction is not reclaimed while a job is still reading it"
assert_log "deferring its reclamation again" "the continued deferral is reported"
rm -f "$root/.reading-target-$DEAD-job1"
run_prune "1000000 900000"
assert_gone "$aside" "a deferred eviction is reclaimed once its reader is gone"
assert_log "reclaiming deferred eviction" "the reclamation is reported"
echo echo
echo "prune-cache-selftest: ${pass_count} assertions passed" echo "prune-cache-selftest: ${pass_count} assertions passed"
+94 -11
View File
@@ -57,6 +57,11 @@
# actually-still-running job that somehow exceeds the threshold is visible # actually-still-running job that somehow exceeds the threshold is visible
# in the log rather than silently losing its cache mid-build. # in the log rather than silently losing its cache mid-build.
# #
# That exclusion is decided TWICE per eviction — once as the cheap filter
# that keeps a held directory out of the pass at all, and once after the
# directory has been renamed aside, which is the decision the unlink
# actually rests on. See evict_dir.
#
# Liveness is resolved by `git ls-remote --heads origin`, wrapped in a # Liveness is resolved by `git ls-remote --heads origin`, wrapped in a
# timeout. A directory name cannot be inverted back to a branch name (the # timeout. A directory name cannot be inverted back to a branch name (the
# sanitiser is lossy and the disambiguating suffix is a one-way hash), so this # sanitiser is lossy and the disambiguating suffix is a one-way hash), so this
@@ -93,34 +98,90 @@ is_protected() {
return 1 return 1
} }
# is_locked <dir> [name]
#
# `name` is the directory's own name for reporting and for the reader-marker
# lookup, which matters when <dir> has been renamed aside for eviction: the
# markers a consumer publishes are keyed on the name it resolved, not on
# whatever the eviction pass has since called the directory.
is_locked() { is_locked() {
local dir="$1" now lock_file lock_age locked=1 readers local dir="$1" name="${2:-$(basename "$1")}" now lock_file lock_age locked=1 readers
now=$(date +%s) now=$(date +%s)
# A directory being hardlink-cloned right now carries no .ci-lock-* of its # A directory being hardlink-cloned right now carries no .ci-lock-* of its
# own — a snapshot has its locks stripped by construction — so the reader # own — a snapshot has its locks stripped by construction — so the reader
# markers are the only signal that unlinking it would truncate somebody's # markers are the only signal that unlinking it would truncate somebody's
# in-flight clone. Today no reachable configuration prunes a snapshot (only # in-flight clone. Today no reachable configuration prunes a snapshot (only
# protected refs publish them, and protected refs are excluded from every # protected refs publish them, and protected refs are excluded from every
# pass), which makes this guard redundant *by policy*. It is here so that # pass), which makes this guard redundant *by policy* — evict_dir's second
# stops being the reason it is safe. # look is what stops policy being the reason it is safe.
readers=$(live_reader_count "$ROOT" "$(basename "$dir")") readers=$(live_reader_count "$ROOT" "$name")
if [ "$readers" -gt 0 ]; then if [ "$readers" -gt 0 ]; then
echo " $(basename "$dir"): ${readers} job(s) currently cloning it — not a candidate" echo " ${name}: ${readers} job(s) currently cloning it — not a candidate"
locked=0 locked=0
fi fi
for lock_file in "$dir"/.ci-lock-*; do for lock_file in "$dir"/.ci-lock-*; do
[ -e "$lock_file" ] || continue [ -e "$lock_file" ] || continue
lock_age=$(( now - $(stat -c '%Y' "$lock_file") )) lock_age=$(( now - $(stat -c '%Y' "$lock_file") ))
if [ "$lock_age" -lt "$STALE_LOCK_SECONDS" ]; then if [ "$lock_age" -lt "$STALE_LOCK_SECONDS" ]; then
echo " $(basename "$dir"): held open by $(basename "$lock_file") (${lock_age}s old)" echo " ${name}: held open by $(basename "$lock_file") (${lock_age}s old)"
locked=0 locked=0
else else
echo " $(basename "$dir"): ignoring stale lock $(basename "$lock_file") (${lock_age}s old > ${STALE_LOCK_SECONDS}s) — treating as abandoned" echo " ${name}: ignoring stale lock $(basename "$lock_file") (${lock_age}s old > ${STALE_LOCK_SECONDS}s) — treating as abandoned"
fi fi
done done
return "$locked" return "$locked"
} }
# evict_dir <dir>
#
# Unlinks <dir>, or declines to and says why. Returns 0 only if the directory
# is actually gone.
#
# The caller has already established that <dir> is a candidate, which is not
# the same as establishing that it is still one at the instant of the unlink:
# a consumer publishes its reader marker whenever it starts a clone, and the
# `du` that measures the directory between those two points runs for seconds
# on a multi-GB tree. Unlinking under a live clone truncates it silently —
# `cp -al` never reports a subtree that was removed before it read the
# parent's listing (see cache-lib.sh's reader-marker section).
#
# So the directory is renamed aside first and only then re-examined, which is
# what makes the second look conclusive rather than merely closer to the
# unlink. It is publish-snapshot.sh's rotation, and it rests on the same
# ordering proof: a consumer publishes its marker BEFORE it resolves the
# source path, so one that resolved this directory did so before the rename
# and therefore published its marker before the scan below, which happens
# strictly after that rename. A consumer arriving after the rename cannot
# resolve the path at all and falls through to its own cold-start path — the
# same safe degrade the publisher's swap window already produces.
#
# The rename disturbs nothing already in flight: it unlinks no entry and
# leaves the source inode unchanged, which is precisely why the publish side
# can rotate a snapshot out from under a live reader. A declined eviction
# therefore costs a deferred eviction and nothing else.
evict_dir() {
local dir="$1" name aside
name=$(basename "$dir")
aside="${ROOT}/.evicting-${name}-$$"
mv -T "$dir" "$aside" 2>/dev/null || {
echo " ${name}: could not be set aside for eviction — skipped this pass"
return 1
}
if is_locked "$aside" "$name"; then
if mv -T "$aside" "$dir" 2>/dev/null; then
echo " ${name}: claimed by a job while its eviction was in flight — restored, not evicted"
else
echo "::warning::prune: ${name} was claimed mid-eviction and its own name is occupied again — leaving $(basename "$aside") for a later pass to reclaim once its readers drain"
fi
return 1
fi
rm -rf "$aside"
return 0
}
# Directories oldest-first, target-* before snapshot-* (see the header). # Directories oldest-first, target-* before snapshot-* (see the header).
# The sort key is a rank digit followed by a zero-padded mtime, so the two # The sort key is a rank digit followed by a zero-padded mtime, so the two
# groups sort as blocks rather than interleaving by age. `.cache-last-used` # groups sort as blocks rather than interleaving by age. `.cache-last-used`
@@ -141,6 +202,28 @@ list_by_lru() {
done | sort | cut -f2- done | sort | cut -f2-
} }
# Deferred reclamations from an earlier pass: a directory renamed aside for
# eviction that could not be unlinked, because a job claimed it inside the
# window and its own name was occupied again before it could be restored — or
# whose run was killed between the rename and the unlink. Nothing below would
# ever see one: every pass globs target-*/snapshot-*, which a dotted name does
# not match. Left unswept it is permanently unreclaimable disk on the one
# volume whose entire problem is disk.
#
# Safe under a concurrent pass mid-eviction for the reason evict_dir is: the
# aside name only exists after that pass's rename, so a consumer that resolved
# the directory published its marker before the count below.
for aside in "$ROOT"/.evicting-*; do
[ -d "$aside" ] || continue
aside_name=$(basename "$aside"); aside_name="${aside_name#.evicting-}"; aside_name="${aside_name%-*}"
if [ "$(live_reader_count "$ROOT" "$aside_name")" -gt 0 ]; then
echo "prune: $(basename "$aside") is still being read — deferring its reclamation again"
continue
fi
echo "prune: reclaiming deferred eviction $(basename "$aside")"
rm -rf "$aside"
done
echo "=== pass 1: liveness (unconditional, not gated on free space) ===" echo "=== pass 1: liveness (unconditional, not gated on free space) ==="
declare -A live_ns=() declare -A live_ns=()
LIVENESS_AVAILABLE=0 LIVENESS_AVAILABLE=0
@@ -174,9 +257,9 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then
[ -n "${live_ns[$name]:-}" ] && continue [ -n "${live_ns[$name]:-}" ] && continue
is_locked "$dir" && continue is_locked "$dir" && continue
dir_gb=$(usage_gb "$dir") dir_gb=$(usage_gb "$dir")
echo "::warning::pruning dead-branch cache ${name} (${dir_gb} GB) — no matching branch on origin" evict_dir "$dir" || continue
echo "::warning::pruned dead-branch cache ${name} (${dir_gb} GB) — no matching branch on origin"
summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — branch no longer exists on origin" summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — branch no longer exists on origin"
rm -rf "$dir"
pruned_any=1 pruned_any=1
done done
[ "$pruned_any" = "1" ] || echo "no dead-branch caches found" [ "$pruned_any" = "1" ] || echo "no dead-branch caches found"
@@ -207,9 +290,9 @@ for dir in "${LRU[@]}"; do
[ "$FREE_KB" -ge "$THRESHOLD_KB" ] && break [ "$FREE_KB" -ge "$THRESHOLD_KB" ] && break
is_locked "$dir" && continue is_locked "$dir" && continue
dir_gb=$(usage_gb "$dir") dir_gb=$(usage_gb "$dir")
echo "::warning::evicting $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)" evict_dir "$dir" || continue
echo "::warning::evicted $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)"
summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)" summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)"
rm -rf "$dir"
done done
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")" read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"