fix(prune): stop protecting publisher target dirs under disk pressure
CI / shellcheck + selftests (pull_request) Successful in 1m48s
CI / shellcheck + selftests (pull_request) Successful in 1m48s
A publisher branch's target-<ref> was protected identically to its snapshot-<ref>, so it was never a pressure-pass candidate however old and however tight the disk. With one branch's target dir permanently resident alongside its snapshot, a second branch had no room to seed, and every PR that night needed a hand eviction between runs (daniel/gitdan-actions#24). A publisher's target dir is a convenience cache its own next run reseeds from the snapshot, so losing it under pressure is cheap; nothing downstream depends on it surviving. Only the snapshot stays protected in the pressure and self-clear passes. Unprotecting the target dir outright surfaced a second bug the fix would otherwise have shipped: a branch's tip is trivially an ancestor of itself, so once a protected ref's target dir was no longer skipped before reaching the merged-branch check, pass 1 read it as "merged into itself" and deleted it unconditionally on every run, independent of disk pressure. is_protected_from_liveness keeps a protected ref's target dir out of pass 1 alone, so it stays an ordinary pressure-pass candidate without ever reaching that check. Scenario 3b in the selftest red-proves this against the unprotect-only version of the fix. Also corrects the header's inode-sharing claim, measured false on the live volume by daniel/zemyna#1073: publish-snapshot.sh unshares every executable after its cp -al, and executables are most of the tree by bytes, so a snapshot eviction is a real, large disk cost rather than the near-free one the old text described — the target-before-snapshot ordering still holds, now for the warm-start reason alone plus that cost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user