refactor(cache): name the mutable set once, and measure it

The set of paths a hardlink clone has to real-copy — dep-info,
build-script metadata, linked outputs, the pruned directories that hold
them — was spelled out inline in unshare_mutable_paths, in four find
invocations. Nothing else needed it, so one spelling was enough.

Something else needs it now: the prune has to know what a clone will
cost before it happens, and a sizer with its own copy of the predicates
would drift from the copier silently and in the dangerous direction — an
under-measured clone is one that starts and runs out of disk halfway
through unsharing. So the directory names become one array and the file
rules one dispatcher, applied by a callback per side, with each rule's
rationale moved to the rule rather than left at the old call site.

mutable_set_kb measures that set off a snapshot, skipping the subtrees
already measured whole so nothing is counted twice; clone_headroom_kb
scales it by a hand-written margin and floor for what the measurement
cannot see (cp -al materialising every directory for real, and the
unshare holding one subtree twice at its peak). Both residuals are named
where the function is, in both directions.

seed_source_candidates moves the seed's source-preference list into
cache-lib for the same reason: the prune ahead of it has to resolve the
same source the seed will clone, and two agreeing derivations are one
edit away from disagreeing.

No behaviour change — the copier applies the same rules to the same
tree, verified by the hardlink-clone suite's inode partition in both
directions.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
This commit is contained in:
2026-09-07 00:20:45 -05:00
co-authored by Claude Fable 5.1
parent 0b099ecf62
commit a960c8f91b
2 changed files with 192 additions and 44 deletions
+187 -40
View File
@@ -330,6 +330,62 @@ _holds_compiled_artifact() {
return 1
}
# The directory names that select a mutable subtree, as one find predicate.
#
# Named once because THREE readers have to agree on it: the selection in
# _mutable_dirs, the prune that skips those subtrees when it sizes the file
# rules, and anything later that measures what a clone will cost. Two
# spellings of this list would size a different tree than the one copied, and
# the direction that fails is silent — an under-measured clone runs out of
# disk mid-unshare, which is gitdan-actions#20.
_MUTABLE_DIR_NAMES=( -name .fingerprint -o -name fingerprint -o -name run -o -name out )
# _mutable_file_rules <fn>
#
# The FILE half of the mutable set, applied one rule at a time:
#
# <fn> <label> <maxdepth|-> <find-predicate...>
#
# Same reason as the array above — `unshare_mutable_paths` copies these and
# `mutable_set_kb` measures them, and a rule that exists in only one of the
# two is exactly the under-estimate the headroom gate cannot survive. The
# maxdepth is a separate field because GNU find wants it ahead of every other
# predicate, so it cannot live inside the predicate vector.
#
# `-type f` is each caller's to add: the sizer needs it inside the `-o`
# alternation it builds, the copier ahead of it.
_mutable_file_rules() {
local fn="$1"
"$fn" 'dep-info files' - -name '*.d' || return 1
# Layout v1's build-script run metadata, which v2 groups under `run/` and v1
# leaves loose in the run unit's directory. `invoked.timestamp` is empty and
# carries its meaning in its mtime, which a shared inode carries too.
"$fn" 'build-script run metadata' - \
\( -name output -o -name root-output -o -name stderr -o -name invoked.timestamp \) || return 1
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
# temporary and renames into place — an executable or shared object is
# written by the LINKER, and the linker writes THROUGH an existing inode.
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
# rewrote the SOURCE's copy of itself in place, under both layouts.
#
# What separates that from the executables measured INTACT is not
# established. Every intact case observed was one Cargo has to re-create
# anyway to maintain an uplift hardlink — a bin target's
# `deps/<bin>-<hash>`, twinned at `<profile>/<bin>`. Whether the twin is the
# mechanism or a correlate of it was not determined, and the rule below does
# not depend on the answer: exempting twinned executables would recover no
# bytes this function newly copies. See gitdan-actions#17.
#
# The executable bit is the discriminator because it is the linker's own
# output that is at risk, not the directory it happens to land in — `.rlib`,
# `.rmeta` and `incremental/` stay shared and they are the bytes that matter.
"$fn" 'linked outputs' - -perm -u+x || return 1
"$fn" '.rustc_info.json' 3 -name '.rustc_info.json' || return 1
return 0
}
# The directories `unshare_mutable_paths` replaces, under either layout.
#
# All four names are pruned, so nothing selected here can contain anything else
@@ -380,10 +436,24 @@ _mutable_dirs() {
fi
printf '%s\n' "$d"
done < <(find "$root" -type d \
\( -name .fingerprint -o -name fingerprint -o -name run -o -name out \) \
\( "${_MUTABLE_DIR_NAMES[@]}" \) \
-prune -print 2>/dev/null)
}
# _mutable_file_rules' callback for the copying side. The root travels in a
# variable rather than an argument because the callback's own signature is the
# rule's, and every rule has to reach the same tree.
_unshare_one_rule() {
local label="$1" maxdepth="$2"; shift 2
local -a depth=()
[ "$maxdepth" = - ] || depth=(-maxdepth "$maxdepth")
_unshare_files "$_MUTABLE_ROOT" ${depth[@]+"${depth[@]}"} -type f "$@" || {
echo "::error::unshare_mutable_paths: failed to unshare ${label} under ${_MUTABLE_ROOT}" >&2
return 1
}
return 0
}
# THE load-bearing function of this whole design.
#
# A hardlink clone is only safe if every write the clone's build performs
@@ -493,6 +563,7 @@ _mutable_dirs() {
unshare_mutable_paths() {
local root="$1" d
[ -d "$root" ] || return 0
_MUTABLE_ROOT="$root"
# The list is materialised in full before anything is replaced: each
# replacement deletes and recreates a directory, and a live `find` walk over
# a tree being mutated underneath it is a needless hazard.
@@ -505,45 +576,121 @@ unshare_mutable_paths() {
return 1
}
done
_unshare_files "$root" -type f -name '*.d' || {
echo "::error::unshare_mutable_paths: failed to unshare dep-info files under ${root}" >&2
return 1
}
# Layout v1's build-script run metadata, which v2 groups under `run/` and v1
# leaves loose in the run unit's directory. `invoked.timestamp` is empty and
# carries its meaning in its mtime, which a shared inode carries too.
_unshare_files "$root" -type f \
\( -name output -o -name root-output -o -name stderr -o -name invoked.timestamp \) || {
echo "::error::unshare_mutable_paths: failed to unshare build-script run metadata under ${root}" >&2
return 1
}
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
# temporary and renames into place — an executable or shared object is
# written by the LINKER, and the linker writes THROUGH an existing inode.
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
# rewrote the SOURCE's copy of itself in place, under both layouts.
#
# What separates that from the executables measured INTACT is not
# established. Every intact case observed was one Cargo has to re-create
# anyway to maintain an uplift hardlink — a bin target's
# `deps/<bin>-<hash>`, twinned at `<profile>/<bin>`. Whether the twin is the
# mechanism or a correlate of it was not determined, and the rule below does
# not depend on the answer: exempting twinned executables would recover no
# bytes this function newly copies. See gitdan-actions#17.
#
# The executable bit is the discriminator because it is the linker's own
# output that is at risk, not the directory it happens to land in — `.rlib`,
# `.rmeta` and `incremental/` stay shared and they are the bytes that matter.
_unshare_files "$root" -type f -perm -u+x || {
echo "::error::unshare_mutable_paths: failed to unshare linked outputs under ${root}" >&2
return 1
}
_unshare_files "$root" -maxdepth 3 -type f -name '.rustc_info.json' || {
echo "::error::unshare_mutable_paths: failed to unshare .rustc_info.json under ${root}" >&2
return 1
}
_mutable_file_rules _unshare_one_rule || return 1
return 0
}
# ---------------------------------------------------------------------------
# What the seed will clone, and what that clone costs in disk
# ---------------------------------------------------------------------------
# The sources seed-target-dir.sh considers, most specific first, as
# `<dir>:<label>` lines.
#
# Read by the seed, which clones the first one that exists, and by the prune
# that has to size the volume for that clone BEFORE it happens. One derivation
# rather than two agreeing ones: a prune that sizes a different tree than the
# seed clones is measuring nothing, and nothing downstream would say so.
seed_source_candidates() {
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}"
[ -n "$base_key" ] && printf '%s:base snapshot\n' "$(snapshot_dir_for "$root" "$base_key")"
printf '%s:own snapshot\n' "$(snapshot_dir_for "$root" "$own_key")"
[ -n "$fallback" ] && printf '%s:fallback dir\n' "$fallback"
return 0
}
# The directory the seed will actually hardlink-clone on this run, or nothing
# at all when it will not clone: its own target dir already exists (the seed
# reuses it and returns before the candidate list is consulted), or no
# candidate exists (it starts cold).
seed_clone_source() {
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}" entry src
[ -d "$(target_dir_for "$root" "$own_key")" ] && return 0
while IFS= read -r entry; do
src="${entry%%:*}"
if [ -d "$src" ]; then printf '%s' "$src"; return 0; fi
done < <(seed_source_candidates "$root" "$own_key" "$base_key" "$fallback")
return 0
}
# _mutable_file_rules' callback for the measuring side.
#
# One find per rule, skipping the subtrees `_mutable_dirs` already selects
# whole — those are measured by the `du` in mutable_set_kb, and counting a
# file twice would inflate the requirement into evicting caches nothing
# needed. `%k` is allocated 1K blocks, the same unit `du -sk` reports, so the
# two halves add.
_size_one_rule() {
local maxdepth="$2"; shift 2
local -a depth=()
[ "$maxdepth" = - ] || depth=(-maxdepth "$maxdepth")
find "$_MUTABLE_ROOT" ${depth[@]+"${depth[@]}"} \
\( "${_MUTABLE_DIR_NAMES[@]}" \) -prune -o \
-type f \( "$@" \) -printf '%k\n' 2>/dev/null
return 0
}
# The kilobytes `unshare_mutable_paths` will really-copy out of <dir> — the
# part of a hardlink clone that costs new disk, as opposed to the `.rlib`,
# `.rmeta` and `incremental/` bytes that stay shared with the source.
#
# Measured off the SAME enumeration the copier uses (`_mutable_dirs` and
# `_mutable_file_rules`), which is the only thing that makes this a
# measurement rather than an estimate.
#
# Two residuals, both named because neither is bounded away:
#
# OVER by any file matching two rules at once — an executable named
# `output`, say. Rare, and small.
# UNDER by the `*.d` files inside an `out` directory that _mutable_dirs
# leaves SHARED (the compile-unit case: it holds an .rlib and has no
# build-script record beside it). Such a directory holds a library artifact
# by definition, so what is missed is dep-info, not executables. Also under
# by a `.rustc_info.json` deeper than the copier's own maxdepth, which is
# kilobytes.
#
# The margin in clone_headroom_kb is what covers the under-count; it is not
# there to make the measurement optional.
mutable_set_kb() {
local root="$1"
[ -d "$root" ] || { printf '0'; return 0; }
_MUTABLE_ROOT="$root"
{
_mutable_dirs "$root" | tr '\n' '\0' | xargs -0 -r du -sk 2>/dev/null | awk '{print $1}'
_mutable_file_rules _size_one_rule
} | awk '{s += $1} END { printf "%d", s + 0 }'
return 0
}
# How much free space the seed needs on the volume before it clones <dir>.
#
# CACHE_CLONE_HEADROOM_PERCENT scales the measured mutable set;
# CACHE_CLONE_HEADROOM_FLOOR_KB is added on top. BOTH DEFAULTS ARE
# HAND-WRITTEN — nothing measures them, and they are separate because they
# cover different things:
#
# The percentage covers what scales with the tree: `unshare_subtree` stages
# each mutable directory through a sibling copy before dropping the shared
# original, so at its peak one subtree is held twice, and the under-count
# named on mutable_set_kb scales with the tree too.
# The floor covers what does not: `cp -al` materialises every DIRECTORY for
# real (only files are linked), and a Bevy-sized target dir has hundreds of
# thousands of them.
#
# Both are overridable, and the direction of error is deliberate. Over-asking
# evicts a cache that would have fitted, costing one branch a cold start;
# under-asking lets the clone start and run out of disk halfway through the
# unshare, which fails the job with an error naming a staging path — the
# failure gitdan-actions#20 is filed about.
CACHE_CLONE_HEADROOM_PERCENT="${CACHE_CLONE_HEADROOM_PERCENT:-150}"
CACHE_CLONE_HEADROOM_FLOOR_KB="${CACHE_CLONE_HEADROOM_FLOOR_KB:-2097152}"
clone_headroom_kb() {
local src="${1:-}" kb
if [ -z "$src" ] || [ ! -d "$src" ]; then printf '0'; return 0; fi
kb=$(mutable_set_kb "$src")
awk -v k="$kb" -v pct="$CACHE_CLONE_HEADROOM_PERCENT" -v floor="$CACHE_CLONE_HEADROOM_FLOOR_KB" \
'BEGIN { printf "%d", (k * pct / 100) + floor }'
return 0
}