feat(cargo-cache): hardlink-clone a per-ref Cargo cache from a published snapshot

Replaces the phase-0 resolution probe with the real actions, merging the two
independent per-branch Cargo cache implementations on this forge into the
design neither of them had.

## The merge

- zemyna seeds a PR branch by `cp -al` hardlink clone (near-free: cost scales
  with inode count, not bytes) from the base branch's LIVE target dir — a
  torn read waiting for a second job slot (its own #911).
- emowheel seeds from a PUBLISHED IMMUTABLE SNAPSHOT (no race by
  construction) but with `cp -a`, duplicating ~35 GB per branch.

This ships hardlink-clone FROM a published snapshot: zemyna's cost profile,
emowheel's soundness, and #911 closed structurally rather than by the runner
happening to have one execution slot.

## The bug both implementations have

A build inside a `cp -al` clone DOES mutate the directory it was cloned from.
Cargo replaces real artifacts, but writes its metadata — and build scripts
write their OUT_DIR — with a plain truncating write, straight through the
shared inode. Measured set: `.fingerprint/<unit>/dep-<target>` (under
CARGO_UNSTABLE_CHECKSUM_FRESHNESS), `build/<pkg>/{output,root-output,out/**}`,
`deps/*.d` and `<profile>/*.d`.

The checksum-freshness case is a wrong answer, not a slow build: a PR clone
rewrites the base's dep-info to describe the PR's sources while the base's
cache still holds the artifact built from the base's; once the PR merges, the
base's next run finds the checksums match, reports `Fresh`, and links a binary
built from the pre-merge code. Reproduced end to end.

Fix: hardlink the artifacts (the GB), real-copy the metadata (the MB) — about
3.7% of a 6.9 GB Bevy target dir, against 100% for a full copy.

## Contents

- `cargo-cache/action.yml` — consume: resolve keys, seed from the base's
  snapshot via staging + one atomic rename, strip Cargo lock files, unshare
  the mutable paths, restore mtimes from git history, lock, prune.
- `cargo-cache-publish/action.yml` — publish: record the build watermark,
  atomically republish the snapshot on a protected branch, release the lock
  (`mode: release-lock` for the `if: always()` step).
- `scripts/` — all logic, so it is testable standalone; the YAML is wiring.
- `scripts/*selftest.sh` + `selftest.sh` — five suites, 63 assertions, every
  fix paired with a control that reproduces the bug. All green locally.

Eviction merges emowheel's liveness pass (dead branches pruned
unconditionally, not gated on disk pressure) with LRU-under-pressure, but
inverts the order within the pressure pass: `target-*` before `snapshot-*`,
because a snapshot is hardlinked to everything cloned from it, so evicting one
frees almost no real bytes while costing every future PR its warm start.

restore-mtimes.sh is ported from emowheel (the watermark variant, which closes
the merge hazard zemyna's copy still has) with its provenance de-projectised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqh2vscfzisk83VuPVQX9L
This commit is contained in:
2026-08-23 14:18:01 -05:00
co-authored by Claude Opus 5
parent 8503883138
commit 248af3061e
17 changed files with 2693 additions and 14 deletions
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Consume side: make this run's own target dir exist, warm, and private.
#
# Usage: seed-target-dir.sh <own-key> <base-key> <cache-root> <tag> [fallback-dir]
# own-key cache key for this run's own ref
# base-key cache key for the ref to layer over ("" for a run whose own
# ref IS a reference branch)
# cache-root mount point of the persistent volume
# tag a per-job-per-run unique string, used to name the staging
# directory so two concurrent jobs can never collide on it
# fallback-dir optional absolute path to seed from when no snapshot exists
# (a legacy flat cache dir during a migration, typically)
#
# The design in one paragraph: a PR branch's first run hardlink-clones the
# base branch's PUBLISHED SNAPSHOT. Hardlink, because the clone then costs
# time proportional to inode count rather than data volume — on ext4, with no
# reflink support, that is the only way to make "layer over the base" cheap.
# Snapshot rather than the base's live target dir, because a live directory is
# being written by its own job while a consumer reads it, and a torn read
# pairs one build's fingerprint with another build's artifact — which degrades
# to a WRONG reuse, not to a safe miss. Neither half is novel; the combination
# is what makes the scheme both cheap and sound, instead of one or the other.
#
# Everything about correctness under concurrency lives in
# hardlink_clone_into() and unshare_mutable_paths() in cache-lib.sh — read
# those before changing anything here.
set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cache-lib.sh"
if [ $# -lt 4 ] || [ $# -gt 5 ]; then
echo "::error::seed-target-dir.sh: expected 4 or 5 arguments (own-key, base-key, cache-root, tag, [fallback-dir])" >&2
exit 1
fi
OWN_KEY="$1"; BASE_KEY="$2"; ROOT="$3"; TAG="$4"; FALLBACK="${5:-}"
OWN_DIR=$(target_dir_for "$ROOT" "$OWN_KEY")
mkdir -p "$ROOT"
if [ -d "$OWN_DIR" ]; then
echo "seed: reusing this ref's own cache at ${OWN_DIR} ($(usage_gb "$OWN_DIR") GB)"
echo "seeded-from=own" >> "${GITHUB_OUTPUT:-/dev/null}"
exit 0
fi
# Source preference, most specific first:
#
# 1. the base ref's snapshot — the ordinary PR case, and the whole point.
# 2. this ref's OWN snapshot — a reference branch whose live target dir was
# evicted under disk pressure can restore itself from the last snapshot
# it published, instead of paying a cold rebuild. Free, given the
# snapshot already exists.
# 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.
CANDIDATES=()
[ -n "$BASE_KEY" ] && CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$BASE_KEY"):base snapshot")
CANDIDATES+=("$(snapshot_dir_for "$ROOT" "$OWN_KEY"):own snapshot")
[ -n "$FALLBACK" ] && CANDIDATES+=("${FALLBACK}:fallback dir")
for entry in "${CANDIDATES[@]}"; do
src="${entry%%:*}"; label="${entry#*:}"
[ -d "$src" ] || continue
echo "seed: hardlink-cloning ${label} ${src} ($(usage_gb "$src") GB) -> ${OWN_DIR}"
start=$(date +%s)
if hardlink_clone_into "$src" "$OWN_DIR" "$TAG"; then
echo "seed: cloned in $(( $(date +%s) - start ))s"
echo "seeded-from=${label// /-}" >> "${GITHUB_OUTPUT:-/dev/null}"
else
# Another job sharing this cache key won the rename while we were
# cloning. Its directory is complete (the rename is the publish step), so
# there is nothing to do but use it — and nothing was ever observable in
# a half-seeded state.
echo "seed: another job seeded ${OWN_DIR} concurrently; discarded our staging copy and using theirs"
echo "seeded-from=concurrent-peer" >> "${GITHUB_OUTPUT:-/dev/null}"
fi
exit 0
done
echo "seed: no snapshot or fallback available — ${OWN_DIR} starts cold"
echo "seeded-from=cold" >> "${GITHUB_OUTPUT:-/dev/null}"
mkdir -p "$OWN_DIR"