fix(prune-cache): stop the aside sweeper depending on timing it cannot see

Review findings on #2. The first is the one that mattered: the sweeper this
PR added had the shape the PR exists to remove. Pass A renames a candidate
aside; pass B's sweep sees an aside with no readers and reclaims it; A then
finds a reader and restores. `rm -rf` traverses fd-relative, so the rename
does not stop it and A can republish a half-emptied tree under a live cache
name. `capacity: 1` bounds it today, which is exactly the kind of reason
this PR was written to stop relying on.

The unlink itself was never the problem — the ordering proof covers it under
any interleaving, since the aside name only exists after the evicting pass's
rename. What was missing is that an aside with no readers is indistinguishable
from one a pass has just created and not yet decided about. The sweeper now
leaves an aside alone until it has settled (EVICTION_ASIDE_SETTLE_SECONDS,
default 60), which separates the two without having to identify the pass that
created it — a PID is meaningless across the job containers these passes run
in, and recycles. Read from ctime, not mtime: rename(2) updates the first and
leaves the second at whenever the cache was last written, which is the signal
list_by_lru wants and the wrong one here.

That is a bound, not a construction, and both the code comment and the README
now say which of the two properties is which instead of asserting the broader
one.

Also from the review: a pass that declined every dead cache it found no
longer signs off with "no dead-branch caches found", and evict_dir no longer
promises a later reclamation of an aside that is already gone.

Scenario 14 covers the settle window against the script's own default, with
nothing faked — the directory really was set aside a moment ago. Scenario 4
gains the summary assertion. 31 -> 37 assertions; each new gate verified red
by defeating it alone in a scratch copy.
This commit is contained in:
2026-08-23 23:30:56 -05:00
parent c6a3fa6d97
commit 65f0782233
3 changed files with 114 additions and 17 deletions
+11 -6
View File
@@ -182,9 +182,14 @@ not collapsed into one:
rename cannot resolve the path at all and starts cold instead. A cache
claimed inside that window is put back under its own name, and one whose
name a concurrent seed has taken in the meantime is left aside and reclaimed
by a later pass once its readers drain. Until this was structural it was
merely policy — snapshots belong to protected refs, protected refs are never
eviction candidates — which is a property held by vigilance rather than by
by a later pass once its readers drain. That later pass leaves an aside
directory alone until it has been set aside for a minute — not for the
unlink's sake, which the ordering proof above already covers, but so that a
pass still deciding about one is never mistaken for a pass that died holding
it. That settle window is a bound rather than a construction, and it is the
only part of this that is. Until the rest of it was structural it was merely
policy — snapshots belong to protected refs, protected refs are never
eviction candidates — a property held by vigilance rather than by
construction.
- **Every other way the source can change mid-clone is detected, not
prevented.** A `seed-fallback-dir` pointing at a directory something else
@@ -199,9 +204,9 @@ not collapsed into one:
residual is capped at one deferred generation per publisher ref, and its
real cost is close to inode count rather than byte count, since the
artifacts are hardlinked to whatever cloned them. A declined eviction is
bounded the same way: the cache stays, either under its own name or aside
awaiting the next pass, and is never unlinked under the job that claimed
it.
never unlinked under the job that claimed it; what it costs meanwhile is
disk, normally as the cache restored under its own name and otherwise as one
set aside for a later pass to reclaim.
**Eviction** runs three passes: caches for branches that no longer exist on
origin are removed unconditionally; then, only if free space is under the