diff --git a/README.md b/README.md index dff0b21..ef66069 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ not collapsed into one: pass still deciding about one is never mistaken for a pass that died holding it. That settle window is a bound rather than a construction, and it is the only part of this that is. Until the rest of it was structural it was merely - policy — snapshots belong to protected refs, protected refs are never - eviction candidates — a property held by vigilance rather than by + policy — snapshots belong to protected refs, and a protected ref's snapshot + is never an eviction candidate — a property held by vigilance rather than by construction. - **Every other way the source can change mid-clone is detected, not prevented.** A `seed-fallback-dir` pointing at a directory something else @@ -271,12 +271,21 @@ not collapsed into one: available to the clone that follows. Caches for branches that are DEAD are removed unconditionally; then, only if free space is under the requirement, live caches are evicted oldest-first; then, as a last resort, this run's own -cache. Protected refs, the source this run is about to clone, and any cache -held open by a running job are never candidates. Within the pressure pass, -`target-*` directories are evicted before `snapshot-*` ones — the reverse of -the obvious order, because a snapshot is hardlinked to everything cloned from -it, so removing one frees almost no real bytes while costing every future PR -its warm start. +cache. A protected ref's *snapshot*, the source this run is about to clone, +and any cache held open by a running job are never candidates in the pressure +or self-clear passes. A protected ref's own *target* dir is an ordinary +pressure-pass candidate, since it is a convenience cache the publisher's next +run reseeds from the snapshot — but it is excluded from the liveness pass +alone, because a branch's tip is trivially an ancestor of itself, and without +that exclusion the merged-branch signal would read a publisher's own target +dir as merged into itself and delete it every run, unconditionally. Within the +pressure pass, `target-*` directories are evicted before `snapshot-*` ones: +losing a target dir is cheap for exactly that reseeding reason, while +evicting a snapshot forces every subsequent PR to start cold and, measured on +the live volume (daniel/zemyna#1073), frees real disk rather than the +near-nothing a shared-inode hardlink clone would suggest — `publish-snapshot.sh` +unshares every executable after its `cp -al`, and executables are most of the +tree by bytes. **A branch is dead in two ways, and neither signal makes the other redundant.** The first is that the branch is gone from origin. The second is @@ -474,7 +483,7 @@ directory; the line just doesn't say which. |---|---|---| | `cache-root` | `/cache` | mount point of the persistent volume inside the job container | | `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) | -| `protected-branches` | `dev main` | refs that publish snapshots and are never evicted | +| `protected-branches` | `dev main` | refs that publish snapshots, whose snapshots are never evicted (their target dirs are ordinary pressure-pass candidates) | | `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it | | `restore-mtimes` | `true` | restore tracked-file mtimes from git history | | `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone | diff --git a/cargo-cache/action.yml b/cargo-cache/action.yml index 4006673..a22795d 100644 --- a/cargo-cache/action.yml +++ b/cargo-cache/action.yml @@ -24,7 +24,9 @@ inputs: default: '' protected-branches: description: >- - Space-separated refs that publish snapshots and are never evicted. + Space-separated refs that publish snapshots. A protected ref's + snapshot is never evicted; its own target dir is an ordinary + pressure-pass candidate, reseeded from the snapshot on its next run. These are the branches PR caches layer over. required: false default: 'dev main' diff --git a/scripts/prune-cache-selftest.sh b/scripts/prune-cache-selftest.sh index e83e239..ab0aa17 100755 --- a/scripts/prune-cache-selftest.sh +++ b/scripts/prune-cache-selftest.sh @@ -11,8 +11,13 @@ # 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. +# 3. A PROTECTED REF'S SNAPSHOT NEVER EVICTED under forced disk pressure, +# even when it is the oldest on disk and would rank first for LRU — its +# own TARGET dir is an ordinary candidate and goes (gitdan-actions#24). +# 3b. AND A PROTECTED REF'S TARGET DIR SURVIVES PASS 1 ANYWAY: its tip is +# trivially an ancestor of itself, so the merged-branch signal must never +# be allowed to evaluate it, or pass 1 — unconditional, not gated on +# pressure — would delete it every run. # 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and # the pass's closing summary agrees with the decline it just logged, # rather than reporting that it found nothing. @@ -139,13 +144,34 @@ assert_kept "$root/target-$LIVE" "live branch survives despite an older marker t assert_log "no matching branch on origin" "eviction reason reported" echo -echo "=== 3: protected refs never evicted under forced pressure ===" +echo "=== 3: protected refs' SNAPSHOTS 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" +# Their TARGET dirs are ordinary candidates and go under the same pressure — +# gitdan-actions#24: protecting them starved every second branch of room to +# seed. Asserted here (gone, not kept) so this scenario still red-proves the +# snapshot half if a future change reintroduces target protection. +assert_gone "$root/target-$DEV" "dev's target dir is an ordinary pressure-pass candidate" +assert_gone "$root/target-$MAIN" "main's target dir is an ordinary pressure-pass candidate" + +echo +echo "=== 3b: a protected ref's target dir is NOT pruned by pass 1's liveness ===" +# The regression this fix could introduce and the selftest above cannot see: +# a protected branch's tip is trivially an ancestor of itself, so once its +# target dir stopped being excluded from pass 1 altogether, is_merged_dead +# read it as "merged into itself" and pass 1 — unconditional, not gated on +# pressure — deleted it on every single run. Plenty of free space, so only +# pass 1 can be responsible for anything gone here. +reset_cache +run_prune "1000000 900000" # 90% free: no pressure at all +assert_kept "$root/target-$DEV" "dev's target dir survives pass 1 despite being its own ancestor" +assert_kept "$root/target-$MAIN" "and so does main's" +if grep -q "merged into" "$scratch/log"; then + fail "a protected ref's own target dir was evaluated by the merged-branch signal at all" +fi +ok "no protected ref's own target dir reaches the merged-branch check" echo echo "=== 9: own cache never evicted by a sibling pass ===" diff --git a/scripts/prune-cache.sh b/scripts/prune-cache.sh index bf20f4e..20baf5c 100755 --- a/scripts/prune-cache.sh +++ b/scripts/prune-cache.sh @@ -67,21 +67,32 @@ # physics, not an arbitrary GB number. # # EVICTION ORDER, and why it is the reverse of the obvious one: within the -# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones. -# A snapshot is a hardlink clone of a live target dir and of every consumer -# cloned from it, so removing it frees almost no real bytes — its inodes stay -# alive through those other links — while costing every future PR its warm -# start. Evicting snapshots first would be nearly pure loss. Target -# directories are where a branch's own divergent artifacts actually live, so -# they are what freeing space means. +# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones +# (a publisher's own target dir included — see PROTECTED below). A publisher +# branch's target dir is a convenience cache that its own next run reseeds +# from the snapshot, so losing it is cheap; evicting the snapshot instead +# forces every subsequent PR to start cold. Measured on the live volume +# (daniel/zemyna#1073), that cold start is not the near-free move it looks +# like either: a snapshot shares almost nothing with the target dirs cloned +# from it, because publish-snapshot.sh unshares every executable after its +# `cp -al` (cargo and the linker rewrite binaries in place, so a shared +# original would corrupt under them), and executables are the great majority +# of the tree by bytes. So a snapshot eviction is a real, large disk cost as +# well as a cold-start one — reserved for last because both costs are larger +# than a target dir's. # # Two exclusions every pass respects: # -# PROTECTED — the publisher branches' target and snapshot directories, and -# this run's own target dir, are never candidates in any pass. Evicting a -# publisher's snapshot doesn't free real disk (every open PR's clone keeps -# the data alive) but does force every subsequent PR to start cold, which is -# the entire benefit this scheme exists to deliver. +# PROTECTED — a publisher branch's SNAPSHOT directory, and this run's own +# target dir, are never candidates in the pressure or self-clear passes. A +# publisher branch's own TARGET dir is not protected there: it is an +# ordinary pressure-pass candidate, evicted oldest-first like any other, +# because nothing downstream depends on it surviving — the publisher's own +# next run reseeds it from the snapshot. It IS excluded from pass 1 alone +# (is_protected_from_liveness): a branch's tip is trivially an ancestor of +# itself, so without this exclusion the merged-branch signal would read a +# publisher's own target dir as "merged into itself" and pass 1 — +# unconditional, not gated on pressure — would delete it every run. # # LOCKED — a directory carrying a .ci-lock-* marker younger than # STALE_LOCK_SECONDS is held open by a running job, or named by a live @@ -180,32 +191,52 @@ else fi declare -A protected_ns=() +# A protected ref's own target dir is excluded from pass 1 ONLY (see +# is_protected_from_liveness below), never from the pressure pass. It must +# stay out of pass 1 for a reason that has nothing to do with disk: a +# protected ref's tip is trivially an ancestor of itself, so without this +# is_merged_dead would read dev's own target dir as "merged into dev" and +# pass 1 — unconditional, not gated on pressure — would delete it on every +# single run. +declare -A protected_target_ns=() for ref in $PROTECTED_REFS; do suffix=$(cache_key "$ref") - protected_ns["target-${suffix}"]=1 protected_ns["snapshot-${suffix}"]=1 + protected_target_ns["target-${suffix}"]=1 done -# Prints why is off limits to every pass, or nothing when it is a -# candidate. The reason is not decoration: it is what the failure report at -# the bottom lists against each directory it kept while running out of space. +# Prints why is off limits to the pressure/self-clear passes, or +# nothing when it is a candidate there. The reason is not decoration: it is +# what the failure report at the bottom lists against each directory it kept +# while running out of space. # -# SEED_SRC is the third exclusion and the one this script did not used to need. -# The prune ran after the seed, so the source had already been cloned and the -# reader marker over it was gone; running BEFORE the seed puts the directory -# this run is about to read squarely in the candidate set, and pass 1 would -# take it the moment its branch merged. +# SEED_SRC is the third exclusion and the one this script did not used to +# need. The prune ran after the seed, so the source had already been cloned +# and the reader marker over it was gone; running BEFORE the seed puts the +# directory this run is about to read squarely in the candidate set, and +# pass 1 would take it the moment its branch merged. protected_reason() { local dir="$1" name name=$(basename "$dir") [ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; } [ -n "$SEED_SRC" ] && [ "$dir" = "$SEED_SRC" ] && { printf 'the source this run is about to clone'; return 0; } - [ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch cache'; return 0; } + [ -n "${protected_ns[$name]:-}" ] && { printf 'a protected branch snapshot'; return 0; } return 1 } is_protected() { protected_reason "$1" >/dev/null; } +# Pass 1 (liveness) only: also excludes a protected ref's own target dir, for +# the self-ancestor reason above protected_target_ns documents. The pressure +# pass does not call this — is_protected is what it uses, via protected_reason +# directly. +is_protected_from_liveness() { + local dir="$1" name + is_protected "$dir" && return 0 + name=$(basename "$dir") + [ -n "${protected_target_ns[$name]:-}" ] +} + # is_locked [name] # # `name` is the directory's own name for reporting and for the reader-marker @@ -480,7 +511,7 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do [ -d "$dir" ] || continue name=$(basename "$dir") - is_protected "$dir" && continue + is_protected_from_liveness "$dir" && continue if [ -z "${live_ns[$name]:-}" ]; then why="no matching branch on origin" why_summary="branch no longer exists on origin"