fix(prune): reclaim merged branches and size the volume for the clone #21
+187
-40
@@ -330,6 +330,62 @@ _holds_compiled_artifact() {
|
|||||||
return 1
|
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.
|
# The directories `unshare_mutable_paths` replaces, under either layout.
|
||||||
#
|
#
|
||||||
# All four names are pruned, so nothing selected here can contain anything else
|
# All four names are pruned, so nothing selected here can contain anything else
|
||||||
@@ -380,10 +436,24 @@ _mutable_dirs() {
|
|||||||
fi
|
fi
|
||||||
printf '%s\n' "$d"
|
printf '%s\n' "$d"
|
||||||
done < <(find "$root" -type 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)
|
-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.
|
# THE load-bearing function of this whole design.
|
||||||
#
|
#
|
||||||
# A hardlink clone is only safe if every write the clone's build performs
|
# A hardlink clone is only safe if every write the clone's build performs
|
||||||
@@ -493,6 +563,7 @@ _mutable_dirs() {
|
|||||||
unshare_mutable_paths() {
|
unshare_mutable_paths() {
|
||||||
local root="$1" d
|
local root="$1" d
|
||||||
[ -d "$root" ] || return 0
|
[ -d "$root" ] || return 0
|
||||||
|
_MUTABLE_ROOT="$root"
|
||||||
# The list is materialised in full before anything is replaced: each
|
# The list is materialised in full before anything is replaced: each
|
||||||
# replacement deletes and recreates a directory, and a live `find` walk over
|
# replacement deletes and recreates a directory, and a live `find` walk over
|
||||||
# a tree being mutated underneath it is a needless hazard.
|
# a tree being mutated underneath it is a needless hazard.
|
||||||
@@ -505,45 +576,121 @@ unshare_mutable_paths() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
done
|
done
|
||||||
_unshare_files "$root" -type f -name '*.d' || {
|
_mutable_file_rules _unshare_one_rule || return 1
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare dep-info files under ${root}" >&2
|
return 0
|
||||||
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
|
# What the seed will clone, and what that clone costs in disk
|
||||||
# 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 \) || {
|
# The sources seed-target-dir.sh considers, most specific first, as
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare build-script run metadata under ${root}" >&2
|
# `<dir>:<label>` lines.
|
||||||
return 1
|
#
|
||||||
}
|
# Read by the seed, which clones the first one that exists, and by the prune
|
||||||
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
|
# that has to size the volume for that clone BEFORE it happens. One derivation
|
||||||
# temporary and renames into place — an executable or shared object is
|
# rather than two agreeing ones: a prune that sizes a different tree than the
|
||||||
# written by the LINKER, and the linker writes THROUGH an existing inode.
|
# seed clones is measuring nothing, and nothing downstream would say so.
|
||||||
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
|
seed_source_candidates() {
|
||||||
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
|
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}"
|
||||||
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
|
[ -n "$base_key" ] && printf '%s:base snapshot\n' "$(snapshot_dir_for "$root" "$base_key")"
|
||||||
# rewrote the SOURCE's copy of itself in place, under both layouts.
|
printf '%s:own snapshot\n' "$(snapshot_dir_for "$root" "$own_key")"
|
||||||
#
|
[ -n "$fallback" ] && printf '%s:fallback dir\n' "$fallback"
|
||||||
# What separates that from the executables measured INTACT is not
|
return 0
|
||||||
# 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
|
# The directory the seed will actually hardlink-clone on this run, or nothing
|
||||||
# mechanism or a correlate of it was not determined, and the rule below does
|
# at all when it will not clone: its own target dir already exists (the seed
|
||||||
# not depend on the answer: exempting twinned executables would recover no
|
# reuses it and returns before the candidate list is consulted), or no
|
||||||
# bytes this function newly copies. See gitdan-actions#17.
|
# candidate exists (it starts cold).
|
||||||
#
|
seed_clone_source() {
|
||||||
# The executable bit is the discriminator because it is the linker's own
|
local root="$1" own_key="$2" base_key="$3" fallback="${4:-}" entry src
|
||||||
# output that is at risk, not the directory it happens to land in — `.rlib`,
|
[ -d "$(target_dir_for "$root" "$own_key")" ] && return 0
|
||||||
# `.rmeta` and `incremental/` stay shared and they are the bytes that matter.
|
while IFS= read -r entry; do
|
||||||
_unshare_files "$root" -type f -perm -u+x || {
|
src="${entry%%:*}"
|
||||||
echo "::error::unshare_mutable_paths: failed to unshare linked outputs under ${root}" >&2
|
if [ -d "$src" ]; then printf '%s' "$src"; return 0; fi
|
||||||
return 1
|
done < <(seed_source_candidates "$root" "$own_key" "$base_key" "$fallback")
|
||||||
}
|
return 0
|
||||||
_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' 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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,11 @@ fi
|
|||||||
# snapshot already exists.
|
# snapshot already exists.
|
||||||
# 3. an explicit fallback directory — migration off a pre-existing flat
|
# 3. an explicit fallback directory — migration off a pre-existing flat
|
||||||
# cache, so the first run under this scheme isn't a needless cold build.
|
# cache, so the first run under this scheme isn't a needless cold build.
|
||||||
CANDIDATES=()
|
#
|
||||||
[ -n "$BASE_KEY" ] && CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$BASE_KEY"):base snapshot")
|
# The list itself lives in cache-lib.sh because prune-cache.sh reads it too:
|
||||||
CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$OWN_KEY"):own snapshot")
|
# it runs ahead of this step and has to free enough disk for the clone below,
|
||||||
[ -n "$FALLBACK" ] && CANDIDATES+=("${FALLBACK}:fallback dir")
|
# which means resolving the same source this loop will pick.
|
||||||
|
mapfile -t CANDIDATES < <(seed_source_candidates "$ROOT" "$OWN_KEY" "$BASE_KEY" "$FALLBACK")
|
||||||
|
|
||||||
for entry in "${CANDIDATES[@]}"; do
|
for entry in "${CANDIDATES[@]}"; do
|
||||||
src="${entry%%:*}"; label="${entry#*:}"
|
src="${entry%%:*}"; label="${entry#*:}"
|
||||||
|
|||||||
Reference in New Issue
Block a user