docs(cache): write down the producer half of the leftover naming contract #8
@@ -115,6 +115,10 @@ env:
|
|||||||
the only thing a consumer ever clones from.
|
the only thing a consumer ever clones from.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Transient dot-prefixed entries appear alongside those two — staging trees,
|
||||||
|
eviction asides, reader markers. Their names are a contract with the host that
|
||||||
|
owns the volume; see [Scratch names in a cache root](#scratch-names-in-a-cache-root-are-a-cross-repo-contract).
|
||||||
|
|
||||||
`<key>` is the ref sanitised to a safe path component, capped at 48
|
`<key>` is the ref sanitised to a safe path component, capped at 48
|
||||||
characters, plus an 8-hex SHA-1 prefix of the *raw* ref. The hash is not
|
characters, plus an 8-hex SHA-1 prefix of the *raw* ref. The hash is not
|
||||||
decoration: `feat/foo` and `feat-foo` sanitise identically and would otherwise
|
decoration: `feat/foo` and `feat-foo` sanitise identically and would otherwise
|
||||||
@@ -228,6 +232,54 @@ last build, where the historically-correct mtime is exactly the wrong answer.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Scratch names in a cache root are a cross-repo contract
|
||||||
|
|
||||||
|
Read this before adding a dot-prefixed directory under a cache root.
|
||||||
|
|
||||||
|
The volume is not swept by these scripts alone. A host-level arbiter —
|
||||||
|
daniel/gitdan's `scripts/ci-cache-reclaim.sh`, which runs outside any job —
|
||||||
|
reclaims the scratch trees a killed job strands here, and reads the reader
|
||||||
|
markers to decide whether a tree is still live. Its `LEFTOVER NAMING CONTRACT`
|
||||||
|
block is the canonical description; this side owns the names.
|
||||||
|
|
||||||
|
| name | produced by | if the job dies holding it |
|
||||||
|
|---|---|---|
|
||||||
|
| `.stage-<tag>` | `cache-lib.sh`, `hardlink_clone_into()` | stranded; only the arbiter reclaims it |
|
||||||
|
| `.publish-new-<tag>` | `publish-snapshot.sh` | stranded, and not in the arbiter's list |
|
||||||
|
| `.publish-old-<key>-<tag>` | `publish-snapshot.sh` | swept by the next publish of that key |
|
||||||
|
| `.evicting-<name>-<pid>` | `prune-cache.sh`, `evict_dir()` | swept at the start of the next prune pass |
|
||||||
|
| `.reading-<source>-<tag>` | `cache-lib.sh`, `reader_lock_acquire()` | not garbage — see below |
|
||||||
|
|
||||||
|
A `.reading-*` marker is protective, not scratch: it is how both this repo's
|
||||||
|
prune pass and the arbiter tell an in-flight clone from an abandoned one, and
|
||||||
|
the arbiter never deletes one. Delete a live marker and the tree it covers
|
||||||
|
becomes eligible for an unlink underneath the walk that is reading it, which is
|
||||||
|
the silent truncation the whole interlock exists to prevent.
|
||||||
|
|
||||||
|
**The rule: no new dot-prefixed entry under a cache root without a matching
|
||||||
|
prefix in `ci-cache-reclaim.sh`.** Adding a shape counts exactly as much as
|
||||||
|
renaming one. That script enumerates by explicit prefix rather than by dotglob
|
||||||
|
— deliberately, because a dotglob would pull reader markers into the candidate
|
||||||
|
stream alongside the trees they protect — so a name it has not been told about
|
||||||
|
is not handled conservatively, it is invisible, and an unreclaimed staging tree
|
||||||
|
is a full clone of a multi-GB target dir on the one volume whose entire problem
|
||||||
|
is disk. `.publish-new-` is the standing example: it strands exactly as
|
||||||
|
`.stage-` does and is in neither side's sweep.
|
||||||
|
|
||||||
|
**The staleness constants are part of the same contract, and that half has a
|
||||||
|
direction.** `CACHE_READ_STALE_SECONDS` (`cache-lib.sh`) and
|
||||||
|
`STALE_LOCK_SECONDS` (`prune-cache.sh`) are mirrored there, and the arbiter's
|
||||||
|
copies must be **greater than or equal to** these. Raising one here for longer
|
||||||
|
jobs, without raising its mirror first, makes the arbiter treat a marker whose
|
||||||
|
owner still considers it live as stale and delete a tree under an in-flight
|
||||||
|
clone — and its own minimum-age guard does not back-stop that, since a clone
|
||||||
|
holding a three-hour-old marker has a roughly three-hour-old staging tree.
|
||||||
|
Lowering either here needs no coordination: the arbiter then only defers a
|
||||||
|
reclamation this side would already have permitted, which costs disk rather
|
||||||
|
than correctness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
### `cargo-cache`
|
### `cargo-cache`
|
||||||
|
|||||||
@@ -8,6 +8,64 @@
|
|||||||
# needs as an argument, so the selftests can drive them against scratch
|
# needs as an argument, so the selftests can drive them against scratch
|
||||||
# directories without a CI context.
|
# directories without a CI context.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CROSS-REPO CONTRACT: the dot-prefixed names left in a cache root
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# The volume these scripts write into is also swept by a host-level arbiter
|
||||||
|
# that runs outside any job and outside this repo: daniel/gitdan's
|
||||||
|
# `scripts/ci-cache-reclaim.sh`. It reclaims the dot-prefixed trees a killed
|
||||||
|
# job strands here, and it reads the reader markers below to decide whether one
|
||||||
|
# of those trees is still live. Its `LEFTOVER NAMING CONTRACT` block is the
|
||||||
|
# canonical description of the arrangement; what follows is the producing
|
||||||
|
# side's half — which names this repo creates, and what changing one obliges.
|
||||||
|
#
|
||||||
|
# Created directly under a cache root:
|
||||||
|
#
|
||||||
|
# .stage-<tag> cache-lib.sh, hardlink_clone_into(): the tree a
|
||||||
|
# clone is built in before the atomic rename that
|
||||||
|
# gives it its real name. <tag> is unique per job
|
||||||
|
# per run, so a job killed before the rename
|
||||||
|
# strands a whole hardlink clone under a name no
|
||||||
|
# later run reuses. Nothing in this repo sweeps it.
|
||||||
|
# .publish-new-<tag> publish-snapshot.sh: the staged snapshot, between
|
||||||
|
# its clone and the swap. Strands the same way.
|
||||||
|
# .publish-old-<key>-<tag> publish-snapshot.sh: the rotated-away generation,
|
||||||
|
# kept while a reader still holds it and swept by
|
||||||
|
# the next publish of the same key.
|
||||||
|
# .evicting-<name>-<pid> prune-cache.sh, evict_dir(): a cache renamed
|
||||||
|
# aside so the decision to unlink it can be retaken
|
||||||
|
# after the rename. Swept at the start of every
|
||||||
|
# prune pass, so it only strands when this repo's
|
||||||
|
# workflow stops running at all.
|
||||||
|
# .reading-<source>-<tag> cache-lib.sh, reader_lock_acquire(): NOT garbage.
|
||||||
|
# It is the live-reader signal the arbiter reads,
|
||||||
|
# and the one shape it must never delete — removing
|
||||||
|
# one clears the way to unlink a tree out from
|
||||||
|
# under an in-flight walk, which is the silent
|
||||||
|
# truncation this whole interlock exists to
|
||||||
|
# prevent.
|
||||||
|
#
|
||||||
|
# THE RULE, which the arbiter states as its own: no new dot-prefixed entry
|
||||||
|
# under a cache root without a matching prefix in that script. ADDING a shape
|
||||||
|
# counts exactly as much as renaming one, because that script enumerates by
|
||||||
|
# explicit prefix rather than by dotglob — deliberately, since a dotglob would
|
||||||
|
# pull reader markers into the candidate stream alongside the trees they
|
||||||
|
# protect. A shape it has not been told about is not handled conservatively, it
|
||||||
|
# is invisible: an unreclaimed staging tree is a full clone of a multi-GB
|
||||||
|
# target dir on the one volume whose entire problem is disk.
|
||||||
|
#
|
||||||
|
# Of the five above, the arbiter enumerates `.stage-`, `.evicting-` and
|
||||||
|
# `.reading-`. The two `.publish-*` names predate the contract and are not in
|
||||||
|
# it, which is why `.publish-new-` — tagged per job per run exactly as
|
||||||
|
# `.stage-` is — is a shape neither side reclaims today. Closing that is a
|
||||||
|
# change over there, not here; the rule above is what stops the list growing
|
||||||
|
# another one.
|
||||||
|
#
|
||||||
|
# The two staleness constants the arbiter mirrors are part of the same
|
||||||
|
# contract, and that half has a direction to it — see CACHE_READ_STALE_SECONDS
|
||||||
|
# below and STALE_LOCK_SECONDS in prune-cache.sh.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Cache keys
|
# Cache keys
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -249,8 +307,24 @@ CACHE_READ_GRACE_SECONDS="${CACHE_READ_GRACE_SECONDS:-300}"
|
|||||||
# A marker older than this belongs to a job the runner killed before it could
|
# A marker older than this belongs to a job the runner killed before it could
|
||||||
# clean up. Honouring one forever would let a crashed job pin an entire
|
# clean up. Honouring one forever would let a crashed job pin an entire
|
||||||
# snapshot generation on disk permanently.
|
# snapshot generation on disk permanently.
|
||||||
|
#
|
||||||
|
# RAISING THIS IS A CROSS-REPO CHANGE, and the drift is not symmetric.
|
||||||
|
# daniel/gitdan's ci-cache-reclaim.sh mirrors this value as
|
||||||
|
# CI_CACHE_READER_STALE_SECONDS (and CI_CACHE_LEFTOVER_MIN_AGE_SECONDS beside
|
||||||
|
# it), and its copies must be GREATER THAN OR EQUAL TO this one. Raise this for
|
||||||
|
# longer jobs while that one stays at 7200 and the arbiter reads a marker whose
|
||||||
|
# owner still considers it live as stale, then deletes the tree under an
|
||||||
|
# in-flight clone; its minimum-age guard does not back-stop that, because a
|
||||||
|
# clone holding a three-hour-old marker has a staging tree roughly three hours
|
||||||
|
# old too, so both of its guards pass. Raise theirs first. Lowering this one
|
||||||
|
# needs no coordination at all: the arbiter then defers a reclamation this side
|
||||||
|
# would already have permitted, which costs disk and not correctness.
|
||||||
CACHE_READ_STALE_SECONDS="${CACHE_READ_STALE_SECONDS:-7200}"
|
CACHE_READ_STALE_SECONDS="${CACHE_READ_STALE_SECONDS:-7200}"
|
||||||
|
|
||||||
|
# `.reading-<source>-<tag>` is a contract name, not a private one: the
|
||||||
|
# host-level arbiter reads these to tell a live clone from an abandoned one,
|
||||||
|
# and never deletes one. See the cross-repo contract at the top of this file
|
||||||
|
# before changing the spelling.
|
||||||
reader_marker_path() { printf '%s/.reading-%s-%s' "$1" "$2" "$3"; }
|
reader_marker_path() { printf '%s/.reading-%s-%s' "$1" "$2" "$3"; }
|
||||||
|
|
||||||
reader_lock_acquire() {
|
reader_lock_acquire() {
|
||||||
@@ -388,6 +462,10 @@ hardlink_clone_into() {
|
|||||||
|
|
||||||
parent=$(dirname "$dst")
|
parent=$(dirname "$dst")
|
||||||
src_name=$(basename "$src")
|
src_name=$(basename "$src")
|
||||||
|
# `.stage-<tag>` is a contract name (see the top of this file): a job killed
|
||||||
|
# between the copy below and the rename at the end strands this tree, and the
|
||||||
|
# only thing that ever reclaims one is the host-level arbiter, by this exact
|
||||||
|
# prefix.
|
||||||
tmp="${parent}/.stage-${tag}"
|
tmp="${parent}/.stage-${tag}"
|
||||||
|
|
||||||
attempt=1
|
attempt=1
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ ROOT="${1:?usage: prune-cache.sh <cache-root> <own-target-dir> <protected-branch
|
|||||||
OWN_DIR="${2:?}"
|
OWN_DIR="${2:?}"
|
||||||
PROTECTED_REFS="${3:-}"
|
PROTECTED_REFS="${3:-}"
|
||||||
MIN_FREE_PCT="${4:-10}"
|
MIN_FREE_PCT="${4:-10}"
|
||||||
|
# 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
|
||||||
|
# this side still honours as abandoned. Same direction and same reasoning as
|
||||||
|
# CACHE_READ_STALE_SECONDS; the argument is written out in cache-lib.sh.
|
||||||
STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-7200}"
|
STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-7200}"
|
||||||
# An aside directory is in flight for one rename plus one marker glob —
|
# An aside directory is in flight for one rename plus one marker glob —
|
||||||
# milliseconds. Anything older belongs to a pass that died between the two, so
|
# milliseconds. Anything older belongs to a pass that died between the two, so
|
||||||
@@ -174,6 +178,12 @@ is_locked() {
|
|||||||
# leaves the source inode unchanged, which is precisely why the publish side
|
# leaves the source inode unchanged, which is precisely why the publish side
|
||||||
# can rotate a snapshot out from under a live reader. A declined eviction
|
# can rotate a snapshot out from under a live reader. A declined eviction
|
||||||
# therefore costs a deferred eviction and nothing else.
|
# therefore costs a deferred eviction and nothing else.
|
||||||
|
#
|
||||||
|
# `.evicting-<name>-<pid>` is a contract name shared with daniel/gitdan's
|
||||||
|
# host-level arbiter (see the cross-repo contract at the top of cache-lib.sh).
|
||||||
|
# The sweep at the top of this script reclaims these on every pass, so the only
|
||||||
|
# one that reaches the arbiter belongs to a repo whose workflow has stopped
|
||||||
|
# running — which is exactly the case no in-workflow pass can reach.
|
||||||
evict_dir() {
|
evict_dir() {
|
||||||
local dir="$1" name aside
|
local dir="$1" name aside
|
||||||
name=$(basename "$dir")
|
name=$(basename "$dir")
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ SRC=$(target_dir_for "$ROOT" "$OWN_KEY")
|
|||||||
DST=$(snapshot_dir_for "$ROOT" "$OWN_KEY")
|
DST=$(snapshot_dir_for "$ROOT" "$OWN_KEY")
|
||||||
# Keyed by cache key as well as tag, so a deferred generation can be matched
|
# Keyed by cache key as well as tag, so a deferred generation can be matched
|
||||||
# back to the snapshot whose readers must drain before it is safe to reclaim.
|
# back to the snapshot whose readers must drain before it is safe to reclaim.
|
||||||
|
#
|
||||||
|
# `.publish-old-` and `.publish-new-` are dot-prefixed names under a cache
|
||||||
|
# root, so the cross-repo contract at the top of cache-lib.sh covers them:
|
||||||
|
# renaming either, or adding a third, requires a matching prefix in
|
||||||
|
# daniel/gitdan's ci-cache-reclaim.sh. Neither is in its list today, and
|
||||||
|
# `.publish-new-` is the shape that most needs to be — it is tagged per job per
|
||||||
|
# run, so a publisher killed between staging its snapshot and the swap below
|
||||||
|
# strands a full hardlink clone that no later run of this script will ever
|
||||||
|
# match. `.publish-old-` is milder: the sweep further down reclaims it on the
|
||||||
|
# next publish of the same key.
|
||||||
OLD="${ROOT}/.publish-old-${OWN_KEY}-${TAG}"
|
OLD="${ROOT}/.publish-old-${OWN_KEY}-${TAG}"
|
||||||
SNAP_NAME=$(basename "$DST")
|
SNAP_NAME=$(basename "$DST")
|
||||||
GRACE="${CACHE_READ_GRACE_SECONDS}"
|
GRACE="${CACHE_READ_GRACE_SECONDS}"
|
||||||
|
|||||||
Reference in New Issue
Block a user