From 6ab73d0e6ed2ae3832a54305adf7f844843ef4ab Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Mon, 5 Oct 2026 10:55:13 +0700 Subject: [PATCH] ci(cache): prune to an 8 GB budget, and after brain-ci and CI The prune from #853 keeps the newest entry of every series on every live ref. On 2026-10-05 that alone came to 7.97 GB (130 entries), and the saves between two prunes took the repository past 10 GB, where GitHub evicts by last read, fixed keys included. The chipdb saved on fix/ci-chipdb-cache on 2026-10-04 was gone by the next morning, and the LED one-hot run on #852 rebuilt it (2 m 54 s). Two changes: - cache-prune.js: after the existing rules, while the kept entries are over BUDGET_BYTES (8e9), drop setup-zig's per-run entries on refs other than main, least recently read first. Nothing else is dropped for the budget: not a fixed key, not main, and not a chipdb whose key ends in a run id (one branch saves chipdb-...-regymm-). A chipdb hit saves about three minutes; a Zig cache hit tens of seconds at most. - actions-cache-prune.yml: also run on workflow_run after brain-ci and CI complete. The hourly schedule started 2 runs in 9 slots between 18:41Z and 03:46Z. brain-ci's jobs hold 8.59 GB and CI's 1.85 GB of 10.84 GB. Eight new tests (22 in all); 13 of 13 mutants of the budget rule are killed. On the 162-entry list of 2026-10-05 the plan drops 43 entries (3.06 GB), 10 of them (0.72 GB) for the budget, and keeps 119 (7.78 GB), including all 5 chipdb entries and all 9 entries on main. Refs #844 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/cache-prune.js | 50 ++++++++++-- .github/scripts/cache-prune.test.js | 95 ++++++++++++++++++++++- .github/workflows/actions-cache-prune.yml | 9 +++ 3 files changed, 143 insertions(+), 11 deletions(-) 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: