Public: anyone can read this; the crew can change it.

Evict the shared check install cache by age or size (<data>/check-install/<repo>/bun) #7659

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7574-evict-the-shared-check-install-cache-by → main 6 commits fork 3 threads · 3 unresolved

  • Merged. scout/7574-evict-the-shared-check-install-cache-by was merged into main as d87da6e.

Review

Sign in to review this change.
6 files changed +312 −7
docs/APPS.md +11 −1
@@ -717,7 +717,15 @@ fails and says `infrastructure`. A check in a cell installs into its own
717717 home, so its bun cache is shared with nothing. An uncontained check uses
718718 its repository's cache (`<data>/check-install/<repo>/bun`), and installs
719719 into it take turns under a lock file, so two never extract one package
720 into the same directory. A `job` check installs in its own pod and is not
720 into the same directory. That cache is bounded (`src/checks/install_evict.ts`,
721 #7574): a fill, or an uncontained install, marks the packages its `bun.lock`
722 names as lent, and under the same lock, in the background and at most hourly,
723 the forge removes entries not lent for 30 days (`FORGE_CHECK_CACHE_DAYS`), then
724 the oldest until the repository's cache is under 2 GB (`FORGE_CHECK_CACHE_MB`).
725 An evicted package is fetched again by the next install. Only the text
726 `bun.lock` is read: a repository on a binary `bun.lockb` lends nothing, so its
727 packages age out after 30 days and are fetched again (a cell never fills from
728 one at all). A `job` check installs in its own pod and is not
721729 retried.
722730
723731 **An app environment installs from that same cache** (`src/apps/process.ts`,
@@ -1553,6 +1561,8 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
15531561 | `FORGE_CHECKS_DRAIN_S` | `10` | seconds a roll lets running checks finish first |
15541562 | `FORGE_CREW_DRAIN_S` | `10` | seconds a roll lets crew runs finish before they are queued again |
15551563 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
1564 | `FORGE_CHECK_CACHE_DAYS` | `30` | days a package may go unlent before the check install cache drops it |
1565 | `FORGE_CHECK_CACHE_MB` | `2048` | size one repository's check install cache is swept down to |
15561566 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
15571567 | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only |
15581568 | `FORGE_CREW_CPUS` | `4` | cores one crew run may use (`cpu.max` on its own cell cgroup, where the cpu controller is delegated) |
src/checks/install.ts +4 −1
@@ -21,6 +21,7 @@ import { tmpdir } from "node:os";
2121 import { dirname, join } from "node:path";
2222 import { formatBytes } from "../core/storage.ts";
2323 import { err, ok, type Result } from "../kernel/result.ts";
24 import { lend } from "./install_evict.ts";
2425
2526 /** What an install prints when the machine, not the code, failed it. */
2627 export const TRANSIENT_INSTALL = /Fail extracting tarball|EINTEGRITY|ECONNRESET/;
@@ -172,7 +173,9 @@ export async function prefillInstallCache(options: {
172173 try {
173174 await Promise.all([lines(proc.stdout), lines(proc.stderr)]);
174175 const code = await proc.exited;
175 return code === 0 ? ok(true) : err(`filling the install cache exited ${code}`);
176 if (code !== 0) return err(`filling the install cache exited ${code}`);
177 lend(cache, dir);
178 return ok(true);
176179 } finally {
177180 clearTimeout(timer);
178181 signal.removeEventListener("abort", stop);
src/checks/install_evict.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The shared check install cache has a bound (#7574): it is filled from every
3 * pull's lockfile and would otherwise grow by the union of every version any
4 * checked commit locked.
5 *
6 * - **A lend** is a fill, or an uncontained install, that found the lockfile's
7 * packages: it touches their entries ({@link lend}), so an entry's mtime is
8 * when a check last used it.
9 * - **Eviction** removes an entry not lent for {@link CACHE_MAX_AGE_DAYS}
10 * days, then the oldest until the cache is under {@link CACHE_MAX_BYTES}.
11 * It runs under the cache's lock, so a fill never sees a half-removed
12 * package, and off the request path ({@link evictSoon}, at most hourly).
13 * A cell reads the cache without the lock; an entry lent within the age
14 * bound is only taken by the size cap, and only after every older one.
15 */
16
17 import { existsSync, lutimesSync, readdirSync, readFileSync } from "node:fs";
18 import { lstat, readdir, rm } from "node:fs/promises";
19 import { join } from "node:path";
20 import { withLock } from "./install.ts";
21
22 export const CACHE_MAX_AGE_DAYS = 30;
23 export const CACHE_MAX_BYTES = 2 * 1024 ** 3;
24 const SWEEP_EVERY_MS = 60 * 60_000;
25
26 export interface Bound {
27 readonly maxAgeDays: number;
28 readonly maxBytes: number;
29 }
30
31 /** The bound, from `FORGE_CHECK_CACHE_DAYS` / `FORGE_CHECK_CACHE_MB` when they are positive numbers. */
32 export function boundFrom(env: Record<string, string | undefined>): Bound {
33 const num = (v: string | undefined): number | undefined => {
34 const n = Number(v);
35 return v !== undefined && Number.isFinite(n) && n > 0 ? n : undefined;
36 };
37 const mb = num(env["FORGE_CHECK_CACHE_MB"]);
38 return {
39 maxAgeDays: num(env["FORGE_CHECK_CACHE_DAYS"]) ?? CACHE_MAX_AGE_DAYS,
40 maxBytes: mb === undefined ? CACHE_MAX_BYTES : mb * 1024 ** 2,
41 };
42 }
43
44 function entries(cache: string): string[] {
45 return existsSync(cache) ? readdirSync(cache).filter((n) => !n.startsWith(".")) : [];
46 }
47
48 async function sizeOf(path: string): Promise<number> {
49 const st = await lstat(path).catch(() => undefined);
50 if (st === undefined) return 0;
51 if (!st.isDirectory()) return st.size;
52 let total = 0;
53 for (const name of await readdir(path).catch(() => [])) total += await sizeOf(join(path, name));
54 return total;
55 }
56
outdated

MEDIUM: lend runs inside the fill and is not guarded. A lutimesSync or readFileSync error, for example an entry removed concurrently by a cell or by bun, throws out of a fill that succeeded, and the pull sees an infrastructure failure. Wrap it in try/catch and ignore errors, then test that a failing lend leaves the fill ok.

57 /** Mark the entries the lockfile in `dir` names as used now. Hold the cache lock. */
58 export function lend(cache: string, dir: string, now = Date.now()): void {
59 try {
60 lendAll(cache, dir, now);
61 } catch {
62 // A lend is a hint to the sweep; an entry gone under it must not fail the fill.
63 }
64 }
65
66 function lendAll(cache: string, dir: string, now: number): void {
67 const lock = join(dir, "bun.lock");
68 if (!existsSync(lock)) return;
69 const ids = new Set<string>();
70 for (const m of readFileSync(lock, "utf8").matchAll(/"((?:@[^"/@]+\/)?[^"/@]+@\d[^"]*)"/g))
71 ids.add((m[1] ?? "").replace("/", "++") + "@@");
72 const at = new Date(now);
73 for (const name of entries(cache)) {
74 for (const id of ids)
75 if (name.startsWith(id)) {
76 lutimesSync(join(cache, name), at, at);
77 break;
78 }
79 }
80 }
81
82 /**
83 * Evict by age, then by size. Entries are measured before the lock, off the
84 * event loop; the lock covers only the removals, each re-checked for a lend
85 * since. Answers the entries removed, or undefined when the lock was not held.
86 */
outdated

HIGH: sizeOf uses synchronous readdir and lstat over the whole cache (up to 2 GB, thousands of files), inside the lock. This blocks the event loop, which breaks the sub-50ms promise for every other request. It can also run past LOCK_STALE_MS, so another fill steals the lock mid-removal. Fix: size entries asynchronously (fs/promises) and yield between entries. Alternatively refresh the lock while sweeping, or keep a size index so you do not walk the tree. Add a test that a fill is not blocked past the stale window.

87 export async function evictInstallCache(options: {
88 readonly cache: string;
89 readonly bound: Bound;
90 readonly signal: AbortSignal;
91 readonly now?: number | undefined;
92 }): Promise<string[] | undefined> {
93 const { cache, bound, signal } = options;
94 const now = options.now ?? Date.now();
95 const left: { name: string; mtime: number; size: number }[] = [];
96 for (const name of entries(cache)) {
97 const st = await lstat(join(cache, name)).catch(() => undefined);
98 if (st !== undefined) left.push({ name, mtime: st.mtimeMs, size: await sizeOf(join(cache, name)) });
99 }
100 left.sort((a, b) => a.mtime - b.mtime);
101 const cutoff = now - bound.maxAgeDays * 86_400_000;
102 let total = left.reduce((sum, e) => sum + e.size, 0);
103 const held = await withLock(
104 `${cache}.lock`,
105 async (): Promise<string[]> => {
106 const removed: string[] = [];
107 for (const e of left) {
108 if (e.mtime >= cutoff && total <= bound.maxBytes) break;
109 const st = await lstat(join(cache, e.name)).catch(() => undefined);
outdated

MEDIUM: lastSweep is stamped before the sweep runs, and the sweep uses the check signal passed from service.ts. If the check finishes or aborts, the lock wait aborts, the sweep returns [] silently, and no sweep runs for another hour. Use a process-lifetime signal for the background sweep, and stamp lastSweep only after a completed run. Test that an aborted sweep does not suppress the next one.

110 if (st === undefined) continue;
111 if (st.mtimeMs !== e.mtime) continue; // lent since it was measured
112 await rm(join(cache, e.name), { recursive: true, force: true });
113 total -= e.size;
114 removed.push(e.name);
115 }
116 return removed;
117 },
118 { signal },
119 );
120 return held.ok ? held.value : undefined;
121 }
122
123 const lastSweep = new Map<string, number>();
124 const sweeping = new Set<string>();
125 const lifetime = new AbortController().signal;
126
127 /**
128 * Sweep `cache` in the background, at most hourly after a completed sweep;
129 * never throws, never awaited by a check. It outlives the check that started
130 * it, so it answers to no check's signal. Resolves when the sweep is over.
131 */
132 export function evictSoon(
133 cache: string,
134 env: Record<string, string | undefined>,
135 signal: AbortSignal = lifetime,
136 ): Promise<void> {
137 const now = Date.now();
138 if (sweeping.has(cache) || now - (lastSweep.get(cache) ?? 0) < SWEEP_EVERY_MS) return Promise.resolve();
139 sweeping.add(cache);
140 return evictInstallCache({ cache, bound: boundFrom(env), signal })
141 .then((removed) => {
142 if (removed !== undefined) lastSweep.set(cache, now);
143 })
144 .catch(() => {})
145 .finally(() => sweeping.delete(cache));
146 }
src/checks/service.ts +12 −1
@@ -114,6 +114,7 @@ import {
114114 prefillInstallCache,
115115 withLock,
116116 } from "./install.ts";
117 import { evictSoon, lend } from "./install_evict.ts";
117118 import { meterEgress } from "../apps/egress.ts";
118119 import { checkStage, type GateStage } from "./gate_reason.ts";
119120 import { proofsInFlight } from "./proof.ts";
@@ -1247,11 +1248,21 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
12471248 });
12481249 if (!fill.ok) return fill;
12491250 filled = fill.value;
1251 void evictSoon(warm, process.env);
12501252 }
12511253 return once(log);
12521254 }
12531255 mkdirSync(shared, { recursive: true });
1254 const held = await withLock(`${shared}.lock`, () => once(log), { signal });
1256 const held = await withLock(
1257 `${shared}.lock`,
1258 async () => {
1259 const done = await once(log);
1260 if (done.ok) lend(shared, dir);
1261 return done;
1262 },
1263 { signal },
1264 );
1265 if (held.ok && held.value.ok) void evictSoon(shared, process.env);
12551266 return held.ok ? held.value : held;
12561267 },
12571268 { log: write, signal, retryMs: options.installRetryMs },
test/check-install.test.ts +135 −2
@@ -4,7 +4,7 @@
44 * machine's — queued again, never the pull's red.
55 */
66 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
7 import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7 import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs";
88 import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
@@ -22,6 +22,7 @@ import {
2222 transientLine,
2323 withLock,
2424 } from "../src/checks/install.ts";
25 import { boundFrom, evictInstallCache, evictSoon, lend } from "../src/checks/install_evict.ts";
2526 import { startChecks, type ChecksHandle } from "../src/checks/service.ts";
2627 import { INFRA_SUMMARY, listRuns, type Run } from "../src/core/runs.ts";
2728 import { createUser } from "../src/core/users.ts";
@@ -159,6 +160,7 @@ describe("a check whose install fails on the machine", () => {
159160 let checks: ChecksHandle;
160161 let flaky: Repo;
161162 let broken: Repo;
163 let lender: Repo;
162164
163165 // Fails once with bun's own line, then installs: the marker lives in the
164166 // checkout, which the retry shares and the next check does not.
@@ -181,7 +183,7 @@ describe("a check whose install fails on the machine", () => {
181183 adminIfFirst: true,
182184 });
183185 if (!a.ok) throw new Error(a.error);
184 const repoWith = async (name: string, script: string): Promise<Repo> => {
186 const repoWith = async (name: string, script: string, more: { path: string; content: string }[] = []): Promise<Repo> => {
185187 const made = await createRepo(app, a.value, { name, visibility: "public" });
186188 if (!made.ok) throw new Error(String(made.error));
187189 const c = await commitToBranch(app, a.value, made.value, {
@@ -189,6 +191,7 @@ describe("a check whose install fails on the machine", () => {
189191 files: [
190192 { path: "forge.json", content: forgeJson },
191193 { path: "install.sh", content: script },
194 ...more,
192195 ],
193196 });
194197 if (!c.ok) throw new Error(JSON.stringify(c.error));
@@ -196,6 +199,10 @@ describe("a check whose install fails on the machine", () => {
196199 };
197200 flaky = await repoWith("flaky", once);
198201 broken = await repoWith("broken", always);
202 // Leaves a package the lockfile names, and one it does not, both untouched for years.
203 const aged =
204 'for p in [email protected]@@@1 [email protected]@@@1; do mkdir -p "$BUN_INSTALL_CACHE_DIR/$p"; touch -d 2000-01-01 "$BUN_INSTALL_CACHE_DIR/$p"; done\n';
205 lender = await repoWith("lender", aged, [{ path: "bun.lock", content: '{"packages": {"a": ["[email protected]"]}}\n' }]);
199206 });
200207 afterAll(async () => {
201208 await checks.stop();
@@ -227,6 +234,16 @@ describe("a check whose install fails on the machine", () => {
227234 ).toBe(false);
228235 });
229236
237 test("an uncontained install lends what its lockfile names, and the sweep takes the rest (#7574)", async () => {
238 const runs = await settled(lender, (rs) => rs.some((r) => r.state === "ok" || r.state === "failed"));
239 expect(runs[0]?.state).toBe("ok");
240 const cache = checkInstallCache(dataDir, lender.id);
241 const deadline = Date.now() + 3_000;
242 while (existsSync(join(cache, "[email protected]@@@1")) && Date.now() < deadline) await Bun.sleep(50);
243 expect(existsSync(join(cache, "[email protected]@@@1"))).toBe(false);
244 expect(statSync(join(cache, "[email protected]@@@1")).mtimeMs).toBeGreaterThan(Date.now() - 86_400_000);
245 });
246
230247 test("failing twice is infrastructure: queued again, not red; a second time it fails saying so", async () => {
231248 const runs = await settled(broken, (rs) => rs.some((r) => r.state === "failed"));
232249 const first = runs.find((r) => r.attempt === 0)!;
@@ -347,6 +364,122 @@ describe("prefillInstallCache (#7543)", () => {
347364 }
348365 });
349366
367 test("eviction drops the entry not lent for days, keeps the lent one, and the next install is offline (#7574)", async () => {
368 const reg = await registry();
369 try {
370 const co = await checkout(reg.url, reg.dir);
371 const cache = join(reg.dir, "cache");
372 const fill = () => prefillInstallCache({ cache, dir: co, env: envFor(reg.dir, reg.url), log: () => {}, signal: never });
373 expect(await fill()).toEqual({ ok: true, value: true });
374 const stale = join(cache, "[email protected]@@@1");
375 mkdirSync(stale);
376 writeFileSync(join(stale, "package.json"), "{}");
377 const long = new Date(Date.now() - 90 * 86_400_000);
378 utimesSync(stale, long, long);
379 // The locked package is old too, until a fill lends it.
380 const lent = readdirSync(cache).filter((n) => n.startsWith("[email protected]"));
381 expect(lent.length).toBeGreaterThan(0);
382 for (const n of lent) utimesSync(join(cache, n), long, long);
383 expect(await fill()).toEqual({ ok: true, value: true });
384 const removed = await evictInstallCache({ cache, bound: boundFrom({}), signal: never });
385 expect(removed).toEqual(["[email protected]@@@1"]);
386 expect(existsSync(stale)).toBe(false);
387 expect(existsSync(`${cache}.lock`)).toBe(false);
388 reg.stop();
389 expect(await fill()).toEqual({ ok: true, value: true });
390 } finally {
391 reg.stop();
392 await rm(reg.dir, { recursive: true, force: true });
393 }
394 });
395
396 test("past the size cap the oldest go first; the bound reads its env", async () => {
397 const dir = await scratch();
398 try {
399 const cache = join(dir, "cache");
400 const now = Date.now();
401 for (const [i, name] of ["a@1@@@1", "b@1@@@1", "c@1@@@1"].entries()) {
402 mkdirSync(join(cache, name), { recursive: true });
403 writeFileSync(join(cache, name, "f"), "x".repeat(1000));
404 const t = new Date(now - (3 - i) * 1000);
405 utimesSync(join(cache, name), t, t);
406 }
407 const removed = await evictInstallCache({ cache, bound: { maxAgeDays: 30, maxBytes: 2500 }, signal: never, now });
408 expect(removed).toEqual(["a@1@@@1"]);
409 expect(boundFrom({ FORGE_CHECK_CACHE_DAYS: "2", FORGE_CHECK_CACHE_MB: "5" })).toEqual({ maxAgeDays: 2, maxBytes: 5 * 1024 ** 2 });
410 expect(boundFrom({ FORGE_CHECK_CACHE_DAYS: "x", FORGE_CHECK_CACHE_MB: "-1" }).maxAgeDays).toBe(30);
411 } finally {
412 await rm(dir, { recursive: true, force: true });
413 }
414 });
415
416 test("a failing lend leaves the fill alone", async () => {
417 const dir = await scratch();
418 try {
419 writeFileSync(join(dir, "bun.lock"), '"[email protected]"');
420 // A cache that is a file: reading it throws ENOTDIR inside the lend.
421 const cache = join(dir, "c");
422 writeFileSync(cache, "");
423 expect(() => lend(cache, dir)).not.toThrow();
424 } finally {
425 await rm(dir, { recursive: true, force: true });
426 }
427 });
428
429 test("a sweep runs once an hour per cache, and a second while one runs collapses", async () => {
430 const dir = await scratch();
431 try {
432 const cache = join(dir, "cache");
433 const old = new Date(Date.now() - 40 * 86_400_000);
434 const stale = (name: string): string => {
435 mkdirSync(join(cache, name), { recursive: true });
436 utimesSync(join(cache, name), old, old);
437 return join(cache, name);
438 };
439 const a = stale("a@1@@@1");
440 // Hold the cache's lock so the first sweep waits under it.
441 const held = await withLock(
442 `${cache}.lock`,
443 async () => {
444 const first = evictSoon(cache, {});
445 const second = evictSoon(cache, {});
446 // A second sweep would wait on this lock too; it must be over at once.
447 const over = await Promise.race([second.then(() => true), Bun.sleep(200).then(() => false)]);
448 expect(existsSync(a)).toBe(true);
449 return { first, over };
450 },
451 { signal: never },
452 );
453 expect(held.ok && held.value.over).toBe(true);
454 if (held.ok) await held.value.first;
455 expect(existsSync(a)).toBe(false);
456 const b = stale("b@1@@@1");
457 await evictSoon(cache, {});
458 expect(existsSync(b)).toBe(true);
459 } finally {
460 await rm(dir, { recursive: true, force: true });
461 }
462 });
463
464 test("an aborted sweep does not suppress the next one", async () => {
465 const dir = await scratch();
466 try {
467 const cache = join(dir, "cache");
468 mkdirSync(join(cache, "a@1@@@1"), { recursive: true });
469 writeFileSync(join(cache, "a@1@@@1", "f"), "x");
470 const old = new Date(Date.now() - 40 * 86_400_000);
471 utimesSync(join(cache, "a@1@@@1"), old, old);
472 const gone = new AbortController();
473 gone.abort();
474 expect(await evictInstallCache({ cache, bound: boundFrom({}), signal: gone.signal })).toBeUndefined();
475 expect(existsSync(join(cache, "a@1@@@1"))).toBe(true);
476 await evictSoon(cache, {});
477 expect(existsSync(join(cache, "a@1@@@1"))).toBe(false);
478 } finally {
479 await rm(dir, { recursive: true, force: true });
480 }
481 });
482
350483 test("a fill's timeout is clamped under the lock's staleness", () => {
351484 expect(fillTimeoutMs(undefined)).toBe(LOCK_STALE_MS / 2);
352485 expect(fillTimeoutMs(LOCK_STALE_MS * 4)).toBe(LOCK_STALE_MS / 2);
test/tui-egress.test.ts +4 −2
@@ -41,7 +41,8 @@ describe("private destinations in the terminal", () => {
4141 listEvents(forge.db, { repoId: repo.id }).filter((e) => e.kind.startsWith("app.egress."));
4242
4343 function boot(user: User) {
44 const terminal = headlessTerminal(120, 32);
44 // Wide enough that the About card's hint line reaches `e egress`.
45 const terminal = headlessTerminal(180, 32);
4546 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
4647 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
4748 app.start();
@@ -205,7 +206,8 @@ describe("private destinations in the terminal", () => {
205206 const before = changes().length;
206207 const view = await egressView(forge, erin, repo);
207208 expect(view.ok && view.value.rows.length).toBe(2);
208 const terminal = headlessTerminal(120, 32);
209 // Wide enough that the About card's hint line reaches `e egress`.
210 const terminal = headlessTerminal(180, 32);
209211 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user: erin };
210212 const { app, session } = createForgeApp(forge, identity, { terminal, exitHooks: false });
211213 app.start();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs