diff --git a/.github/scripts/cache-prune.js b/.github/scripts/cache-prune.js index 3751d4389..5e7a158aa 100644 --- a/.github/scripts/cache-prune.js +++ b/.github/scripts/cache-prune.js @@ -13,12 +13,28 @@ // // Everything else is kept: fixed keys (setup-zig-tarball-*, the chipdb keyed on // part + image) and the newest entry of every series on every live ref. +// +// Then a budget. The newest entry of every series on every open pull request +// and its branch still came to 7.97 GB (130 entries) on 2026-10-05, and the +// saves between two prunes took the repository past 10 GB, where GitHub evicts +// whatever was read least recently, fixed keys included. The chipdb on a +// branch was gone within a day. So while the kept entries are over +// BUDGET_BYTES, setup-zig's per-run entries on refs other than main are +// dropped, least recently read first. Nothing else is dropped for the budget, +// including a chipdb whose key ends in a run id: a chipdb hit saves about +// three minutes of build, a Zig cache hit tens of seconds at most. 'use strict'; +// Under GitHub's 10 GB, with room for the saves of a few runs between prunes. +const BUDGET_BYTES = 8e9; + // A run id (8 or more digits) at the end of a key, with an optional attempt. const RUN_SUFFIX = /-\d{8,}(?:-\d+)?$/; +// What mlugg/setup-zig saves on every run; the only entries the budget spends. +const ZIG_CACHE = 'setup-zig-cache-'; + // The key with its run id removed, or null when the key is fixed. function seriesOf(key) { const perRun = RUN_SUFFIX.test(key); @@ -39,11 +55,16 @@ function newestFirst(a, b) { return a.created_at < b.created_at ? 1 : -1; } -// caches: [{id, key, ref, size_in_bytes, created_at}] as the REST API returns them +function lastRead(c) { + return c.last_accessed_at || c.created_at; +} + +// caches: [{id, key, ref, size_in_bytes, created_at, last_accessed_at}] as the REST API returns them // closed: Set of pull request numbers that are closed // goneRefs: Set of branch refs (refs/heads/...) that no longer exist +// budget: bytes the kept entries may take; see BUDGET_BYTES // Returns {drop: [{entry, reason}], keep: [entry]}. -function plan(caches, closed, goneRefs) { +function plan(caches, closed, goneRefs, budget = Infinity) { const drop = []; const keep = []; const series = new Map(); @@ -78,7 +99,20 @@ function plan(caches, closed, goneRefs) { drop.push({ entry: c, reason: `superseded by ${newest} on ${slot.split('\n')[0]}` }); } } - return { drop, keep }; + + let kept = bytes(keep); + const spendable = keep + .filter((c) => c.key.startsWith(ZIG_CACHE) && seriesOf(c.key) !== null && c.ref !== 'refs/heads/main') + .sort((a, b) => (lastRead(a) < lastRead(b) ? -1 : lastRead(a) > lastRead(b) ? 1 : a.id - b.id)); + const over = new Set(); + for (const c of spendable) { + const fits = kept <= budget; + if (fits) break; + over.add(c); + kept -= c.size_in_bytes; + drop.push({ entry: c, reason: `over the ${gb(budget)} GB budget, last read ${lastRead(c)}` }); + } + return { drop, keep: keep.filter((c) => !over.has(c)) }; } function bytes(list) { @@ -89,7 +123,7 @@ function gb(n) { return (n / 1e9).toFixed(2); } -async function run({ github, context, core, dryRun }) { +async function run({ github, context, core, dryRun, budget = BUDGET_BYTES }) { const { owner, repo } = context.repo; const caches = await github.paginate(github.rest.actions.getActionsCacheList, { owner, repo, per_page: 100, @@ -116,10 +150,10 @@ async function run({ github, context, core, dryRun }) { } } - const { drop, keep } = plan(caches, closed, goneRefs); + const { drop, keep } = plan(caches, closed, goneRefs, budget); core.info(`${caches.length} entries, ${gb(bytes(caches))} GB; ` + - `drop ${drop.length} (${gb(bytes(drop.map((d) => d.entry)))} GB), keep ${keep.length} (${gb(bytes(keep))} GB)` + - (dryRun ? ' [dry run]' : '')); + `drop ${drop.length} (${gb(bytes(drop.map((d) => d.entry)))} GB), keep ${keep.length} (${gb(bytes(keep))} GB), ` + + `budget ${gb(budget)} GB` + (dryRun ? ' [dry run]' : '')); let deleted = 0; let deletedBytes = 0; @@ -152,4 +186,4 @@ async function run({ github, context, core, dryRun }) { if (failed > 0) core.setFailed(`${failed} cache entries could not be deleted`); } -module.exports = { plan, run, seriesOf, prNumberOf }; +module.exports = { plan, run, seriesOf, prNumberOf, BUDGET_BYTES }; diff --git a/.github/scripts/cache-prune.test.js b/.github/scripts/cache-prune.test.js index cf8ac1294..9c1b088d8 100644 --- a/.github/scripts/cache-prune.test.js +++ b/.github/scripts/cache-prune.test.js @@ -3,12 +3,15 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { plan, run, seriesOf, prNumberOf } = require('./cache-prune.js'); +const { plan, run, seriesOf, prNumberOf, BUDGET_BYTES } = require('./cache-prune.js'); const ZIG = 'setup-zig-cache-v2-brain_unit-zig-x86_64-linux-0.16.0-'; let nextId = 1; -function entry(ref, key, created, size = 100) { - return { id: nextId++, ref, key, created_at: created, size_in_bytes: size }; +function entry(ref, key, created, size = 100, accessed = undefined) { + const e = { id: nextId++, ref, key, created_at: created, size_in_bytes: size }; + const read = accessed !== undefined; + if (read) e.last_accessed_at = accessed; + return e; } const ids = (list) => list.map((x) => (x.entry || x).id).sort((a, b) => a - b); @@ -86,6 +89,72 @@ test('plan accounts for every entry exactly once', () => { assert.deepEqual([...ids(drop), ...ids(keep)].sort((a, b) => a - b), ids(all)); }); +const CHIPDB = 'chipdb-xc7a200tfbg484-2-0123abc-bbasm-le-v1'; + +test('over the budget, per-run entries off main go, least recently read first', () => { + const a = entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100, '2026-10-04T03:00:00Z'); + const b = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z', 100, '2026-10-04T01:00:00Z'); + const c = entry('refs/pull/853/merge', `${ZIG}-37000000003-1`, '2026-10-03T00:00:00Z', 100, '2026-10-04T02:00:00Z'); + const { drop, keep } = plan([a, b, c], new Set(), new Set(), 150); + assert.deepEqual(ids(drop), ids([b, c])); + assert.deepEqual(ids(keep), ids([a])); + assert.match(drop[0].reason, /budget/); +}); + +test('the budget never drops a fixed key or an entry on main, even when still over', () => { + const chip = entry('refs/pull/852/merge', CHIPDB, '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z'); + const tar = entry('refs/heads/fix/x', 'setup-zig-tarball-x', '2026-10-01T00:00:00Z', 60, '2026-10-01T00:00:00Z'); + const main = entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100, '2026-10-01T00:00:00Z'); + const pr = entry('refs/pull/852/merge', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z'); + const { drop, keep } = plan([chip, tar, main, pr], new Set(), new Set(), 10); + assert.deepEqual(ids(drop), ids([pr])); + assert.deepEqual(ids(keep), ids([chip, tar, main])); +}); + +// A chipdb keyed on its run id, as a branch saved on 2026-10-05. +test('the budget never drops a chipdb, even one whose key ends in a run id', () => { + const chip = entry('refs/heads/fix/x', 'chipdb-xc7a200tfbg484-2-regymm-37246227964', '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z'); + const zig = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z'); + assert.notEqual(seriesOf(chip.key), null); + const { drop, keep } = plan([chip, zig], new Set(), new Set(), 10); + assert.deepEqual(ids(drop), ids([zig])); + assert.deepEqual(ids(keep), ids([chip])); +}); + +test('the budget never drops a setup-zig cache key that has no run id', () => { + const fixed = entry('refs/heads/fix/x', `${ZIG}-pinned`, '2026-10-01T00:00:00Z', 70, '2026-10-01T00:00:00Z'); + const zig = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-05T00:00:00Z', 100, '2026-10-05T00:00:00Z'); + const { drop, keep } = plan([fixed, zig], new Set(), new Set(), 10); + assert.deepEqual(ids(drop), ids([zig])); + assert.deepEqual(ids(keep), ids([fixed])); +}); + +test('under the budget, the budget drops nothing', () => { + const a = entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', 100); + const b = entry('refs/heads/fix/x', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z', 100); + const { drop } = plan([a, b], new Set(), new Set(), 200); + assert.equal(drop.length, 0); +}); + +test('the last read decides, not the creation time', () => { + const oldButRead = entry('refs/pull/1/merge', `${ZIG}-37000000001-1`, '2026-09-01T00:00:00Z', 100, '2026-10-05T00:00:00Z'); + const newButIdle = entry('refs/pull/2/merge', `${ZIG}-37000000002-1`, '2026-10-04T00:00:00Z', 100, '2026-10-04T00:00:00Z'); + const { drop } = plan([oldButRead, newButIdle], new Set(), new Set(), 100); + assert.deepEqual(ids(drop), ids([newButIdle])); +}); + +test('with a budget, plan still accounts for every entry exactly once', () => { + const all = [ + entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z'), + entry('refs/heads/main', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/pull/1/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/pull/2/merge', `${ZIG}-37000000002-1`, '2026-10-02T00:00:00Z'), + entry('refs/pull/2/merge', CHIPDB, '2026-10-02T00:00:00Z'), + ]; + const { drop, keep } = plan(all, new Set([1]), new Set(), 150); + assert.deepEqual([...ids(drop), ...ids(keep)].sort((a, b) => a - b), ids(all)); +}); + // A stand-in for the github-script client: enough of octokit for run(). function fakeGithub(caches, { closed = [], gone = [], deleteStatus = {} } = {}) { const deleted = []; @@ -151,6 +220,26 @@ test('a real run deletes exactly what plan drops', async () => { assert.equal(core.failed, null); }); +test('run passes its budget to plan, and defaults to BUDGET_BYTES', async () => { + const open = (size) => [ + entry('refs/pull/852/merge', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', size), + entry('refs/heads/main', `${ZIG}-37000000001-1`, '2026-10-01T00:00:00Z', size), + ]; + const tight = open(100); + const g1 = fakeGithub(tight); + await run({ github: g1, context, core: fakeCore(), dryRun: false, budget: 0 }); + assert.deepEqual(g1.deleted, ids([tight[0]])); + const g2 = fakeGithub(open(100)); + await run({ github: g2, context, core: fakeCore(), dryRun: false }); + assert.deepEqual(g2.deleted, []); + // 2 x 5 GB is over the default 8 GB, so the default budget drops the pull request's entry. + const big = open(5e9); + const g3 = fakeGithub(big); + await run({ github: g3, context, core: fakeCore(), dryRun: false }); + assert.deepEqual(g3.deleted, ids([big[0]])); + assert.equal(BUDGET_BYTES, 8e9); +}); + test('an entry already deleted by someone else is not a failure', async () => { const caches = fixture(); const github = fakeGithub(caches, { closed: [839], deleteStatus: { [caches[0].id]: 404 } }); diff --git a/.github/workflows/actions-cache-prune.yml b/.github/workflows/actions-cache-prune.yml index 2d8d15e1f..4288a2dfb 100644 --- a/.github/workflows/actions-cache-prune.yml +++ b/.github/workflows/actions-cache-prune.yml @@ -11,10 +11,19 @@ name: actions-cache-prune # The rules live in .github/scripts/cache-prune.js and are tested by # .github/scripts/cache-prune.test.js, which this job runs before it deletes # anything. +# +# The hourly schedule alone was not enough: between this workflow's merge +# (2026-10-04 18:41Z) and 03:46Z it started 2 runs in 9 slots, and the cache +# went from 7.97 GB kept to over 11 GB. So it also runs after the two +# workflows that save the most (brain-ci 8.59 GB and CI 1.85 GB of 10.84 GB on +# 2026-10-05). The concurrency group below holds at most one pending run. on: pull_request: types: [closed] + workflow_run: + workflows: ['S³AI Brain CI — "Functional MRI" Gate', CI] + types: [completed] schedule: - cron: '41 * * * *' workflow_dispatch: