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 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 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 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 policy — snapshots belong to protected refs, and a protected ref's snapshot
eviction candidates — a property held by vigilance rather than by is never an eviction candidate — a property held by vigilance rather than by
construction. 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.** A `seed-fallback-dir` pointing at a directory something else 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 available to the clone that follows. Caches for branches that are DEAD are
removed unconditionally; then, only if free space is under the requirement, 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 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 cache. A protected ref's *snapshot*, the source this run is about to clone,
held open by a running job are never candidates. Within the pressure pass, and any cache held open by a running job are never candidates in the pressure
`target-*` directories are evicted before `snapshot-*` ones — the reverse of or self-clear passes. A protected ref's own *target* dir is an ordinary
the obvious order, because a snapshot is hardlinked to everything cloned from pressure-pass candidate, since it is a convenience cache the publisher's next
it, so removing one frees almost no real bytes while costing every future PR run reseeds from the snapshot — but it is excluded from the liveness pass
its warm start. 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 **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 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-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) | | `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 | | `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 | | `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 | | `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: '' default: ''
protected-branches: protected-branches:
description: >- 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. These are the branches PR caches layer over.
required: false required: false
default: 'dev main' default: 'dev main'
+31 -5
View File
@@ -11,8 +11,13 @@
# Waiting for pressure to notice means paying for dead caches until then. # Waiting for pressure to notice means paying for dead caches until then.
# 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness, # 2. LIVE BRANCH SURVIVES despite being OLDER than the dead one — liveness,
# not age, is what decides pass 1. # not age, is what decides pass 1.
# 3. PROTECTED REFS NEVER EVICTED under forced disk pressure, even when # 3. A PROTECTED REF'S SNAPSHOT NEVER EVICTED under forced disk pressure,
# their caches are the oldest on disk and would rank first for LRU. # 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 # 4. LOCKED CACHE PROTECTED even when dead, old, and under pressure — and
# the pass's closing summary agrees with the decline it just logged, # the pass's closing summary agrees with the decline it just logged,
# rather than reporting that it found nothing. # 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" assert_log "no matching branch on origin" "eviction reason reported"
echo echo
echo "=== 3: protected refs never evicted under forced pressure ===" echo "=== 3: protected refs' SNAPSHOTS never evicted under forced pressure ==="
reset_cache reset_cache
run_prune "1000000 1000" # 0.1% free 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/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" 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
echo "=== 9: own cache never evicted by a sibling pass ===" echo "=== 9: own cache never evicted by a sibling pass ==="
+54 -23
View File
@@ -67,21 +67,32 @@
# physics, not an arbitrary GB number. # physics, not an arbitrary GB number.
# #
# EVICTION ORDER, and why it is the reverse of the obvious one: within the # EVICTION ORDER, and why it is the reverse of the obvious one: within the
# pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones. # pressure pass, `target-*` directories are evicted BEFORE `snapshot-*` ones
# A snapshot is a hardlink clone of a live target dir and of every consumer # (a publisher's own target dir included — see PROTECTED below). A publisher
# cloned from it, so removing it frees almost no real bytes — its inodes stay # branch's target dir is a convenience cache that its own next run reseeds
# alive through those other links — while costing every future PR its warm # from the snapshot, so losing it is cheap; evicting the snapshot instead
# start. Evicting snapshots first would be nearly pure loss. Target # forces every subsequent PR to start cold. Measured on the live volume
# directories are where a branch's own divergent artifacts actually live, so # (daniel/zemyna#1073), that cold start is not the near-free move it looks
# they are what freeing space means. # 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: # Two exclusions every pass respects:
# #
# PROTECTED — the publisher branches' target and snapshot directories, and # PROTECTED — a publisher branch's SNAPSHOT directory, and this run's own
# this run's own target dir, are never candidates in any pass. Evicting a # target dir, are never candidates in the pressure or self-clear passes. A
# publisher's snapshot doesn't free real disk (every open PR's clone keeps # publisher branch's own TARGET dir is not protected there: it is an
# the data alive) but does force every subsequent PR to start cold, which is # ordinary pressure-pass candidate, evicted oldest-first like any other,
# the entire benefit this scheme exists to deliver. # 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 # 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 # STALE_LOCK_SECONDS is held open by a running job, or named by a live
@@ -180,32 +191,52 @@ else
fi fi
declare -A protected_ns=() 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 for ref in $PROTECTED_REFS; do
suffix=$(cache_key "$ref") suffix=$(cache_key "$ref")
protected_ns["target-${suffix}"]=1
protected_ns["snapshot-${suffix}"]=1 protected_ns["snapshot-${suffix}"]=1
protected_target_ns["target-${suffix}"]=1
done done
# Prints why <dir> is off limits to every pass, or nothing when it is a # Prints why <dir> is off limits to the pressure/self-clear passes, or
# candidate. The reason is not decoration: it is what the failure report at # nothing when it is a candidate there. The reason is not decoration: it is
# the bottom lists against each directory it kept while running out of space. # 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. # SEED_SRC is the third exclusion and the one this script did not used to
# The prune ran after the seed, so the source had already been cloned and the # need. The prune ran after the seed, so the source had already been cloned
# reader marker over it was gone; running BEFORE the seed puts the directory # and the reader marker over it was gone; running BEFORE the seed puts the
# this run is about to read squarely in the candidate set, and pass 1 would # directory this run is about to read squarely in the candidate set, and
# take it the moment its branch merged. # pass 1 would take it the moment its branch merged.
protected_reason() { protected_reason() {
local dir="$1" name local dir="$1" name
name=$(basename "$dir") name=$(basename "$dir")
[ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; } [ "$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 "$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 return 1
} }
is_protected() { protected_reason "$1" >/dev/null; } 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] # is_locked <dir> [name]
# #
# `name` is the directory's own name for reporting and for the reader-marker # `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 for dir in "$ROOT"/target-* "$ROOT"/snapshot-*; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
name=$(basename "$dir") name=$(basename "$dir")
is_protected "$dir" && continue is_protected_from_liveness "$dir" && continue
if [ -z "${live_ns[$name]:-}" ]; then if [ -z "${live_ns[$name]:-}" ]; then
why="no matching branch on origin" why="no matching branch on origin"
why_summary="branch no longer exists on origin" why_summary="branch no longer exists on origin"