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
This commit is contained in:
+54
-19
@@ -29,9 +29,18 @@ inputs:
|
||||
required: false
|
||||
default: 'dev main'
|
||||
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
|
||||
default: '10'
|
||||
default: '0'
|
||||
restore-mtimes:
|
||||
description: >-
|
||||
Restore every tracked file's mtime from git history. Requires a
|
||||
@@ -40,13 +49,20 @@ inputs:
|
||||
required: false
|
||||
default: 'true'
|
||||
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
|
||||
default: 'true'
|
||||
liveness-prune:
|
||||
description: >-
|
||||
Within the prune pass, remove caches for branches that no longer exist
|
||||
on origin. Set to false on a runner that cannot reach origin.
|
||||
Within the prune pass, remove caches for branches that are dead: gone
|
||||
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
|
||||
default: 'true'
|
||||
own-ref:
|
||||
@@ -169,6 +185,39 @@ runs:
|
||||
echo "CI_WATERMARK_FILE=${WATERMARK}"
|
||||
} >> "$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
|
||||
# scripts/seed-target-dir.sh — the staging-then-atomic-rename is what
|
||||
# makes concurrent jobs sharing one cache key safe by construction rather
|
||||
@@ -228,17 +277,3 @@ runs:
|
||||
CARGO_TARGET_DIR: ${{ steps.resolve.outputs.target-dir }}
|
||||
CI_WATERMARK_FILE: ${{ steps.resolve.outputs.watermark-file }}
|
||||
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 }}"
|
||||
|
||||
Reference in New Issue
Block a user