Author SHA1 Message Date
claude 0184df25a2 Merge pull request 'fix(prune): reclaim merged branches and size the volume for the clone' (#21) from fix/prune-merged-live-and-seed-headroom into main
CI / shellcheck + selftests (push) Successful in 1m24s
2026-09-07 05:41:12 +00:00
claudeandClaude Fable 5.1 38a6387936 docs(cache): recommend delete-on-merge, and say what ancestry still covers
CI / shellcheck + selftests (pull_request) Successful in 1m27s
`daniel/zemyna` enabled `default_delete_branch_after_merge` after this
branch was written, so the deleted-branch signal will fire there on
future merges. That makes the setting worth recommending — it is the
cheapest case for this scheme, decidable from `ls-remote` with no
checkout, no objects and no walk — and it does not make the ancestry
signal redundant.

Three things it leaves behind, now enumerated in the README's eviction
section rather than implied: every branch merged before the setting was
turned on, of which zemyna carried 48 and which nothing retroactively
deletes; every merge whose deletion the forge declines or is never asked
to make, since it is best-effort and silent and an API merge without the
flag never asks; and every repo that has not enabled it, which is the
default.

Two present-tense claims about one repo's configuration are reworded
into the conditions they were standing in for, in prune-cache.sh's
header and beside is_merged_dead, plus the two in the selftest that
asserted the forge keeps branches rather than describing the fixture.
No behaviour change; the suite is green and unchanged at 61 assertions.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
2026-09-07 00:27:11 -05:00
claudeandClaude Fable 5.1 24f87a6b98 docs(cache): describe both liveness signals and the derived requirement
CI / shellcheck + selftests (pull_request) Skipped
The eviction section described one way for a branch to be dead and a
percentage threshold that no longer exists as a gate. Rewritten around
what the pass actually does: two dead-branch signals with the limits of
the ancestry one stated, a requirement measured off the clone's mutable
set, and a failure that names its shortfall.

The 10% figure is deleted rather than corrected — it was the default of
a gate, and min-free-percent is now an additional floor defaulting to 0,
so there is no percentage left to state. The inputs table, the prune and
liveness rows, the selftest coverage row and the fetch-depth comment in
the usage example all say what they now mean; fetch-depth: 0 has a
second reason to be required, since a shallow checkout cannot answer
ancestry.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
2026-09-07 00:21:12 -05:00
claudeandClaude Fable 5.1 07ba53ca79 fix(prune): reclaim merged branches, and size the volume for the clone
Two assumptions in the eviction pass did not hold on this forge, and
between them a volume filled up three times in three days with nothing
reclaimed automatically. Both are replaced here; the pass also moves
ahead of the seed, which is the only order in which its work can help
the run performing it.

LIVENESS. Pass 1 evicted a cache only when its branch was gone from
origin. Gitea keeps a PR's branch after the merge unless the repo opts
into delete-on-merge, and zemyna does not — so ls-remote reports fifty
merged branches and the signal fires for none of them. A second signal
is added beside it: a branch still on origin whose tip is an ancestor of
a protected branch's tip holds no commit that branch does not, so its
cache will never be read again and goes in the same unconditional pass.

Ancestry is answered from the commits in the job's own checkout, so the
answer "cannot tell" exists and stays distinct from "not merged" at both
granularities. A shallow checkout withholds the signal entirely, since a
missing object is its normal case rather than evidence. A single branch
whose tip is not in the checkout is kept, with a warning naming it. A
squash or rebase merge leaves no ancestry and reads as live until the
branch is deleted. All three are missed reclamations, which cost disk;
the other direction costs a branch its cache mid-build.

HEADROOM. Passes 2 and 3 gated on a percentage of the volume, which
cannot express the failure they have to prevent: a clone runs out of
disk while unsharing its mutable paths, and how much that needs is a
property of the snapshot rather than of the disk. Staging failed at 34 G
free and passed at 74 G, so a 10% floor — 19 G here — never fired first.
The requirement is now measured per run off the very source the seed
will read, the pass evicts oldest-first until it is met and stops there,
and falling short of it fails with the shortfall and every directory it
kept, rather than letting the seed fail seconds later against a staging
path that names none of that. min-free-percent survives as an additional
floor, defaulting to 0, and falling short of that one is still a warning
and a self-clear.

ORDERING. The prune step ran after the seed, so each run freed space for
the next one. It now runs between resolve and seed. Two things that
makes newly reachable are closed: the source about to be cloned is
excluded from every pass by name, and a concurrent job's target dir
already carries its lock from the instant it appears under its final
name, so nothing is seen unlocked that is in use.

Red-proven: sixteen assertions across the four new scenarios fail
against the pre-fix scripts, including the zemyna layout evicting
nothing where it should evict exactly one directory, and the headroom
scenario exiting 0 where it should exit 1.

Refs #20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JXMQCJ5Eg5f9G9cfYzyh4Z
2026-09-07 00:21:12 -05:00
claudeandClaude Fable 5.1 a960c8f91b 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
2026-09-07 00:20:45 -05:00
claude 0b099ecf62 Merge pull request 'fix(ci): trigger on edited so un-drafting actually lifts the draft skip' (#18) from fix/ci-edited-trigger into main
CI / shellcheck + selftests (push) Successful in 1m24s
2026-09-02 18:54:43 +00:00
claudeandClaude Opus 5 5abd0a9968 docs(ci): name the timing fields this forge actually returns
CI / shellcheck + selftests (pull_request) Successful in 1m30s
The accepted-cost derivation cited `run_started_at` and `updated_at`, which are
GitHub's field names. This Gitea's runs payload has neither -- it returns
`started_at` and `completed_at`, and omits `run_started_at`, `updated_at` and
`created_at` entirely (confirmed by dumping the keys of a run object). The
figures are unaffected: the script that produced them fell through to the real
fields, so 83 s median over 77-106 s stands.

The derivation was named so a reader could re-take the measurement, and as
written it returned nothing when followed literally, which defeats the only
reason it was there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjbhSqQf3pwnPA6MVaWcWL
2026-09-02 13:03:45 -05:00
claudeandClaude Opus 5 8217f53d4a docs(ci): measure the accepted cost instead of comparing it unmeasured
CI / shellcheck + selftests (pull_request) Successful in 1m21s
The accepted-cost paragraph claimed this repo's job made the `edited` trade
worse than in the sibling repos that shipped it first, reasoning from the
toolchain install, the Cargo-driving suites and `timeout-minutes: 20`. The
measurement inverts it: last twelve non-skipped runs here are 83 s median
(77-106), against ~118 s for daniel/gitdan and ~330 s for daniel/emowheel --
this is the cheapest of the three, and 20 minutes is a hang ceiling, not a
duration. This PR's own runs measured 87 s and 90 s.

The comparison is dropped rather than re-pointed; the absolute figure replaces
it, with the derivation named (`run_started_at` to `updated_at` off the Actions
API) so a reader can re-take it. The neighbouring concurrency claim was read
off this repo's own file and is unchanged -- it was the checked half of a
paragraph whose other half was not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjbhSqQf3pwnPA6MVaWcWL
2026-09-02 12:55:24 -05:00
claudeandClaude Opus 5 5b6acd7b3b docs(ci): date the cancellation observation to 1.26.0, not the current version
CI / shellcheck + selftests (pull_request) Successful in 1m30s
The concurrency note said "this Gitea (1.26.0)" while `GET /version` now
returns 1.27.2 — the instance was upgraded on 2026-08-25/26 (daniel/gitdan's
runbook, gitdan#46). Swapping the number would have asserted the cancellation
was observed on 1.27.2, which nobody has checked: the runs it cites were seen
before the upgrade. The comment now dates the observation to 1.26.0, names the
current version, and says persistence is unverified — which is why every commit
gets its own group rather than trusting `cancel-in-progress`. That reasoning is
unchanged; only the implied currency was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjbhSqQf3pwnPA6MVaWcWL
2026-09-02 12:49:56 -05:00
claudeandClaude Opus 5 b791896e03 fix(ci): trigger on edited so un-drafting actually lifts the draft skip
CI / shellcheck + selftests (pull_request) Successful in 1m27s
`ready_for_review` does not exist as a pull_request action on this Gitea, so
it never fired and the draft skip never lifted: a PR opened as `WIP:` carried
its skip decision to merge unless a later push happened to create a run. Draft
here is not a persisted column — it is the `WIP:` title prefix, derived by
`issue.IsWorkInProgress` — so un-drafting is a title edit, which fires a plain
`edited`.

Ported from daniel/emowheel commit 08da820 (see daniel/emowheel#71) and
daniel/gitdan (see daniel/gitdan#92), where the identical change landed first.

Accepted cost: Gitea populates no `changes` field for a title-or-body edit, so
the workflow cannot tell an un-drafting edit from an ordinary body PATCH, and
every body edit on a non-draft PR now starts a real run. That is a heavier
trade here than in the sibling repos -- this job installs two Rust toolchains
and drives a real Cargo, at `timeout-minutes: 20` -- but the `concurrency:`
block groups `pull_request` runs on `github.ref` with `cancel-in-progress:
true`, so a burst of edits collapses to one run. Still-draft PRs are unchanged.

Docs: the workflow comment is rewritten and shortened, and README's Development
section retires the empty-commit workaround it prescribed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjbhSqQf3pwnPA6MVaWcWL
2026-09-02 12:34:24 -05:00
claude 14bea98626 Merge pull request 'fix(hardlink): name the mutable set directly, under both build-dir layouts' (#16) from fix/layout-v2-selection into main
CI / shellcheck + selftests (push) Successful in 1m46s
2026-08-27 20:03:40 +00:00
claude 4bb880b7a7 chore(ci): trigger CI after un-WIP
CI / shellcheck + selftests (pull_request) Successful in 1m21s
2026-08-27 14:52:11 -05:00
claude fb3c72aa28 docs(hardlink): withdraw the nlink claim, which asserted more than was measured
CI / shellcheck + selftests (pull_request) Skipped
The comments said hardlink count at link time was ruled out as the
discriminator between a rewritten executable and an intact one, citing a
lib+bin crate whose two test binaries were both `nlink == 1` and appeared to
behave differently. Re-checked on review: the intact one had not been rebuilt
at all — same content, same inode — so it demonstrated nothing, and forcing
both to rebuild rewrote both.

What was actually observed is narrower and now says so: every executable
measured intact had an uplift hardlink twin Cargo must re-create anyway, every
one measured rewritten had none, and whether the twin is the mechanism or a
correlate was not determined. The rule does not rest on the answer — exempting
twinned executables would recover none of the bytes this change newly copies.

This PR exists because a claim outlived its evidence; it should not ship one.
2026-08-27 14:50:53 -05:00
claude 0553a6b956 fix(hardlink): resolve an ambiguous out/ toward unsharing, and pin the linked-output scenario on a shape that exhibits it
CI / shellcheck + selftests (pull_request) Skipped
Two review findings on #16.

The `out/` discriminator keyed on Cargo's record of a build-script execution,
which Cargo writes only AFTER the script exits successfully. A build script
that populates OUT_DIR and then fails leaves a unit with no record at all, so
its OUT_DIR read as a compile unit's artifact directory and stayed shared —
a regression against the old `-name build` selection, which real-copied that
state by construction. Reproduced on cargo 1.93.1 stable.

An `out` directory now stays shared only when two independent signals agree:
it holds an `.rlib`/`.rmeta` of its own, and its unit carries no execution
record. Either one missing real-copies it. The cost is unchanged to the byte —
the newly-unshared directories hold only executables and `*.d`, both already
privately owned by the file rules.

The live linked-test-binary scenario was built on the lib+bin probe crate,
whose test binaries relink to a fresh inode — a shape gitdan-actions#17 records
as measured safe. Both halves passed green against the unfixed selection on
dep-info mutations the previous scenario already covers. It now builds a
bin-only crate with a unit test, reads only executables, and skips loudly with
a warning rather than passing quietly if the toolchain does not exhibit the
rewrite at all.

Also: drop a clause asserting the linker writes in place "whenever the path has
no other hard link", which this change's own evidence denies; move the
load-bearing comment block back above `unshare_mutable_paths`; correct a
superseded 99.998% figure; and record both cost rows in the README rather than
only the flattering whole-tree one.
2026-08-27 14:39:29 -05:00
claude e5b26a9368 fix(hardlink): name the mutable set directly, under both build-dir layouts
CI / shellcheck + selftests (pull_request) Skipped
`unshare_mutable_paths` selected `.fingerprint` and `build` directories. Under
Cargo's build-dir layout v2 the first clause matches nothing and the second
matches the whole tree, because v2 regroups artifacts under `build/` alongside
the metadata. Measured on one scratch crate: 39.3% of the tree real-copied
under v1, 99.996% under v2.

The selection now names the mutable set rather than the container it used to
live in: fingerprint directories under either spelling, layout v2's `run/`
directories, layout v1's loose build-script run metadata, and the `out`
directories that are a build script's OUT_DIR rather than a compile unit's
artifact directory. The two are told apart structurally, by Cargo's record of
the build-script execution sitting beside the OUT_DIR and nowhere else.

Verifying that turned up a second, layout-independent hazard: a linked
executable is written through whatever inode is already at its path, so a
`cargo test --no-run` inside a `cp -al` clone rewrites the source's own test
binary. Reproduced on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly and
1.100.0-nightly, under both layouts. Every executable is now real-copied;
`.rlib`, `.rmeta` and `incremental/` are what stay shared.

hardlink-clone-selftest.sh gains two file-only layout fixtures that pin the
partition in both directions without a compiler, and a live scenario that
relinks a test binary.
2026-08-27 14:12:01 -05:00
claude 4114996954 Merge pull request 'fix(hardlink): content freshness moved switches, it was not withdrawn' (#15) from chore/checksum-freshness into main
CI / shellcheck + selftests (push) Successful in 1m19s
2026-08-27 03:20:03 +00:00
9 changed files with 1332 additions and 150 deletions
+14 -14
View File
@@ -5,27 +5,27 @@ on:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
# Spelled out only to keep `ready_for_review` in the list — naming any type # Spelled out only to keep `edited` in the list — naming any type replaces
# replaces the whole default set, so the other three have to be restated. # the whole default set, so the other three have to be restated.
# It is inert on this instance (draft state here is the `WIP:` title
# prefix, so un-drafting is a title edit and raises no
# `ready_for_review` action) and costs nothing.
# #
# The consequence, which is the part that bites: the `if:` guard below is # `edited`, not `ready_for_review`: draft here is the `WIP:` title prefix,
# evaluated when a run is CREATED, and un-drafting creates no run. A PR # so un-drafting is a title edit and no ready-for-review action is ever
# opened as a draft keeps its skip decision until something else produces # raised. That edit is what creates the run which lifts the `if:` skip
# one. Push an empty commit after un-WIP'ing. # below (decided once, when a run is CREATED). Do not swap it back and do
types: [opened, synchronize, reopened, ready_for_review] # not drop this to the bare default — either restores the bug. The accepted
# cost and the evidence are in README's Development section.
types: [opened, synchronize, reopened, edited]
# gitdan-ci runs four repos' CI on two capacity slots, and the compiler-backed # gitdan-ci runs four repos' CI on two capacity slots, and the compiler-backed
# suites below are multi-minute. A superseded run costs a slot in front of # suites below are multi-minute. A superseded run costs a slot in front of
# somebody's build, so drop it. # somebody's build, so drop it.
# #
# `push` groups on `github.sha` rather than `github.ref`: a constant per-branch # `push` groups on `github.sha` rather than `github.ref`: a constant per-branch
# group is what let this Gitea (1.26.0) cancel two of daniel/gitdan's merge # group is what let this Gitea cancel two of daniel/gitdan's merge runs
# runs outright while `cancel-in-progress` was gated away from `push` entirely # outright while `cancel-in-progress` was gated away from `push` entirely
# see the long note in that repo's ci.yaml for the evidence. Giving every # see the long note in that repo's ci.yaml. Observed on 1.26.0; the instance
# commit its own group leaves that behaviour nothing to act on. # is 1.27.2 now and whether it persists is unverified, which is why every
# commit gets its own group instead of trusting the flag.
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }} group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: true cancel-in-progress: true
+166 -32
View File
@@ -29,7 +29,7 @@ on the runner having a single execution slot.
One thing neither project had, and the reason the clone is not a plain One thing neither project had, and the reason the clone is not a plain
`cp -al`: **a build inside a hardlink clone does mutate the directory it was `cp -al`: **a build inside a hardlink clone does mutate the directory it was
cloned from.** Cargo writes real artifacts by replacing them, but writes its cloned from.** rustc renames its own outputs into place, but Cargo writes its
metadata — and build scripts write their `OUT_DIR` — with a plain truncating metadata — and build scripts write their `OUT_DIR` — with a plain truncating
write, straight through the shared inode. When Cargo resolves freshness by write, straight through the shared inode. When Cargo resolves freshness by
content the file that gets corrupted is its dep-info fingerprint — content the file that gets corrupted is its dep-info fingerprint —
@@ -37,17 +37,55 @@ content the file that gets corrupted is its dep-info fingerprint —
`build/<pkg>/<hash>/fingerprint/dep-<target>` under Cargo's build-dir layout v2 `build/<pkg>/<hash>/fingerprint/dep-<target>` under Cargo's build-dir layout v2
— which holds the per-source checksums that decide freshness, and the failure — which holds the per-source checksums that decide freshness, and the failure
is silent stale-artifact reuse rather than a slow build. is silent stale-artifact reuse rather than a slow build.
`scripts/hardlink-clone-selftest.sh` reproduces it as an explicit control and
asserts the fix. The fix is to hardlink the artifacts (the GB) and real-copy
the metadata (the MB) — about 3.7% of a Bevy-sized target directory, against
100% for a full copy.
That ratio is a **layout-v1** figure and does not survive layout v2, which One more family joins them, and it is not metadata: **anything the linker
regroups artifacts under `build/` alongside the metadata and so drags nearly writes**. rustc writes an `.rlib` or `.rmeta` to a temporary and renames it
the whole tree into the real-copy set. Measured 2026-08-26 at 99.998% on a into place, but a linked executable is written through whatever inode is
scratch crate. v2 is the nightly default and stabilises in cargo 1.100.0 on already at its path — so a `cargo test --no-run` in a raw `cp -al` clone
2026-11-12; the correctness guarantee above is unaffected, the saving is not. rewrites the source's own test binary. Measured 2026-08-27 on cargo 1.93.1
Tracked as [#14](https://gitdan.com/daniel/gitdan-actions/issues/14). stable, 1.96.0-nightly, 1.98.0-nightly and 1.100.0-nightly, under both
build-dir layouts.
`scripts/hardlink-clone-selftest.sh` reproduces both as explicit controls and
asserts the fix. The fix is to hardlink what rustc renames into place — the
`.rlib`, `.rmeta` and `incremental/` bulk — and real-copy the metadata and the
linker outputs.
**The selection names that set directly, under either build-dir layout.**
Layout v2 regroups everything per build unit under
`build/<pkg>/<hash>/{fingerprint,out,run}/`, artifacts included, so there is no
`.fingerprint` and no `deps` to key off and `build/` is no longer a proxy for
"metadata" — it is the whole tree. The one place the two layouts genuinely
differ is that under v2 a build script's `OUT_DIR` and a compile unit's rlib
are both a directory called `out`.
**Ambiguity there resolves toward unsharing**, because over-unsharing costs
bytes and under-unsharing costs corruption. An `out` directory stays shared
only when two independent signals agree it is a compile unit's: it holds an
`.rlib`/`.rmeta` of its own, *and* its unit carries no record of a build-script
execution beside it (`run/` under v2, a loose `root-output` under v1). The
execution record alone is not enough — Cargo writes it only after the script
succeeds, so a build script that populates `OUT_DIR` and then fails leaves a
unit that reads as a compile unit. v2 is the nightly default and stabilises in
cargo 1.100.0 on 2026-11-12.
### What it costs
Real-copied share of a 5.5 GB Bevy target directory, before and after the
linker-output rule landed:
| tree | before | after |
|---|---|---|
| **excluding `incremental/`** — the figure to plan against, since the quick-start below sets `CARGO_INCREMENTAL: 0` | **36.4%** | **57.0%** |
| whole tree, `incremental/` included (a local dev checkout, not CI) | 9.0% | 14.1% |
The first row is the one a CI consumer gets. The increase is the linker-output
rule, not the layout work: on a scratch crate the layout fix alone takes v2
from 99.996% to 0.2%.
The copy is paid per clone and does not amortise — a fresh `cp -al` leaves
every file with `nlink >= 2`, so the `-links +1` filter cannot skip anything —
and a clone happens twice per job, once seeding and once publishing.
--- ---
@@ -73,10 +111,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# REQUIRED. The mtime restore walks every commit that ever touched a # REQUIRED, for two things. The mtime restore walks every commit
# tracked file; a depth-1 checkout makes every file resolve to the # that ever touched a tracked file; a depth-1 checkout makes every
# tip commit and the cache stops working. The action fails loudly # file resolve to the tip commit and the cache stops working, and
# rather than silently degrading if this is missing. # the action fails loudly rather than silently degrading. The prune
# also decides whether a branch has been merged by asking this
# checkout for ancestry, which a shallow one cannot answer — there
# it withholds that half of the pass and says so, so merged-but-
# undeleted branches keep their caches.
fetch-depth: 0 fetch-depth: 0
- uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1 - uses: https://gitdan.com/daniel/gitdan-actions/cargo-cache@v1
@@ -225,14 +267,73 @@ not collapsed into one:
disk, normally as the cache restored under its own name and otherwise as one disk, normally as the cache restored under its own name and otherwise as one
set aside for a later pass to reclaim. set aside for a later pass to reclaim.
**Eviction** runs three passes: caches for branches that no longer exist on **Eviction** runs three passes, ahead of the seed so that what it frees is
origin are removed unconditionally; then, only if free space is under the available to the clone that follows. Caches for branches that are DEAD are
threshold, live caches are evicted oldest-first; then, as a last resort, this removed unconditionally; then, only if free space is under the requirement,
run's own cache. Protected refs and any cache held open by a running job are live caches are evicted oldest-first; then, as a last resort, this run's own
never candidates. Within the pressure pass, `target-*` directories are evicted cache. Protected refs, the source this run is about to clone, and any cache
before `snapshot-*` ones — the reverse of the obvious order, because a held open by a running job are never candidates. Within the pressure pass,
snapshot is hardlinked to everything cloned from it, so removing one frees `target-*` directories are evicted before `snapshot-*` ones — the reverse of
almost no real bytes while costing every future PR its warm start. 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.
**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
ancestry: a branch still on origin whose tip is an ancestor of a protected
branch's tip holds no commit that branch does not, so its cache will never be
read again and goes in the same pass.
**Turn delete-on-merge on** (`default_delete_branch_after_merge`, per repo) —
it is the setting this scheme is cheapest under, because a deleted branch is
decidable from `ls-remote` alone, with no checkout, no objects and no walk.
The ancestry signal is what covers the rest, and the rest is not a corner:
- **Every branch merged before the setting was turned on.** They stay on
origin forever; nothing retroactively deletes them. zemyna carried 48 of
them at the time the setting was enabled, and ancestry is the only thing
that reclaims a cache dir belonging to any of them.
- **Every merge the deletion declines or fails.** Gitea's delete is
best-effort and silent: it declines for a protected branch and for one
another open PR still uses, and an API merge that omits the flag — which
`tea pulls merge` does — simply never asks.
- **Repos that have not enabled it**, which is the default.
That is the difference between reclaiming nothing and reclaiming a 40 GB
directory per merged PR on a full volume (issue 20).
Ancestry is answered from the commits in the job's own checkout, so it is only
answered where they are there to answer it — and "cannot tell" is never folded
into "dead", at either granularity. A shallow checkout withholds the signal
entirely, because a missing object is its normal case rather than evidence; a
single branch whose tip is not in the checkout is kept, with a warning naming
it. A squash or rebase merge leaves no ancestry at all, so its branch reads as
live until it is deleted. All three are missed reclamations, which cost disk;
the alternative direction costs a branch its cache while it is still being
built on.
**How much free space is enough is measured, not chosen.** What the seed is
about to do is hardlink-clone a snapshot and then real-copy that clone's
*mutable set* — the dep-info, build-script metadata and linked outputs that a
build would otherwise write through a shared inode. The rest stays hardlinked
and costs nothing. So the requirement is derived per run, from that set,
measured off the very snapshot the seed will read using the same enumeration
`unshare_mutable_paths` copies from; the pass evicts oldest-first until it is
met and then stops. A percentage of the volume cannot express this: how much a
clone needs is a property of the snapshot, and a threshold sized for a
different failure is one that never fires before the seed refuses. `cp -al`
still materialises every directory for real, and the unshare stages each
subtree through a sibling copy, so the measurement carries a margin —
`CACHE_CLONE_HEADROOM_PERCENT` and `CACHE_CLONE_HEADROOM_FLOOR_KB`, both
hand-written defaults, both erring toward asking for more.
A run that cannot reach the derived requirement after evicting everything
eligible **fails, naming the shortfall and every directory it kept instead**.
The seed would otherwise fail seconds later, reporting a staging path and
nothing about which cache was holding the space — which is the failure this
pass now pre-empts. `min-free-percent` is an additional floor on top and
nothing more: it defaults to `0`, it only ever raises the requirement, and
falling short of it is still a warning and a self-clear rather than a failure.
**File mtimes.** `actions/checkout` stamps every file with "now", which makes **File mtimes.** `actions/checkout` stamps every file with "now", which makes
every crate look changed to Cargo's mtime-based freshness check — a persistent every crate look changed to Cargo's mtime-based freshness check — a persistent
@@ -374,10 +475,10 @@ 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 and are never evicted |
| `min-free-percent` | `10` | prune when free space drops below this | | `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 | | `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |
| `liveness-prune` | `true` | within eviction, remove caches for branches gone from origin | | `liveness-prune` | `true` | within eviction, remove caches for branches that are dead: gone from origin, or merged into a protected branch |
| `own-ref` | *(auto)* | override; defaults to `github.head_ref`, else `github.ref_name` | | `own-ref` | *(auto)* | override; defaults to `github.head_ref`, else `github.ref_name` |
| `base-ref` | *(auto)* | override; defaults to `github.base_ref` (empty on push) | | `base-ref` | *(auto)* | override; defaults to `github.base_ref` (empty on push) |
| `seed-fallback-dir` | *(empty)* | absolute path to seed from when no snapshot exists — for migrating off an existing flat cache | | `seed-fallback-dir` | *(empty)* | absolute path to seed from when no snapshot exists — for migrating off an existing flat cache |
@@ -567,10 +668,43 @@ cannot measure) and references no credentials; the scratch workspaces the
compiler-backed suites build use path dependencies only, so nothing reaches compiler-backed suites build use path dependencies only, so nothing reaches
crates.io. It runs the full suite rather than `--fast`, crates.io. It runs the full suite rather than `--fast`,
because the two compiler-backed suites are the ones that check this scheme because the two compiler-backed suites are the ones that check this scheme
against real Cargo instead of against a fixture. Draft (`WIP:`-titled) PRs against real Cargo instead of against a fixture.
skip it, and un-drafting does **not** un-skip them — the guard is evaluated
when a run is created and un-drafting creates none, so push an empty commit **Draft (`WIP:`-titled) PRs skip it; un-drafting un-skips them, through
after un-WIP'ing. `edited`** — no empty commit needed. The skip is decided when a run is
*created*, so lifting it needs an event that creates one, and un-drafting on
this Gitea is a title edit: `edited` is in the workflow's `pull_request` types
for exactly that reason. `ready_for_review` held that slot first and never
fired — this Gitea has no draft column and no ready-for-review event at all,
draft being computed from the title prefix — so a PR opened as `WIP:` carried
its skip decision all the way to merge unless some later push happened to
create a run. Do not swap the type back and do not drop the `types:` list to
its bare default; either restores the bug.
**Accepted cost: a body edit on an already-non-draft PR now triggers a real
run.** Gitea populates no `changes` field for a title-or-body edit, unlike
GitHub, so the workflow cannot tell the edit that un-drafts a PR from an
ordinary body PATCH — a closing-reference fix-up, say. The price is around
**90 seconds** of a runner shared across four repos on two capacity slots: the
last twelve non-skipped runs of this job, `started_at` to `completed_at` off
the Actions API, are 83 s median over 77106 s. `timeout-minutes: 20` is a
ceiling for a hung suite, not a duration. It is also bounded: the workflow's
`concurrency:` block groups `pull_request` runs on `github.ref` with
`cancel-in-progress: true`, so a burst of edits collapses to one run rather
than N. A still-draft PR pays nothing extra — the `if:` guard skips those
exactly as before.
The evidence for `edited` comes from `daniel/emowheel`, which hit the identical
bug, shipped the identical wrong fix, and corrected it in commit `08da820` (see
`daniel/emowheel#71`): Gitea 1.27.2's `HookIssueAction` enum has no
`ready_for_review` entry and no notifier emits one, while
`issue.IsWorkInProgress` derives draft from the title. Live since: emowheel PR
#141 was un-drafted at 19:01:13 on 2026-08-31 and run 2801 was created two
seconds later on the **same** head SHA as the two runs skipped before it — a
run created by the title edit alone, with no push, that executed and passed.
`daniel/gitdan` ported the same one-word change. That it behaves the same way
*here* has not been demonstrated in this repo; the next `WIP:` PR opened
against `main` is the test.
This repo is consumed by three other repos' CI at `@v1`, a moving tag, so a This repo is consumed by three other repos' CI at `@v1`, a moving tag, so a
change here reaches all of them at once. That is what the gate is for. change here reaches all of them at once. That is what the gate is for.
@@ -578,10 +712,10 @@ change here reaches all of them at once. That is what the gate is for.
| suite | covers | | suite | covers |
|---|---| |---|---|
| `cache-root-selftest.sh` | that a lineage nests one level and nothing else moves: no lineage resolves byte-for-byte to the cache root, two lineages on one cache key get disjoint target dirs, seed/publish/prune all stay inside their own lineage, a PR layers over its own lineage's base snapshot — **and one rejection per lineage name a reader elsewhere would stop seeing**, plus the publish-side mismatch guard | | `cache-root-selftest.sh` | that a lineage nests one level and nothing else moves: no lineage resolves byte-for-byte to the cache root, two lineages on one cache key get disjoint target dirs, seed/publish/prune all stay inside their own lineage, a PR layers over its own lineage's base snapshot — **and one rejection per lineage name a reader elsewhere would stop seeing**, plus the publish-side mismatch guard |
| `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler, **and a nightly that actually resolves freshness by content for its last scenario**: the source's-next-build check reasons about content rather than mtime, so under mtime freshness it would assert a bug. Whether the toolchain does is settled by experiment on a throwaway crate, not by asking it — accepting `-Z checksum-freshness` stopped implying it on 2026-08-22, when cargo PR #17382 demoted the flag to a gate and gave `build.fingerprint` (default `mtime`) the choice; the suite now exports both and 1.100.0-nightly measures ACTIVE again. The experiment reports **three** outcomes, not two: active, measured-inactive, and *not measured*. Its answer codes are `0` and `3`, deliberately clear of every status bash generates for its own errors — so nothing that goes wrong inside the probe, including an expansion failure no guard can catch, can be read as an answer. The scenario is skipped for the last two alike, but a failure to measure is never reported as a measurement. The control also reports which mutation families the running Cargo exhibits — a note, not an assertion, since that set moves upstream. | | `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler, **and a nightly that actually resolves freshness by content for its last scenario**: the source's-next-build check reasons about content rather than mtime, so under mtime freshness it would assert a bug. Whether the toolchain does is settled by experiment on a throwaway crate, not by asking it — accepting `-Z checksum-freshness` stopped implying it on 2026-08-22, when cargo PR #17382 demoted the flag to a gate and gave `build.fingerprint` (default `mtime`) the choice; the suite now exports both and 1.100.0-nightly measures ACTIVE again. The experiment reports **three** outcomes, not two: active, measured-inactive, and *not measured*. Its answer codes are `0` and `3`, deliberately clear of every status bash generates for its own errors — so nothing that goes wrong inside the probe, including an expansion failure no guard can catch, can be read as an answer. The scenario is skipped for the last two alike, but a failure to measure is never reported as a measurement. The control also reports which mutation families the running Cargo exhibits — a note, not an assertion, since that set moves upstream. It also pins the SELECTION itself against both of Cargo's build-dir layouts, from two file-only fixtures that need no compiler — so the layout the installed Cargo does not happen to write is still covered — and asserts the partition in both directions: every file Cargo rewrites in place is privately owned, and every `.rlib`/`.rmeta` still shares its inode. The second half is what the old suite never checked beyond `shared > 0`, and it is what a layout change silently inverts. |
| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot | | `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and one scenario per check a hardlink clone is validated against**: a source rotated wholesale, a subtree silently lost from the walk, a copy that reports failure over a tree both other checks read as whole, and a source identity that resolved at neither end — plus a staging tree that could not be privately owned being discarded rather than published, and the publisher's log showing it waited on the consumer's own reader-lock marker before reclaiming a rotated snapshot |
| `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone | | `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone |
| `prune-cache-selftest.sh` | liveness, protection, locking, eviction order, self-clear, **and that a cache a job claims *inside* the check-to-unlink window survives it** — against a real scratch `origin` | | `prune-cache-selftest.sh` | liveness in both its forms — a branch deleted from origin, and one still on it whose tip is already merged — plus protection, locking, eviction order, self-clear, **that a cache a job claims *inside* the check-to-unlink window survives it**, and that a requirement derived from the clone's mutable set evicts exactly enough and then fails rather than under-delivering. Against a real scratch `origin`, including a genuinely shallow clone of it and a `df` that answers from the fixture's own size, since a fixed one cannot show a pass stopping |
| `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. | | `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. |
Every suite runs the actual script, not a reimplementation of its logic, and Every suite runs the actual script, not a reimplementation of its logic, and
+54 -19
View File
@@ -29,9 +29,18 @@ inputs:
required: false required: false
default: 'dev main' default: 'dev main'
min-free-percent: min-free-percent:
description: 'Prune when free space on the cache volume drops below this percentage.' description: >-
An ADDITIONAL free-space floor, as a percentage of the cache volume.
The prune's own requirement is derived per run from what the seed is
about to clone — the mutable set it has to real-copy out of the source
snapshot, which is a property of that snapshot and not of the volume —
and this floor only ever raises it. 0, the default, leaves the derived
requirement as the only gate. Set it to keep headroom for something
other than the clone (the build's own output, another job on the same
volume); it will not make the clone fit, because it does not know how
big the clone is.
required: false required: false
default: '10' default: '0'
restore-mtimes: restore-mtimes:
description: >- description: >-
Restore every tracked file's mtime from git history. Requires a Restore every tracked file's mtime from git history. Requires a
@@ -40,13 +49,20 @@ inputs:
required: false required: false
default: 'true' default: 'true'
prune: prune:
description: 'Run the eviction pass (dead-branch liveness + disk pressure).' description: >-
Run the eviction pass (dead-branch liveness + disk pressure). It runs
BEFORE the seed step, so what it frees is available to the clone that
step makes.
required: false required: false
default: 'true' default: 'true'
liveness-prune: liveness-prune:
description: >- description: >-
Within the prune pass, remove caches for branches that no longer exist Within the prune pass, remove caches for branches that are dead: gone
on origin. Set to false on a runner that cannot reach origin. from origin, or still on origin with a tip already merged into a
protected branch. The second signal is what reclaims anything at all on
a forge that keeps branches after merge, and it needs the protected
branches' commits in the checkout — a shallow one withholds it and says
so. Set to false on a runner that cannot reach origin.
required: false required: false
default: 'true' default: 'true'
own-ref: own-ref:
@@ -169,6 +185,39 @@ runs:
echo "CI_WATERMARK_FILE=${WATERMARK}" echo "CI_WATERMARK_FILE=${WATERMARK}"
} >> "$GITHUB_ENV" } >> "$GITHUB_ENV"
# Runs BEFORE the seed, which is the only order in which its work can
# help: the eviction it performs is what makes room for the clone the seed
# step is about to make, and the requirement it evicts against is measured
# off the snapshot that clone will read. Running afterwards — where this
# step used to be — meant every run freed space for the NEXT one and the
# seed met whatever the last run happened to leave.
#
# Two things this ordering has to be safe against, and is:
#
# The source it is about to read is excluded from every pass by name
# (see protected_reason in prune-cache.sh), so pass 1 cannot take the
# snapshot out from under the seed that follows it.
# A concurrent job's target dir carries its lock from the instant it
# appears under its final name — the seed writes it into the staging
# tree before the rename — so there is no window in which running this
# earlier sees an unlocked directory somebody is using.
- if: ${{ inputs.prune == 'true' }}
shell: bash
env:
STALE_LOCK_SECONDS: ${{ inputs.stale-lock-seconds }}
CACHE_LIVENESS: ${{ inputs.liveness-prune }}
run: |
set -euo pipefail
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
bash "${SCRIPTS}/prune-cache.sh" \
"${{ steps.resolve.outputs.cache-root }}" \
"${{ steps.resolve.outputs.target-dir }}" \
"${{ inputs.protected-branches }}" \
"${{ inputs.min-free-percent }}" \
"${{ steps.resolve.outputs.cache-key }}" \
"${{ steps.resolve.outputs.base-key }}" \
"${{ inputs.seed-fallback-dir }}"
# Seeds this ref's target dir from the base's published snapshot. See # Seeds this ref's target dir from the base's published snapshot. See
# scripts/seed-target-dir.sh — the staging-then-atomic-rename is what # scripts/seed-target-dir.sh — the staging-then-atomic-rename is what
# makes concurrent jobs sharing one cache key safe by construction rather # makes concurrent jobs sharing one cache key safe by construction rather
@@ -228,17 +277,3 @@ runs:
CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }} CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }}
CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }} CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }}
run: bash "$(cd "${{ github.action_path }}/.." && pwd)/scripts/restore-mtimes.sh" run: bash "$(cd "${{ github.action_path }}/.." && pwd)/scripts/restore-mtimes.sh"
- if: ${{ inputs.prune == 'true' }}
shell: bash
env:
STALE_LOCK_SECONDS: ${{ inputs.stale-lock-seconds }}
CACHE_LIVENESS: ${{ inputs.liveness-prune }}
run: |
set -euo pipefail
SCRIPTS=$(cd "${{ github.action_path }}/.." && pwd)/scripts
bash "${SCRIPTS}/prune-cache.sh" \
"${{ steps.resolve.outputs.cache-root }}" \
"${{ steps.resolve.outputs.target-dir }}" \
"${{ inputs.protected-branches }}" \
"${{ inputs.min-free-percent }}"
+286 -34
View File
@@ -317,17 +317,153 @@ _unshare_files() {
xargs -0 -r -n 64 bash -c 'rc=0; for f; do cp -p -- "$f" "$f.unshare.$$" && mv -f -- "$f.unshare.$$" "$f" || rc=1; done; exit $rc' _ xargs -0 -r -n 64 bash -c 'rc=0; for f; do cp -p -- "$f" "$f.unshare.$$" && mv -f -- "$f.unshare.$$" "$f" || rc=1; done; exit $rc' _
} }
# True when a directory holds a compiled library artifact of its own.
#
# The glob is left unquoted and unmatched-glob-safe on purpose: with nullglob
# off an unmatched pattern stays literal and the `-e` test fails, which is the
# answer wanted.
_holds_compiled_artifact() {
local f
for f in "$1"/*.rlib "$1"/*.rmeta; do
[ -e "$f" ] && return 0
done
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
# selected here and the caller never unshares a subtree twice.
#
# `out` is the one that needs deciding rather than naming, and it is the whole
# difficulty of layout v2: a compile unit's rlib and a build script's OUT_DIR
# are both a directory called `out`, one directory apart, and they need
# opposite treatment.
#
# AMBIGUITY RESOLVES TOWARD UNSHARING, and that direction is the rule rather
# than a default: over-unsharing costs bytes, under-unsharing costs corruption.
# So an `out` directory is left shared only when TWO independent signals agree
# it is a compile unit's artifact directory, and either one missing is enough
# to real-copy it:
#
# 1. it holds an `.rlib`/`.rmeta` of its own — the artifact whose sharing is
# the entire point of the clone; and
# 2. its unit directory has no record of a build-script execution beside it
# (`run/` under layout v2, a loose `root-output` under v1).
#
# Signal 2 alone was the first cut of this and it is NOT sufficient, because
# Cargo writes `root-output` only AFTER the script exits successfully. A build
# script that populates `OUT_DIR` and then FAILS leaves a unit with no record
# at all, which reads as "compile unit" — and the old `-name build` selection
# covered that state by real-copying `build/` wholesale, so trusting signal 2
# alone was a regression against it. Reproduced on cargo 1.93.1 stable: the
# clone's build wrote through the shared inode into the source's OUT_DIR.
# Signal 1 closes it, because a failed build script's OUT_DIR holds no rlib.
#
# The residual is a build script that writes a file NAMED `*.rlib`/`*.rmeta`
# into `OUT_DIR` and has never once succeeded. Nothing bounds that away; it is
# simply far narrower than what it replaces.
#
# Requiring signal 1 also means a bin, test or build-script COMPILE unit's
# `out` is real-copied rather than shared — at no cost in bytes, since
# everything in one is an executable or a `*.d`, and both are privately owned
# by the file rules below either way.
_mutable_dirs() {
local root="$1" d unit
while IFS= read -r d; do
if [ "${d##*/}" = out ]; then
unit="${d%/out}"
if ! [ -d "$unit/run" ] && ! [ -e "$unit/root-output" ] \
&& _holds_compiled_artifact "$d"; then
continue
fi
fi
printf '%s\n' "$d"
done < <(find "$root" -type d \
\( "${_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. # 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
# lands on a NEW inode, leaving the source's data untouched. That is true for # lands on a NEW inode, leaving the source's data untouched. That is true of
# compilation artifacts — rustc and the linker replace `deps/*.rlib`, # rustc's own outputs — it writes an `.rlib` or `.rmeta` to a temporary and
# `*.rmeta`, and binaries rather than truncating them in place — and it is # renames it into place — and it is NOT true of the metadata Cargo and build
# NOT true for the metadata Cargo and build scripts write with a plain # scripts write with a plain truncating write, nor of anything the LINKER
# truncating write. Measured directly (Linux, ext4, cargo 1.9x nightly: # produces. Measured directly (Linux, ext4: `cp -al` a warm target dir, change
# `cp -al` a warm target dir, change a source file, build in the clone, diff # a source file, build in the clone, diff the source) the following files in
# the source) the following files in the SOURCE were mutated through the # the SOURCE were mutated through the shared inode:
# shared inode:
# #
# <profile>/.fingerprint/<unit>/dep-<target> (build-dir layout v1) — or, # <profile>/.fingerprint/<unit>/dep-<target> (build-dir layout v1) — or,
# <profile>/build/<pkg>/<hash>/fingerprint/dep-<target> # <profile>/build/<pkg>/<hash>/fingerprint/dep-<target>
@@ -347,6 +483,20 @@ _unshare_files() {
# plain fs::write) # plain fs::write)
# <profile>/deps/*.d, <profile>/*.d (Cargo's post-processed # <profile>/deps/*.d, <profile>/*.d (Cargo's post-processed
# dep-info) # dep-info)
# <profile>/deps/<test>-<hash> (a linked TEST binary; under
# layout v2,
# `build/<pkg>/<hash>/out/`)
#
# THE LINKED-OUTPUT CASE IS NOT LAYOUT-SPECIFIC AND WAS NOT PART OF THIS
# FUNCTION UNTIL 2026-08-27 (gitdan-actions#14). A `cargo test --no-run` inside
# a raw `cp -al` clone rewrote the source's own test binary in place on cargo
# 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly (layout v1) and 1.100.0-nightly
# (layout v2) alike. Cargo re-creates the path first when it also has to uplift
# the result — a bin target's `deps/<bin>-<hash>` has a hardlink twin at
# `<profile>/<bin>` — and other crate shapes relinked to a fresh inode for
# reasons this measurement did not pin down. Since the safe cases could not be
# enumerated, every executable is treated as mutable; `.rlib`, `.rmeta` and
# `incremental/` are what stay shared, and they are the bytes worth sharing.
# #
# The checksum-freshness case is not a cosmetic one. Reproduced end to end: # The checksum-freshness case is not a cosmetic one. Reproduced end to end:
# branch B clones base's cache, builds its own content, and thereby rewrites # branch B clones base's cache, builds its own content, and thereby rewrites
@@ -390,25 +540,20 @@ _unshare_files() {
# included. Bracketed locally: cargo 1.97.1 and 1.98.0-nightly write v1, # included. Bracketed locally: cargo 1.97.1 and 1.98.0-nightly write v1,
# 1.100.0-nightly writes v2. # 1.100.0-nightly writes v2.
# #
# The `-name .fingerprint` clause below therefore matches nothing under a v2 # Until 2026-08-27 the selection was `-name .fingerprint -o -name build`,
# Cargo, and the dep-info file is covered only because the `-name build` clause # which under a v2 Cargo matched nothing on its first clause and the entire
# happens to swallow its new home. That is belt-and-braces by accident, not by # tree on its second, because the artifacts moved under `build/` too. The guard
# design — and the same accident makes this function real-copy essentially the # held by accident and the saving did not: on one scratch crate (serde +
# whole tree, because the artifacts moved under `build/` too. Measured on one # serde_json + regex plus a build script), same sources both ways —
# scratch crate (serde + serde_json + regex), same sources both ways:
# #
# cargo 1.97.1 (layout v1) 27.0 MB unshared of 126.7 MB 21.3% # cargo 1.98.0-nightly (layout v1) 64.9 MB unshared of 165.1 MB — 39.3%
# 1.100.0-nightly (layout v2) 105.9 MB unshared of 105.9 MB — 99.998% # 1.100.0-nightly (layout v2) 110.4 MB unshared of 110.4 MB — 99.996%
# #
# So the guard still holds and the saving does not. Deliberately NOT fixed # The selection now names the mutable set directly rather than by the container
# here: adjusting the selection is a change to what gets hardlinked on every # it used to live in, so it holds under both layouts; the same crate measures
# consumer, which wants its own change and its own review, and the deadline is # 45.3% (v1) and 38.4% (v2), both dominated by the linked-output rule above
# cargo 1.100.0 stable on 2026-11-12. Tracked as gitdan-actions#14. # rather than by the layout. On a real 5.5 GB Bevy target dir the whole change
# # moves the real-copied share from 9.0% to 14.1%.
# The historical v1 figure this block used to quote stands as measured: on a
# 6.9 GB Bevy workspace target dir the unshared set was .fingerprint 22 MB +
# build/ 237 MB + a handful of dep-info files — about 3.7% of the tree, against
# 100% for a plain `cp -a`. It describes layout v1 only.
# #
# `incremental/` is deliberately left shared: rustc writes each incremental # `incremental/` is deliberately left shared: rustc writes each incremental
# session to a fresh `s-*-working` directory and finalises it with a rename, # session to a fresh `s-*-working` directory and finalises it with a rename,
@@ -418,12 +563,12 @@ _unshare_files() {
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. `-prune` keeps a # a tree being mutated underneath it is a needless hazard.
# match's own contents out of the list.
local -a dirs=() local -a dirs=()
mapfile -t dirs < <(find "$root" -type d \( -name .fingerprint -o -name build \) -prune -print 2>/dev/null) mapfile -t dirs < <(_mutable_dirs "$root")
for d in "${dirs[@]}"; do for d in "${dirs[@]}"; do
[ -n "$d" ] || continue [ -n "$d" ] || continue
unshare_subtree "$d" || { unshare_subtree "$d" || {
@@ -431,14 +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
} }
_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 # 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 return 0
} }
+331 -10
View File
@@ -28,8 +28,6 @@ set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh" . "$script_dir/cache-lib.sh"
command -v cargo >/dev/null || { echo "SKIP: no cargo on PATH"; exit 0; }
scratch=$(mktemp -d) scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT trap 'rm -rf "$scratch"' EXIT
pass_count=0 pass_count=0
@@ -59,6 +57,14 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[workspace] [workspace]
TOML TOML
# A binary as well as a library, because the two are written differently and
# only one of them is safe to share: rustc writes an rlib to a temporary and
# renames it into place, while the LINKER writes an executable through the
# existing inode. Without a bin target this suite never relinks anything and
# cannot see that difference.
cat > "$dir/src/main.rs" <<'RS'
fn main() { println!("{}", probe::f()); }
RS
cat > "$dir/build.rs" <<'RS' cat > "$dir/build.rs" <<'RS'
use std::{env, fs, path::PathBuf}; use std::{env, fs, path::PathBuf};
fn main() { fn main() {
@@ -70,6 +76,219 @@ fn main() {
RS RS
} }
# ---------------------------------------------------------------------------
# Both build-dir layouts, without a compiler
# ---------------------------------------------------------------------------
#
# Every other scenario in this file runs whichever layout the installed Cargo
# happens to write, so on any one machine it exercises exactly ONE of the two —
# and gitdan-ci's is v1. The two fixtures below reproduce both directory shapes
# from files alone, clone them through the real `hardlink_clone_into()`, and
# assert file by file which side of the partition each one lands on.
#
# Shapes taken from a scratch crate (serde + serde_json + regex, plus a build
# script) built on 2026-08-27: cargo 1.98.0-nightly (a335d47ff 2026-06-26)
# writes v1, cargo 1.100.0-nightly (e8cb624d5 2026-08-22) writes v2.
#
# v2 is where the partition is easy to get wrong, and the fixtures are built to
# say so: a build script's OUT_DIR and a compile unit's rlib are BOTH a
# directory called `out`, one directory apart, and they need opposite
# treatment.
mkfile() { mkdir -p "$(dirname "$1")"; printf '%s' "$2" > "$1"; }
# Big enough that the byte-fraction assertion below measures something.
artifact_bytes=$(head -c 4096 /dev/zero | tr '\0' 'A')
# Spec lines are `<shared|private>|<path relative to the tree root>`.
assert_partition() {
local label="$1" src="$2" spec="$3"
local clone="${src}-clone" want rel si ci total=0 copied=0 sz
hardlink_clone_into "$src" "$clone" "selftest-$label" \
|| fail "$label: hardlink_clone_into refused the destination"
while IFS='|' read -r want rel; do
[ -n "${rel:-}" ] || continue
[ -e "$clone/$rel" ] || fail "$label: $rel is missing from the clone"
si=$(stat -c '%i' "$src/$rel"); ci=$(stat -c '%i' "$clone/$rel")
case "$want" in
shared)
[ "$si" = "$ci" ] \
|| fail "$label: $rel was real-copied, but it is an artifact and must stay shared" ;;
private)
[ "$si" != "$ci" ] \
|| fail "$label: $rel still shares an inode with the source, so a build in the clone can rewrite it" ;;
*) fail "$label: unknown spec verb '$want'" ;;
esac
done <<< "$spec"
ok "$label: every file landed on the right side of the partition"
# The cost model, asserted rather than assumed. Selecting too much is not a
# correctness bug, which is exactly why nothing caught layout v2 taking the
# selection from 39.3% of one scratch crate's tree to 99.996% of it
# (gitdan-actions#14):
# a hardlink clone that real-copies everything is a `cp -a` with extra steps.
# The bound is loose on purpose. It is not a budget — the honest figure moves
# with how much of a tree is linker output, and these fixtures are mostly
# that by construction — it is a floor under "still a hardlink clone at all".
while IFS= read -r rel; do
# `.cargo-*lock*` is stripped from every clone by design, so it has no
# counterpart to compare against.
[ -e "$clone/$rel" ] || continue
sz=$(stat -c '%s' "$src/$rel")
total=$((total + sz))
[ "$(stat -c '%i' "$src/$rel")" = "$(stat -c '%i' "$clone/$rel")" ] || copied=$((copied + sz))
done < <(cd "$src" && find . -type f -printf '%P\n')
[ "$total" -gt 0 ] || fail "$label: fixture has no bytes to measure"
[ $((copied * 100 / total)) -lt 90 ] \
|| fail "$label: the clone real-copied $((copied * 100 / total))% of its source's bytes — the hardlink saving is gone"
ok "$label: clone real-copies $((copied * 100 / total))% of ${total} B (${copied} B), the rest is shared"
}
# Layout v2: no `.fingerprint`, no `deps`. Everything regroups per build unit
# under `build/<pkg>/<hash>/{fingerprint,out,run}`, artifacts included — which
# is what took `-name build` from "the metadata" to "the whole tree".
#
# THE THREE UNIT KINDS ARE THE POINT. `1bf...` is a compile unit: its `out`
# holds the rlib. `08c...` is the build script's own compile unit: its `out`
# holds the build-script binary. `da9...` is the build-script RUN unit: its
# `out` IS the OUT_DIR, and it is the only one of the three whose `out` is
# mutable. The `run/` directory beside it is the structural difference.
v2="$scratch/layout-v2"
mkfile "$v2/CACHEDIR.TAG" 'Signature: 8a477f597d28d172'
mkfile "$v2/.rustc_info.json" '{"rustc_fingerprint":1}'
mkfile "$v2/debug/.cargo-lock" ''
mkfile "$v2/debug/libprobe.rlib" "$artifact_bytes"
mkfile "$v2/debug/libprobe.d" '/probe/src/lib.rs:'
for f in dep-lib-probe lib-probe lib-probe.json invoked.timestamp; do
mkfile "$v2/debug/build/probe/1bf5493368dce3cd/fingerprint/$f" "$f"
done
mkfile "$v2/debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rlib" "$artifact_bytes"
mkfile "$v2/debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rmeta" "$artifact_bytes"
mkfile "$v2/debug/build/probe/1bf5493368dce3cd/out/probe-1bf5493368dce3cd.d" '/probe/src/lib.rs:'
for f in build-script-build-script-build build-script-build-script-build.json \
dep-build-script-build-script-build invoked.timestamp; do
mkfile "$v2/debug/build/probe/08c7dda6eacd6dca/fingerprint/$f" "$f"
done
mkfile "$v2/debug/build/probe/08c7dda6eacd6dca/out/build_script_build" "$artifact_bytes"
chmod +x "$v2/debug/build/probe/08c7dda6eacd6dca/out/build_script_build"
mkfile "$v2/debug/build/probe/08c7dda6eacd6dca/out/build_script_build.d" '/probe/build.rs:'
# A test binary: the same `out` directory as the rlib above, and the largest
# thing in a real tree that a linker writes.
for f in dep-test-lib-probe test-lib-probe test-lib-probe.json invoked.timestamp; do
mkfile "$v2/debug/build/probe/6a091d813b2be60d/fingerprint/$f" "$f"
done
mkfile "$v2/debug/build/probe/6a091d813b2be60d/out/probe-6a091d813b2be60d" "$artifact_bytes"
chmod +x "$v2/debug/build/probe/6a091d813b2be60d/out/probe-6a091d813b2be60d"
mkfile "$v2/debug/build/probe/6a091d813b2be60d/out/probe-6a091d813b2be60d.d" '/probe/src/lib.rs:'
for f in run-build-script-build-script-build run-build-script-build-script-build.json; do
mkfile "$v2/debug/build/probe/da96cf45111f80dd/fingerprint/$f" "$f"
done
mkfile "$v2/debug/build/probe/da96cf45111f80dd/out/gen.txt" 'generated from 24 bytes'
for f in invoked.timestamp root-output stdout stderr; do
mkfile "$v2/debug/build/probe/da96cf45111f80dd/run/$f" "$f"
done
# A build script that wrote into OUT_DIR and then FAILED: Cargo records the
# run only on success, so this unit has `out/` populated and no `run/` at all.
# Reading "no execution record" as "compile unit" left this shared, which is
# the one state the old `-name build` selection covered and the first cut of
# this one did not.
mkfile "$v2/debug/build/probe/f00ded00f00ded00/out/gen.txt" 'half-written'
mkfile "$v2/debug/incremental/probe-abc/s-xyz/dep-graph.bin" "$artifact_bytes"
assert_partition "layout v2" "$v2" "$(cat <<'SPEC'
private|.rustc_info.json
private|debug/libprobe.d
private|debug/build/probe/1bf5493368dce3cd/fingerprint/dep-lib-probe
private|debug/build/probe/1bf5493368dce3cd/fingerprint/lib-probe
private|debug/build/probe/1bf5493368dce3cd/fingerprint/lib-probe.json
private|debug/build/probe/1bf5493368dce3cd/fingerprint/invoked.timestamp
private|debug/build/probe/1bf5493368dce3cd/out/probe-1bf5493368dce3cd.d
private|debug/build/probe/08c7dda6eacd6dca/fingerprint/dep-build-script-build-script-build
private|debug/build/probe/08c7dda6eacd6dca/fingerprint/invoked.timestamp
private|debug/build/probe/08c7dda6eacd6dca/out/build_script_build.d
private|debug/build/probe/da96cf45111f80dd/fingerprint/run-build-script-build-script-build
private|debug/build/probe/da96cf45111f80dd/out/gen.txt
private|debug/build/probe/da96cf45111f80dd/run/root-output
private|debug/build/probe/da96cf45111f80dd/run/stdout
private|debug/build/probe/da96cf45111f80dd/run/stderr
private|debug/build/probe/da96cf45111f80dd/run/invoked.timestamp
private|debug/build/probe/f00ded00f00ded00/out/gen.txt
shared|debug/libprobe.rlib
shared|debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rlib
shared|debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rmeta
private|debug/build/probe/08c7dda6eacd6dca/out/build_script_build
private|debug/build/probe/6a091d813b2be60d/out/probe-6a091d813b2be60d
private|debug/build/probe/6a091d813b2be60d/out/probe-6a091d813b2be60d.d
private|debug/build/probe/6a091d813b2be60d/fingerprint/dep-test-lib-probe
shared|debug/incremental/probe-abc/s-xyz/dep-graph.bin
SPEC
)"
# Layout v1: one `.fingerprint` and one `deps` per profile; `build/<pkg>-<hash>`
# holds the build script's compiled binary in one unit directory and its run
# metadata plus OUT_DIR in another.
v1="$scratch/layout-v1"
mkfile "$v1/CACHEDIR.TAG" 'Signature: 8a477f597d28d172'
mkfile "$v1/.rustc_info.json" '{"rustc_fingerprint":1}'
mkfile "$v1/debug/.cargo-lock" ''
mkfile "$v1/debug/libprobe.rlib" "$artifact_bytes"
mkfile "$v1/debug/libprobe.d" '/probe/src/lib.rs:'
mkfile "$v1/debug/deps/libprobe-1bf5493368dce3cd.rlib" "$artifact_bytes"
mkfile "$v1/debug/deps/libprobe-1bf5493368dce3cd.rmeta" "$artifact_bytes"
mkfile "$v1/debug/deps/probe-1bf5493368dce3cd.d" '/probe/src/lib.rs:'
# A test binary, which layout v1 leaves in `deps/` beside the rlibs.
mkfile "$v1/debug/deps/probe-6a091d813b2be60d" "$artifact_bytes"
chmod +x "$v1/debug/deps/probe-6a091d813b2be60d"
mkfile "$v1/debug/deps/probe-6a091d813b2be60d.d" '/probe/src/lib.rs:'
for f in dep-lib-probe lib-probe lib-probe.json invoked.timestamp; do
mkfile "$v1/debug/.fingerprint/probe-1bf5493368dce3cd/$f" "$f"
done
mkfile "$v1/debug/build/probe-08c7dda6eacd6dca/build-script-build" "$artifact_bytes"
mkfile "$v1/debug/build/probe-08c7dda6eacd6dca/build_script_build-08c7dda6eacd6dca" "$artifact_bytes"
chmod +x "$v1/debug/build/probe-08c7dda6eacd6dca/build-script-build" \
"$v1/debug/build/probe-08c7dda6eacd6dca/build_script_build-08c7dda6eacd6dca"
mkfile "$v1/debug/build/probe-08c7dda6eacd6dca/build_script_build-08c7dda6eacd6dca.d" '/probe/build.rs:'
for f in invoked.timestamp output root-output stderr; do
mkfile "$v1/debug/build/probe-da96cf45111f80dd/$f" "$f"
done
mkfile "$v1/debug/build/probe-da96cf45111f80dd/out/gen.txt" 'generated from 24 bytes'
# The same never-succeeded build script under layout v1.
mkfile "$v1/debug/build/probe-f00ded00f00ded00/out/gen.txt" 'half-written'
mkfile "$v1/debug/incremental/probe-abc/s-xyz/dep-graph.bin" "$artifact_bytes"
assert_partition "layout v1" "$v1" "$(cat <<'SPEC'
private|.rustc_info.json
private|debug/libprobe.d
private|debug/deps/probe-1bf5493368dce3cd.d
private|debug/.fingerprint/probe-1bf5493368dce3cd/dep-lib-probe
private|debug/.fingerprint/probe-1bf5493368dce3cd/lib-probe
private|debug/.fingerprint/probe-1bf5493368dce3cd/lib-probe.json
private|debug/.fingerprint/probe-1bf5493368dce3cd/invoked.timestamp
private|debug/build/probe-08c7dda6eacd6dca/build_script_build-08c7dda6eacd6dca.d
private|debug/deps/probe-6a091d813b2be60d
private|debug/deps/probe-6a091d813b2be60d.d
private|debug/build/probe-da96cf45111f80dd/invoked.timestamp
private|debug/build/probe-da96cf45111f80dd/output
private|debug/build/probe-da96cf45111f80dd/root-output
private|debug/build/probe-da96cf45111f80dd/stderr
private|debug/build/probe-da96cf45111f80dd/out/gen.txt
private|debug/build/probe-f00ded00f00ded00/out/gen.txt
shared|debug/libprobe.rlib
shared|debug/deps/libprobe-1bf5493368dce3cd.rlib
shared|debug/deps/libprobe-1bf5493368dce3cd.rmeta
private|debug/build/probe-08c7dda6eacd6dca/build-script-build
private|debug/build/probe-08c7dda6eacd6dca/build_script_build-08c7dda6eacd6dca
shared|debug/incremental/probe-abc/s-xyz/dep-graph.bin
SPEC
)"
echo
command -v cargo >/dev/null || {
echo "SKIP: no cargo on PATH — the fixture scenarios above ran, the live-Cargo ones cannot"
echo "hardlink-clone-selftest: ${pass_count} assertions passed"
exit 0
}
crate_dir="$scratch/probe" crate_dir="$scratch/probe"
mkcrate "$crate_dir" mkcrate "$crate_dir"
cd "$crate_dir" cd "$crate_dir"
@@ -304,27 +523,44 @@ build_base "$base_fix"
before=$(snapshot_tree "$base_fix") before=$(snapshot_tree "$base_fix")
hardlink_clone_into "$base_fix" "$clone_fix" "selftest" || fail "hardlink_clone_into reported the destination already existed" hardlink_clone_into "$base_fix" "$clone_fix" "selftest" || fail "hardlink_clone_into reported the destination already existed"
# The clone's contract, asserted before anything builds in it: artifacts # The clone's contract, asserted before anything builds in it and asserted in
# share inodes (that is what makes the clone near-free), and every file Cargo # BOTH directions: every file Cargo rewrites in place is privately owned (that
# rewrites in place does not (that is what makes it sound). Checking after a # is what makes the clone sound), and every artifact still shares its inode
# rebuild would prove nothing — the rebuild replaces those files anyway. # (that is what makes it near-free). Checking after a rebuild would prove
shared=0; unshared=0 # nothing — the rebuild replaces those files anyway.
#
# The mutable-family patterns cover both layouts: `.fingerprint/` is v1's,
# `fingerprint/` and `run/` are v2's, and `gen.txt` is this crate's build
# script's OUT_DIR product, which under v2 sits in a directory called `out`
# beside sibling units whose `out` holds artifacts.
shared=0; unshared=0; shared_bytes=0; copied_bytes=0
while IFS= read -r f; do while IFS= read -r f; do
rel="${f#"$base_fix"/}" rel="${f#"$base_fix"/}"
[ -e "$clone_fix/$rel" ] || continue [ -e "$clone_fix/$rel" ] || continue
sz=$(stat -c '%s' "$f")
if [ "$(stat -c '%i' "$f")" = "$(stat -c '%i' "$clone_fix/$rel")" ]; then if [ "$(stat -c '%i' "$f")" = "$(stat -c '%i' "$clone_fix/$rel")" ]; then
case "$rel" in case "$rel" in
*/.fingerprint/*|*/build/*|*.d|.rustc_info.json) */.fingerprint/*|*/fingerprint/*|*/run/*|*/out/gen.txt|*/output|*/root-output|*/stderr|*/invoked.timestamp|*.d|.rustc_info.json)
fail "mutable path still shares an inode with the source: $rel" ;; fail "mutable path still shares an inode with the source: $rel" ;;
esac esac
shared=$((shared + 1)) shared=$((shared + 1)); shared_bytes=$((shared_bytes + sz))
else else
unshared=$((unshared + 1)) case "$rel" in
*.rlib|*.rmeta)
fail "artifact was real-copied rather than shared: $rel" ;;
esac
unshared=$((unshared + 1)); copied_bytes=$((copied_bytes + sz))
fi fi
done < <(find "$base_fix" -type f) done < <(find "$base_fix" -type f)
[ "$shared" -gt 0 ] || fail "nothing is shared — the clone degenerated into a full copy" [ "$shared" -gt 0 ] || fail "nothing is shared — the clone degenerated into a full copy"
[ "$unshared" -gt 0 ] || fail "nothing was unshared — unshare_mutable_paths did not run" [ "$unshared" -gt 0 ] || fail "nothing was unshared — unshare_mutable_paths did not run"
total_bytes=$((shared_bytes + copied_bytes))
ok "fresh clone shares ${shared} artifact files and privately owns ${unshared} mutable ones" ok "fresh clone shares ${shared} artifact files and privately owns ${unshared} mutable ones"
# Reported, not asserted. This crate has no dependencies, so almost all of its
# bytes are the two executables — a ratio that says nothing about a real tree.
# The fixtures above are where the cost model is gated, because there the
# composition is fixed.
echo " note: this clone real-copies $((copied_bytes * 100 / total_bytes))% of ${total_bytes} B"
printf '%s\n' "$CONTENT_B" > src/lib.rs printf '%s\n' "$CONTENT_B" > src/lib.rs
CARGO_TARGET_DIR="$clone_fix" "${CARGO_BIN[@]}" build -q CARGO_TARGET_DIR="$clone_fix" "${CARGO_BIN[@]}" build -q
@@ -337,6 +573,91 @@ if [ -n "$fix_mutated" ]; then
fi fi
ok "no file in the source changed after a full rebuild in the clone" ok "no file in the source changed after a full rebuild in the clone"
echo
echo "=== a linked TEST binary, which nothing uplifts and nothing replaces ==="
# The one artifact family that is NOT safe to share, and the reason
# `unshare_mutable_paths` privately owns every executable. rustc writes an
# rlib to a temporary and renames it in; the LINKER writes an executable
# through whatever inode is already at the path.
#
# THE CRATE SHAPE IS LOAD-BEARING AND WAS WRONG ONCE. An earlier cut of this
# scenario reused the lib+bin probe crate above, whose test binaries relink to
# a FRESH inode — a shape gitdan-actions#17 records as measured safe. Both
# halves then passed green against the unfixed selection, on the strength of
# dep-info mutations the previous scenario already covers, and the scenario
# pinned nothing. A bin-only crate with a unit test does exhibit the rewrite,
# on cargo 1.93.1 stable and on 1.98.0-nightly and 1.100.0-nightly, so that is
# what this builds. The shape is chosen by measurement rather than derived:
# what separates a rewritten executable from an intact one is not established,
# so the only crate shape this scenario may rest on is one observed to exhibit
# the rewrite.
mkbincrate() {
local dir="$1" marker="$2"
mkdir -p "$dir/src"
cat > "$dir/Cargo.toml" <<'TOML'
[package]
name = "binprobe"
version = "0.1.0"
edition = "2021"
[workspace]
TOML
cat > "$dir/src/main.rs" <<RS
fn main() { println!("${marker}"); }
#[cfg(test)]
mod t { #[test] fn a() { assert_eq!("${marker}".len() > 0, true); } }
RS
}
bin_dir="$scratch/binprobe"
mkbincrate "$bin_dir" MARKER_AAAA
cd "$bin_dir"
base_exe="$scratch/base-exe"; clone_exe_ctl="$scratch/clone-exe-ctl"; clone_exe="$scratch/clone-exe"
# Only the executables are read here. The families the other scenarios cover
# would satisfy a "something changed" assertion on their own, which is exactly
# how the earlier cut of this passed while pinning nothing.
source_exe_digest() {
(cd "$1" && find . -type f -executable -print0 | sort -z | xargs -0 -r sha1sum) 2>/dev/null
}
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1 \
|| fail "the bin-only probe crate failed to build"
before=$(source_exe_digest "$base_exe")
cp -al "$base_exe" "$clone_exe_ctl"
strip_cargo_locks "$clone_exe_ctl"
mkbincrate "$bin_dir" MARKER_BBBB
CARGO_TARGET_DIR="$clone_exe_ctl" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
exe_ctl_mutated=$(mutated_paths "$before" "$(source_exe_digest "$base_exe")")
# THREE OUTCOMES, as the freshness probe above has, and for the same reason: a
# scenario that cannot tell "the fix works" from "the hazard never fired" is
# not a gate. If this Cargo does not rewrite the source's test binary, the
# assertion below would pass for a toolchain reason rather than a code one, so
# it is skipped LOUDLY instead of passing quietly.
if [ -z "$exe_ctl_mutated" ]; then
echo "::warning::hardlink-clone-selftest: this toolchain did not rewrite the source's test binary through a raw cp -al clone, so the linked-output scenario proves nothing here and was SKIPPED. That is a statement about this Cargo, not about unshare_mutable_paths."
else
ok "control: a raw cp -al clone rewrites the source's own linked test binary"
printf '%s\n' "$exe_ctl_mutated" | sed 's/^/ /'
# Rebuild the base from the original marker so it is warm and consistent
# again, then do the same thing through the real clone.
mkbincrate "$bin_dir" MARKER_AAAA
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
before=$(snapshot_tree "$base_exe")
hardlink_clone_into "$base_exe" "$clone_exe" "selftest-exe" \
|| fail "hardlink_clone_into refused the destination"
mkbincrate "$bin_dir" MARKER_BBBB
CARGO_TARGET_DIR="$clone_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
exe_mutated=$(mutated_paths "$before" "$(snapshot_tree "$base_exe")")
if [ -n "$exe_mutated" ]; then
printf '%s\n' "$exe_mutated" | sed 's/^/ /' >&2
fail "a test build in the clone mutated the source through a shared inode"
fi
ok "no file in the source changed after a full test build in the clone"
fi
cd "$crate_dir"
echo echo
if [ "$CHECKSUM_MODE" = "on" ]; then if [ "$CHECKSUM_MODE" = "on" ]; then
echo "=== the whole point: the source's next build is still correct ===" echo "=== the whole point: the source's next build is still correct ==="
+194 -1
View File
@@ -46,10 +46,36 @@
# empty a tree its owner may still restore under a live cache name. Its # empty a tree its owner may still restore under a live cache name. Its
# fixture is an OLD directory renamed a moment ago — production's shape, # fixture is an OLD directory renamed a moment ago — production's shape,
# and what lets it tell the two timestamps apart. # and what lets it tell the two timestamps apart.
# 15. A MERGED-BUT-UNDELETED BRANCH IS DEAD TOO. A branch the forge did not
# delete at merge stays on `ls-remote` forever, so scenario 1's signal
# never fires for it — which is how three 40 GB caches sat on a full
# volume until somebody removed them by hand (gitdan-actions#20). A
# branch whose tip is an ancestor of a protected branch's tip is pruned
# like a deleted one; an unmerged branch beside it is not.
# 16. AND "CANNOT TELL" IS STILL NOT DEATH, at both granularities: a branch
# whose tip is not in this checkout is kept with a warning naming it,
# and a shallow checkout — where a missing object is the normal case —
# withholds the whole signal rather than reading it as "nothing merged".
# The deleted-branch signal keeps working in both.
# 17. THE FREE-SPACE REQUIREMENT IS MEASURED OFF THE SOURCE, not taken as a
# percentage of the volume: the pass evicts until the clone the seed is
# about to make fits, and stops there rather than draining the volume.
# When it cannot get there it FAILS, naming the shortfall and every
# directory it kept instead — because the seed would otherwise fail
# seconds later against a staging path that names nothing.
# 18. AND ON THE LAYOUT THAT PRODUCED THE BUG: three equal-sized caches, one
# of them a merged-but-undeleted branch's, with disk to spare. Exactly
# that one goes. Equal sizes and no pressure are the point — nothing but
# the merge state can be what decides.
set -euo pipefail set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh" . "$script_dir/cache-lib.sh"
prune="$script_dir/prune-cache.sh" prune="$script_dir/prune-cache.sh"
# Two scenarios below put a stub of a real tool on PATH for one command.
# Captured once, here, rather than read back at each of those sites: a `$PATH`
# read after the first of them is indistinguishable, to a static check, from
# reading the modification the subshell lost.
outer_path="$PATH"
scratch=$(mktemp -d) scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT trap 'rm -rf "$scratch"' EXIT
@@ -235,7 +261,7 @@ done
exec "$real_du" "\$@" exec "$real_du" "\$@"
EOF EOF
chmod +x "$scratch/bin/du" chmod +x "$scratch/bin/du"
( PATH="$scratch/bin:$PATH"; run_prune "1000000 900000" ) ( PATH="$scratch/bin:$outer_path"; run_prune "1000000 900000" )
[ -e "$root/.reading-target-$DEAD-racer" ] || fail "the racing marker was never published — scenario 12 proves nothing" [ -e "$root/.reading-target-$DEAD-racer" ] || fail "the racing marker was never published — scenario 12 proves nothing"
assert_kept "$root/target-$DEAD" "a cache claimed inside the eviction window is not unlinked" assert_kept "$root/target-$DEAD" "a cache claimed inside the eviction window is not unlinked"
assert_kept "$root/target-$DEAD/blob" "the reprieved cache still has its contents" assert_kept "$root/target-$DEAD/blob" "the reprieved cache still has its contents"
@@ -286,5 +312,172 @@ assert_kept "$aside" "an aside younger than the settle window is not reclaimed"
assert_kept "$aside/blob" "and is left intact, not part-way emptied" assert_kept "$aside/blob" "and is left intact, not part-way emptied"
assert_log "may still be evicting it" "the deferral gives its actual reason" assert_log "may still be evicting it" "the deferral gives its actual reason"
echo
echo "=== 15: a merged-but-undeleted branch is dead too ==="
# A branch the forge did not delete at merge, which `ls-remote` then reports
# forever. Built the way that happens: a branch merged into dev with a merge
# commit, still pushed, beside one branched at the same point and NOT merged.
git="git -c user.email=t@t -c user.name=t -c commit.gpgsign=false"
(
cd "$work"
git checkout -q dev
git checkout -q -b feat/merged
$git commit -q --allow-empty -m merged
git checkout -q dev
$git merge -q --no-ff feat/merged -m "merge feat/merged"
git checkout -q -b feat/unmerged
$git commit -q --allow-empty -m unmerged
git checkout -q dev
git push -q origin dev feat/merged feat/unmerged
)
MERGED=$(cache_key feat/merged); UNMERGED=$(cache_key feat/unmerged)
reset_cache
mk "target-$MERGED" '2030-01-01'
mk "snapshot-$MERGED" '2030-01-01'
mk "target-$UNMERGED" '2020-01-01' # older, deliberately: merge state decides, not age
run_prune "1000000 900000" # 90% free: no pressure at all
assert_log "merged-branch detection anchored on" "the pass says what it anchored ancestry on"
assert_gone "$root/target-$MERGED" "a merged branch's cache is pruned though its branch is still on origin"
assert_gone "$root/snapshot-$MERGED" "and so is its snapshot"
assert_kept "$root/target-$UNMERGED" "an unmerged branch's cache survives, though it is the older of the two"
assert_log "merged into dev" "the eviction names the branch it was merged into"
echo
echo "=== 16: 'cannot tell' is not death, per branch and per checkout ==="
# A branch whose tip this checkout has never seen. Pushed from a second clone,
# so `ls-remote` reports a SHA that `$work` holds no object for — which is
# what "cannot determine" actually looks like, rather than a stubbed failure.
other="$scratch/other"; git clone -q "$origin" "$other"
(
cd "$other"
git checkout -q -b feat/elsewhere origin/dev
git -c user.email=t@t -c user.name=t -c commit.gpgsign=false commit -q --allow-empty -m elsewhere
git push -q origin feat/elsewhere
)
ELSEWHERE=$(cache_key feat/elsewhere)
reset_cache
mk "target-$ELSEWHERE" '2030-01-01'
mk "target-$MERGED" '2030-01-01'
run_prune "1000000 900000"
assert_kept "$root/target-$ELSEWHERE" "a branch whose tip is not in this checkout is kept, not classified dead"
assert_log "cannot tell merged from live" "and the undecidable branch is named, not silently skipped"
assert_gone "$root/target-$MERGED" "while a branch it CAN decide is still pruned in the same pass"
# A shallow checkout, where a missing object is the ordinary case rather than
# a signal — so the whole merged half is withheld. The deleted-branch half is
# unaffected, which is what keeps this a narrowing rather than an outage.
shallow="$scratch/shallow"; git clone -q --depth 1 -b dev "file://$origin" "$shallow"
[ "$(git -C "$shallow" rev-parse --is-shallow-repository)" = true ] \
|| fail "the fixture clone is not shallow — scenario 16's second half proves nothing"
reset_cache
mk "target-$MERGED" '2030-01-01'
(
cd "$shallow"
CACHE_DF_OVERRIDE="1000000 900000" GITHUB_STEP_SUMMARY="$scratch/summary" \
bash "$prune" "$root" "$root/target-$OWN" "dev main" 10
) > "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "prune-cache.sh exited non-zero in a shallow checkout"; }
assert_log "checkout is shallow" "a shallow checkout withholds the merged signal and says why"
assert_kept "$root/target-$MERGED" "and keeps a merged branch's cache rather than guessing"
assert_gone "$root/target-$DEAD" "while the deleted-branch signal still fires"
echo
echo "=== 17: the free-space requirement is measured off the clone's source ==="
# A `df` that answers from the cache root's actual size, because the property
# under test is that the pass STOPS once the requirement is met — which a
# fixed CACHE_DF_OVERRIDE cannot express, since evicting never changes it.
mkdir -p "$scratch/bin17"
real_du=$(command -v du)
build_seed_fixture() {
rm -rf "$root"; mkdir -p "$root"
# The source the seed is about to clone. 8 MB of dep-info, which
# unshare_mutable_paths has to real-copy, beside 16 MB of .rlib that it
# leaves hardlinked — so a requirement derived from the SIZE of the source
# would be three times the one derived from its mutable set.
mkdir -p "$root/snapshot-$DEV/debug/.fingerprint/unit" "$root/snapshot-$DEV/debug/deps"
head -c $((8 * 1024 * 1024)) /dev/zero > "$root/snapshot-$DEV/debug/.fingerprint/unit/dep-lib"
head -c $((16 * 1024 * 1024)) /dev/zero > "$root/snapshot-$DEV/debug/deps/libx.rlib"
touch -d '2020-01-01' "$root/snapshot-$DEV/.cache-last-used"
# Three live, unmerged branches' caches of 4 MB each, oldest first.
local i=0
for b in a b c; do
i=$((i + 1))
mkdir -p "$root/target-$(cache_key "feat/$b")"
head -c $((4 * 1024 * 1024)) /dev/zero > "$root/target-$(cache_key "feat/$b")/blob"
touch -d "202${i}-01-01" "$root/target-$(cache_key "feat/$b")/.cache-last-used"
done
# A volume with 2 MB to spare: under the requirement, over nothing else.
cap=$(( $($real_du -sk "$root" | awk '{print $1}') + 2048 ))
cat > "$scratch/bin17/df" <<DFEOF
#!/usr/bin/env bash
used=\$($real_du -sk "$root" | awk '{print \$1}')
echo "Filesystem 1024-blocks Used Available Capacity Mounted-on"
echo "fake $cap \$used \$(( $cap - used )) 50% $root"
DFEOF
chmod +x "$scratch/bin17/df"
}
(
cd "$work"
for b in a b c; do
git checkout -q dev
git checkout -q -b "feat/$b"
git -c user.email=t@t -c user.name=t -c commit.gpgsign=false commit -q --allow-empty -m "$b"
done
git checkout -q dev
git push -q origin feat/a feat/b feat/c
)
# run_seeded_prune <own-ref> <base-ref> — the form cargo-cache/action.yml uses:
# the same pass, told what the seed step it now runs ahead of will clone. The
# headroom knobs are pinned so the arithmetic is the fixture's, not the
# defaults' (whose 2 GiB floor would dwarf any fixture on a test host).
seeded_rc=0
run_seeded_prune() {
seeded_rc=0
PATH="$scratch/bin17:$outer_path" \
CACHE_CLONE_HEADROOM_PERCENT=100 CACHE_CLONE_HEADROOM_FLOOR_KB=1024 \
GITHUB_STEP_SUMMARY="$scratch/summary" \
bash "$prune" "$root" "$root/target-$(cache_key "$1")" "dev main" 0 \
"$(cache_key "$1")" "$(cache_key "$2")" "" \
> "$scratch/log" 2>&1 || seeded_rc=$?
}
build_seed_fixture
run_seeded_prune feat/own dev
[ "$seeded_rc" = 0 ] || { cat "$scratch/log"; fail "prune-cache.sh exited ${seeded_rc} with the requirement satisfiable"; }
assert_log "measured from its mutable set" "the requirement says where it came from"
assert_gone "$root/target-$(cache_key feat/a)" "the oldest cache is evicted to make room for the clone"
assert_gone "$root/target-$(cache_key feat/b)" "and the next oldest, because one was not enough"
assert_kept "$root/target-$(cache_key feat/c)" "and the pass STOPS there rather than draining the volume"
assert_kept "$root/snapshot-$DEV" "the source the seed is about to clone is never a candidate"
# Nothing eligible: every sibling is held open by a running job. The pass
# cannot reach the requirement, and the seed that follows would fail against a
# staging path naming none of this.
build_seed_fixture
for b in a b c; do date +%s > "$root/target-$(cache_key "feat/$b")/.ci-lock-ci-1"; done
run_seeded_prune feat/own dev
[ "$seeded_rc" = 1 ] || { cat "$scratch/log"; fail "expected exit 1 when the clone cannot fit, got ${seeded_rc}" ; }
ok "a clone that cannot be made to fit fails the pass rather than the seed"
assert_log "short by" "the failure names the shortfall"
assert_log "held open by a running job" "and what was kept instead of it, with the reason"
assert_kept "$root/target-$(cache_key feat/a)" "a locked cache is still not evicted, however tight the disk"
echo
echo "=== 18: the layout that produced the bug ==="
# zemyna's volume on 2026-09-07: the base branch's snapshot and target dir,
# plus one target dir for a branch merged the day before and never deleted.
# Equal sizes and 90% free, so neither age nor pressure nor size can be what
# decides — only the merge state.
rm -rf "$root"; mkdir -p "$root"
mk "snapshot-$DEV" '2026-09-01'
mk "target-$DEV" '2026-09-01'
mk "target-$MERGED" '2026-09-06'
run_prune "1000000 900000"
assert_kept "$root/snapshot-$DEV" "the base snapshot stays"
assert_kept "$root/target-$DEV" "and the base target dir stays"
assert_gone "$root/target-$MERGED" "and the merged-but-undeleted branch's cache is the one reclaimed"
[ "$(grep -c 'pruned dead-branch cache' "$scratch/log")" = 1 ] \
|| { cat "$scratch/log"; fail "expected exactly one eviction on the zemyna layout"; }
ok "exactly one directory is evicted, and it is that one"
echo echo
echo "prune-cache-selftest: ${pass_count} assertions passed" echo "prune-cache-selftest: ${pass_count} assertions passed"
+276 -32
View File
@@ -1,8 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Eviction for the per-ref cache directories on the persistent volume. # Eviction for the per-ref cache directories on the persistent volume.
# #
# Usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent> # Usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> \
# <min-free-percent> [own-key] [base-key] [fallback-dir]
# protected-branches space-separated raw refs (e.g. "dev main") # protected-branches space-separated raw refs (e.g. "dev main")
# min-free-percent a FLOOR, not the gate — 0 to rely on the derived
# requirement alone (the default)
# own-key, base-key, fallback-dir
# the same three the seed step resolves its source from.
# Given them, this pass sizes the volume for the clone
# that step is about to make; without them it has only
# the percentage floor, and says so.
# #
# Optional environment: # Optional environment:
# STALE_LOCK_SECONDS age past which a .ci-lock-* marker is treated as # STALE_LOCK_SECONDS age past which a .ci-lock-* marker is treated as
@@ -16,19 +24,42 @@
# #
# Three passes, in order: # Three passes, in order:
# #
# 1. LIVENESS — every target-*/snapshot-* directory whose branch no longer # 1. LIVENESS — every target-*/snapshot-* directory whose branch is DEAD is
# exists on origin is removed UNCONDITIONALLY, not gated on free space. # removed UNCONDITIONALLY, not gated on free space. A directory for a
# A directory for a branch deleted days ago is pure loss: nothing will # branch nothing will build again is pure loss; waiting for disk pressure
# ever read it again, since a merged PR's branch cannot be reopened. # to notice means paying for it until then. Two signals make a branch
# Waiting for disk pressure to notice means paying for it until then. # dead, and the second exists because the first alone is inert wherever
# Skipped entirely, loudly, if the liveness signal itself is # a merged branch stays on origin — the default, and still the outcome
# unavailable — "couldn't determine" is never folded into "dead". # whenever delete-on-merge declines or is not asked (gitdan-actions#20):
# 2. PRESSURE — if free space is still under the threshold, evict remaining #
# (now necessarily live) directories oldest-first until it clears. # DELETED — the branch is no longer on origin at all.
# 3. SELF-CLEAR — if pass 2 still isn't enough, wipe this run's own target # MERGED — the branch is still on origin, but its tip is an ancestor
# dir and pay a cold rebuild, reported to the job summary as well as the # of a protected branch's tip, so every commit it holds is
# log, because a warning on a green run is what lets a silently 4x-slower # already on the branch its cache would be re-cloned from.
# job go unnoticed. #
# Skipped entirely, loudly, if the signal itself is unavailable —
# "couldn't determine" is never folded into "dead", for either signal
# and at either granularity: a checkout that cannot answer ancestry at
# all skips the merged half, and a single branch whose tip is not in the
# checkout is kept with a warning naming it.
# 2. PRESSURE — if free space is under the requirement, evict remaining
# (now necessarily live) directories oldest-first until it clears. The
# requirement is what the seed step is about to need to clone its source,
# MEASURED off that source (see clone_headroom_kb in cache-lib.sh), and a
# percentage floor only if one is configured. A percentage cannot express
# this: the failure it has to prevent is a clone running out of disk
# part-way through unsharing its mutable paths, and how much that needs
# is a property of the snapshot, not of the volume.
# 3. SELF-CLEAR — if pass 2 still isn't enough for the percentage floor,
# wipe this run's own target dir and pay a cold rebuild, reported to the
# job summary as well as the log, because a warning on a green run is
# what lets a silently 4x-slower job go unnoticed. It is not a way out of
# the derived requirement: a run that has an own target dir to wipe is a
# run whose seed reuses it and clones nothing, so that requirement is
# zero. Falling short of a NON-ZERO derived requirement fails the job
# here, naming the shortfall and what was kept instead of it — the seed
# would otherwise fail seconds later against a half-unshared staging
# tree, which is the failure this pass exists to pre-empt.
# #
# Reactive-only, with no hard cap on cache size: a workspace's natural working # Reactive-only, with no hard cap on cache size: a workspace's natural working
# set is what it is, and bounding the footprint preemptively means wiping # set is what it is, and bounding the footprint preemptively means wiping
@@ -75,16 +106,34 @@
# than reimplementing a lookalike is what makes the classification sound; any # than reimplementing a lookalike is what makes the classification sound; any
# drift between two spellings would silently misclassify every directory. # drift between two spellings would silently misclassify every directory.
# #
# The merged half reads the TIP SHA out of that same `ls-remote` output and
# asks `git merge-base --is-ancestor` against each protected branch's tip,
# using the objects in this job's own checkout. Ancestry is only decidable
# where the objects are there to decide it, so the answer "I cannot tell"
# exists and is distinct from "not merged" everywhere it can arise:
#
# - a shallow checkout makes a MISSING object prove nothing, so the whole
# signal is withheld rather than read as "no branch is merged";
# - a protected tip that is not in the checkout is not used as an anchor;
# - a branch tip that is not in the checkout is kept, loudly.
#
# A squash or rebase merge leaves no ancestor relationship at all, so its
# branch reads as live here. That is a missed reclamation, not a wrong one,
# and the deleted-branch signal still covers it once the branch is removed.
#
# Only ever globs inside <cache-root>. Another project's volume is a different # Only ever globs inside <cache-root>. Another project's volume is a different
# Docker named volume and is not mounted in this container at all, so "stays # Docker named volume and is not mounted in this container at all, so "stays
# scoped to this repo's cache" holds structurally, not by convention. # scoped to this repo's cache" holds structurally, not by convention.
set -euo pipefail set -euo pipefail
. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cache-lib.sh" . "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/cache-lib.sh"
ROOT="${1:?usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent>}" ROOT="${1:?usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branches> <min-free-percent> [own-key] [base-key] [fallback-dir]}"
OWN_DIR="${2:?}" OWN_DIR="${2:?}"
PROTECTED_REFS="${3:-}" PROTECTED_REFS="${3:-}"
MIN_FREE_PCT="${4:-10}" MIN_FREE_PCT="${4:-0}"
OWN_KEY="${5:-}"
BASE_KEY="${6:-}"
FALLBACK="${7:-}"
# Mirrored by daniel/gitdan's ci-cache-reclaim.sh, whose copy must be >= this # Mirrored by daniel/gitdan's ci-cache-reclaim.sh, whose copy must be >= this
# one — raising this without raising theirs first lets that script treat a lock # one — raising this without raising theirs first lets that script treat a lock
# this side still honours as abandoned. Same direction and same reasoning as # this side still honours as abandoned. Same direction and same reasoning as
@@ -102,6 +151,34 @@ STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-7200}"
# the volume is under pressure. # the volume is under pressure.
EVICTION_ASIDE_SETTLE_SECONDS="${EVICTION_ASIDE_SETTLE_SECONDS:-60}" EVICTION_ASIDE_SETTLE_SECONDS="${EVICTION_ASIDE_SETTLE_SECONDS:-60}"
# What the seed step is about to do, resolved through the same function that
# step resolves it with (cache-lib.sh's seed_source_candidates). Empty when it
# will clone nothing at all: its own target dir already exists and it reuses
# it, or no source exists and it starts cold. Either way the derived
# requirement is zero, because nothing is about to be copied.
#
# gb() is for reporting only. Every comparison below is in KB, because
# `read_df` reports KB and rounding a threshold to a tenth of a GB either
# passes a run that cannot fit or evicts a cache the run did not need.
gb() { awk -v k="${1:-0}" 'BEGIN { printf "%.1f", k / 1048576 }'; }
SEED_SRC=""
CLONE_KB=0
if [ -n "$OWN_KEY" ]; then
SEED_SRC=$(seed_clone_source "$ROOT" "$OWN_KEY" "$BASE_KEY" "$FALLBACK")
if [ -n "$SEED_SRC" ]; then
# A full walk of the source, and the reason this pass moved ahead of the
# seed rather than staying where it was: the number is only useful before
# the clone it describes.
CLONE_KB=$(clone_headroom_kb "$SEED_SRC")
echo "clone requirement: seeding from $(basename "$SEED_SRC") needs $(gb "$CLONE_KB") GB free (measured from its mutable set)"
else
echo "clone requirement: none — this run reuses its own cache or starts cold, so nothing will be copied"
fi
else
echo "clone requirement: not derivable — no cache key was passed to this pass; the ${MIN_FREE_PCT}% floor is the only gate"
fi
declare -A protected_ns=() declare -A protected_ns=()
for ref in $PROTECTED_REFS; do for ref in $PROTECTED_REFS; do
suffix=$(cache_key "$ref") suffix=$(cache_key "$ref")
@@ -109,14 +186,26 @@ for ref in $PROTECTED_REFS; do
protected_ns["snapshot-${suffix}"]=1 protected_ns["snapshot-${suffix}"]=1
done done
is_protected() { # 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.
#
# 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 local dir="$1" name
name=$(basename "$dir") name=$(basename "$dir")
[ "$dir" = "$OWN_DIR" ] && return 0 [ "$dir" = "$OWN_DIR" ] && { printf 'this run own cache'; return 0; }
[ -n "${protected_ns[$name]:-}" ] && 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; }
return 1 return 1
} }
is_protected() { protected_reason "$1" >/dev/null; }
# 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
@@ -277,6 +366,11 @@ done
echo "=== pass 1: liveness (unconditional, not gated on free space) ===" echo "=== pass 1: liveness (unconditional, not gated on free space) ==="
declare -A live_ns=() declare -A live_ns=()
# The tip SHA origin reports for the branch each directory name belongs to.
# Same output, same loop, one field over — reading it from a second `git` call
# would be reading a different instant.
declare -A live_tip=()
declare -A remote_tip_of=()
LIVENESS_AVAILABLE=0 LIVENESS_AVAILABLE=0
LIVENESS_REASON="" LIVENESS_REASON=""
if [ "${CACHE_LIVENESS:-true}" = "0" ] || [ "${CACHE_LIVENESS:-true}" = "false" ]; then if [ "${CACHE_LIVENESS:-true}" = "0" ] || [ "${CACHE_LIVENESS:-true}" = "false" ]; then
@@ -289,9 +383,13 @@ elif remote_heads=$(timeout 20 git ls-remote --heads origin 2>&1); then
case "$line" in *refs/heads/*) ;; *) continue ;; esac case "$line" in *refs/heads/*) ;; *) continue ;; esac
branch="${line#*refs/heads/}" branch="${line#*refs/heads/}"
[ -n "$branch" ] || continue [ -n "$branch" ] || continue
sha="${line%%[[:space:]]*}"
suffix=$(cache_key "$branch") suffix=$(cache_key "$branch")
live_ns["target-${suffix}"]=1 live_ns["target-${suffix}"]=1
live_ns["snapshot-${suffix}"]=1 live_ns["snapshot-${suffix}"]=1
live_tip["target-${suffix}"]="$sha"
live_tip["snapshot-${suffix}"]="$sha"
remote_tip_of["$branch"]="$sha"
branch_count=$((branch_count + 1)) branch_count=$((branch_count + 1))
done <<< "$remote_heads" done <<< "$remote_heads"
echo "liveness: ${branch_count} live branches on origin" echo "liveness: ${branch_count} live branches on origin"
@@ -299,6 +397,80 @@ else
LIVENESS_REASON="git ls-remote --heads origin failed or timed out" LIVENESS_REASON="git ls-remote --heads origin failed or timed out"
fi fi
# The merged half of pass 1, and whether this checkout can answer it at all.
# Every branch of this decision that ends in "no" ends in the signal being
# WITHHELD, never in a directory being classified dead by default.
MERGED_AVAILABLE=0
MERGED_REASON=""
PROTECTED_TIPS=()
declare -A merged_verdict=()
declare -A merged_into=()
MERGED_INTO=""
if [ "$LIVENESS_AVAILABLE" = "1" ]; then
if ! git rev-parse --git-dir >/dev/null 2>&1; then
MERGED_REASON="not inside a git checkout"
elif [ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo unknown)" != "false" ]; then
# In a shallow clone an absent commit is the normal case, so `--is-ancestor`
# answers about the graph that was fetched rather than the one that exists.
MERGED_REASON="the checkout is shallow, so a commit missing from it says nothing about ancestry"
else
for ref in $PROTECTED_REFS; do
tip="${remote_tip_of[$ref]:-}"
[ -n "$tip" ] || continue
git cat-file -e "${tip}^{commit}" 2>/dev/null || continue
PROTECTED_TIPS+=("${ref}:${tip}")
done
if [ "${#PROTECTED_TIPS[@]}" -gt 0 ]; then
MERGED_AVAILABLE=1
echo "liveness: merged-branch detection anchored on ${PROTECTED_TIPS[*]%%:*}"
else
MERGED_REASON="none of the protected branch tips (${PROTECTED_REFS:-none configured}) is present in this checkout"
fi
fi
[ "$MERGED_AVAILABLE" = "1" ] || \
echo "::warning::liveness: ${MERGED_REASON} — merged-but-undeleted branches keep their caches this run"
fi
# is_merged_dead <dir-name>
#
# True when the branch this directory belongs to is still on origin but every
# commit it holds is already on a protected branch — a merged PR whose branch
# the forge did not delete, which the deleted-branch signal above can never
# see. Enabling delete-on-merge narrows this to the branches merged before it
# was enabled, the ones its deletion declines (protected, or used by another
# open PR), and the merges that never ask (an API merge without the flag);
# see README's eviction section.
#
# Memoised per tip because target-<key> and snapshot-<key> share one branch,
# and because the "cannot tell" warning belongs to the branch rather than to
# each of its directories.
is_merged_dead() {
local name="$1" tip="${live_tip[$1]:-}" entry ref psha
MERGED_INTO=""
[ "$MERGED_AVAILABLE" = "1" ] || return 1
[ -n "$tip" ] || return 1
case "${merged_verdict[$tip]:-}" in
dead) MERGED_INTO="${merged_into[$tip]}"; return 0 ;;
live|unknown) return 1 ;;
esac
if ! git cat-file -e "${tip}^{commit}" 2>/dev/null; then
merged_verdict["$tip"]=unknown
echo "::warning::prune: ${name}: its branch tip ${tip} is not in this checkout — cannot tell merged from live, keeping it"
return 1
fi
for entry in "${PROTECTED_TIPS[@]}"; do
ref="${entry%%:*}"; psha="${entry#*:}"
if git merge-base --is-ancestor "$tip" "$psha" 2>/dev/null; then
merged_verdict["$tip"]=dead
merged_into["$tip"]="$ref"
MERGED_INTO="$ref"
return 0
fi
done
merged_verdict["$tip"]=live
return 1
}
if [ "$LIVENESS_AVAILABLE" = "1" ]; then if [ "$LIVENESS_AVAILABLE" = "1" ]; then
pruned_any=0 pruned_any=0
# Tracked separately so the line below cannot contradict the decline lines # Tracked separately so the line below cannot contradict the decline lines
@@ -309,12 +481,20 @@ if [ "$LIVENESS_AVAILABLE" = "1" ]; then
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
name=$(basename "$dir") name=$(basename "$dir")
is_protected "$dir" && continue is_protected "$dir" && continue
[ -n "${live_ns[$name]:-}" ] && continue if [ -z "${live_ns[$name]:-}" ]; then
why="no matching branch on origin"
why_summary="branch no longer exists on origin"
elif is_merged_dead "$name"; then
why="merged into ${MERGED_INTO}, whose tip already contains its every commit"
why_summary="merged into \`${MERGED_INTO}\`"
else
continue
fi
is_locked "$dir" && { spared_any=1; continue; } is_locked "$dir" && { spared_any=1; continue; }
dir_gb=$(usage_gb "$dir") dir_gb=$(usage_gb "$dir")
evict_dir "$dir" || { spared_any=1; continue; } evict_dir "$dir" || { spared_any=1; continue; }
echo "::warning::pruned dead-branch cache ${name} (${dir_gb} GB) — no matching branch on origin" echo "::warning::pruned dead-branch cache ${name} (${dir_gb} GB) — ${why}"
summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — branch no longer exists on origin" summary_line "- pruned dead-branch cache \`${name}\` (${dir_gb} GB) — ${why_summary}"
pruned_any=1 pruned_any=1
done done
if [ "$pruned_any" = "0" ]; then if [ "$pruned_any" = "0" ]; then
@@ -329,16 +509,45 @@ else
fi fi
echo echo
echo "=== pass 2/3: disk pressure (threshold: free < ${MIN_FREE_PCT}%) ===" echo "=== pass 2/3: disk pressure ==="
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")" read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
THRESHOLD_KB=$(( TOTAL_KB * MIN_FREE_PCT / 100 )) PCT_KB=$(( TOTAL_KB * MIN_FREE_PCT / 100 ))
if [ "$FREE_KB" -ge "$THRESHOLD_KB" ]; then # The two floors, and which of them governs. They are kept apart all the way
echo "cache: $(basename "$OWN_DIR") $(usage_gb "$OWN_DIR") GB | $(report_df host "$FREE_KB" "$TOTAL_KB")" # down rather than collapsed here, because falling short of them means
# different things: the derived one predicts that the very next step cannot
# finish, and the percentage one is a hygiene target for the volume.
REQUIRED_KB="$CLONE_KB"
GOVERNS="the clone this run is about to make"
if [ "$PCT_KB" -gt "$REQUIRED_KB" ]; then
REQUIRED_KB="$PCT_KB"
GOVERNS="the ${MIN_FREE_PCT}% floor"
fi
echo "required: $(gb "$REQUIRED_KB") GB free — ${GOVERNS} (clone $(gb "$CLONE_KB") GB, floor $(gb "$PCT_KB") GB)"
own_report() {
if [ -d "$OWN_DIR" ]; then
echo "cache: $(basename "$OWN_DIR") $(usage_gb "$OWN_DIR") GB | $(report_df host "$1" "$2")"
else
# Ordinary now that this pass runs ahead of the seed: on a branch's first
# run of the day the directory does not exist yet, and reporting 0.0 GB
# for it would read as an emptied cache.
echo "cache: $(basename "$OWN_DIR") not seeded yet | $(report_df host "$1" "$2")"
fi
}
if [ "$FREE_KB" -ge "$REQUIRED_KB" ]; then
own_report "$FREE_KB" "$TOTAL_KB"
exit 0 exit 0
fi fi
echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < ${MIN_FREE_PCT}% threshold" echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < the $(gb "$REQUIRED_KB") GB this run requires"
# What survived the pass, and why, in the order the pass considered them. Read
# only by the failure report at the bottom: a run that cannot fit its clone is
# a run whose log has to answer "then what is all that space?" without anyone
# having to reconstruct the pass by hand.
KEPT=()
# A plain loop over a pre-materialised, pre-sorted list rather than a live # A plain loop over a pre-materialised, pre-sorted list rather than a live
# `find | while` pipeline, so `rm -rf` inside the loop cannot make a running # `find | while` pipeline, so `rm -rf` inside the loop cannot make a running
@@ -346,21 +555,56 @@ echo "::warning::$(report_df disk "$FREE_KB" "$TOTAL_KB") < ${MIN_FREE_PCT}% thr
mapfile -t LRU < <(list_by_lru) mapfile -t LRU < <(list_by_lru)
for dir in "${LRU[@]}"; do for dir in "${LRU[@]}"; do
[ -d "$dir" ] || continue [ -d "$dir" ] || continue
is_protected "$dir" && continue if reason=$(protected_reason "$dir"); then
KEPT+=("$(basename "$dir")${reason}")
continue
fi
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")" read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
[ "$FREE_KB" -ge "$THRESHOLD_KB" ] && break [ "$FREE_KB" -ge "$REQUIRED_KB" ] && break
is_locked "$dir" && continue if is_locked "$dir"; then
KEPT+=("$(basename "$dir") — held open by a running job")
continue
fi
dir_gb=$(usage_gb "$dir") dir_gb=$(usage_gb "$dir")
evict_dir "$dir" || continue if ! evict_dir "$dir"; then
KEPT+=("$(basename "$dir") — claimed by a job while its eviction was in flight")
continue
fi
echo "::warning::evicted $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)" echo "::warning::evicted $(basename "$dir") (${dir_gb} GB, LRU under disk pressure)"
summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)" summary_line "- evicted \`$(basename "$dir")\` (${dir_gb} GB, LRU under disk pressure)"
done done
read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")" read -r TOTAL_KB FREE_KB <<< "$(read_df "$ROOT")"
if [ "$FREE_KB" -lt "$THRESHOLD_KB" ]; then
# Falling short of the DERIVED requirement is a failure, not a warning. The
# seed step is next, it will clone that source, and it will run out of disk
# part-way through unsharing the clone's mutable paths — reported against a
# staging path, with nothing in the message about which cache was holding the
# space. Failing here says that instead.
#
# There is nothing to self-clear on this path and it is not skipped in error:
# a non-zero requirement means the seed is about to CLONE, which means this
# run has no own target dir to wipe (seed_clone_source returns nothing when it
# does), so pass 3 has no candidate. See the header.
if [ "$FREE_KB" -lt "$CLONE_KB" ]; then
echo "::error::prune: $(gb "$FREE_KB") GB free after evicting every eligible cache, but seeding from $(basename "$SEED_SRC") needs $(gb "$CLONE_KB") GB — short by $(gb "$(( CLONE_KB - FREE_KB ))") GB"
summary_line "- **out of disk**: seeding from \`$(basename "$SEED_SRC")\` needs $(gb "$CLONE_KB") GB, $(gb "$FREE_KB") GB free"
echo "prune: kept, and why:"
for entry in ${KEPT[@]+"${KEPT[@]}"}; do echo " ${entry}"; done
[ "${#KEPT[@]}" -gt 0 ] || echo " (nothing — the volume holds no cache directories at all)"
exit 1
fi
if [ "$FREE_KB" -lt "$PCT_KB" ]; then
OWN_GB=$(usage_gb "$OWN_DIR") OWN_GB=$(usage_gb "$OWN_DIR")
echo "::warning::still under threshold after evicting every eligible sibling; clearing own $(basename "$OWN_DIR") (was ${OWN_GB} GB) — this run pays a cold rebuild" echo "::warning::still under threshold after evicting every eligible sibling; clearing own $(basename "$OWN_DIR") (was ${OWN_GB} GB) — this run pays a cold rebuild"
summary_line "- **self-clear**: \`$(basename "$OWN_DIR")\` (was ${OWN_GB} GB) wiped — this run pays a cold rebuild" summary_line "- **self-clear**: \`$(basename "$OWN_DIR")\` (was ${OWN_GB} GB) wiped — this run pays a cold rebuild"
# Recreated empty rather than left absent, and that is what keeps this path
# out of the requirement above: the seed reuses an own target dir that
# exists, whatever is in it, so a self-cleared run clones nothing and needs
# no headroom. Leaving it absent would send that run to the base snapshot
# instead, needing a clone this pass has just spent its last eligible bytes
# not sizing for.
rm -rf "$OWN_DIR" rm -rf "$OWN_DIR"
mkdir -p "$OWN_DIR" mkdir -p "$OWN_DIR"
else else
+4 -2
View File
@@ -53,9 +53,11 @@ pass_count=0
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; } fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; } ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
# Cargo and rustc REPLACE an artifact (write elsewhere, rename over the path) # rustc REPLACES an `.rlib`/`.rmeta` (writes elsewhere, renames over the path)
# rather than truncating it in place, which is exactly why a snapshot may # rather than truncating it in place, which is exactly why a snapshot may
# share artifact inodes with the live target dir it was cloned from. The # share those inodes with the live target dir it was cloned from. Linker
# outputs are the exception and are real-copied instead — see
# `unshare_mutable_paths` in cache-lib.sh. The
# fixtures here have to model that faithfully — a plain `>` redirect truncates # fixtures here have to model that faithfully — a plain `>` redirect truncates
# in place and would write straight through the shared inode into the # in place and would write straight through the shared inode into the
# snapshot and every consumer, which is a property of the test fixture, not of # snapshot and every consumer, which is a property of the test fixture, not of
+5 -4
View File
@@ -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#*:}"