fix(prune): stop protecting publisher target dirs under disk pressure #25

Merged
claude merged 1 commits from fix/prune-unprotect-target into main 2026-09-22 16:07:53 +00:00
4 changed files with 106 additions and 38 deletions
Showing only changes of commit dc473f0d0c - Show all commits
+18 -9
View File
@@ -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 |
+3 -1
View File
@@ -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'
+31 -5
View File
@@ -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 ==="
+54 -23
View File
@@ -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 <dir> 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 <dir> 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 <dir> [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"