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

Find what stalled the forge's event loop ~2.8 s just after 0.7.287 booted (the /healthz the release proof saw) #8688

Merged opened this

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

Merged

scout/forge:scout/7688-find-what-stalled-the-forge-s-event-loop → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/7688-find-what-stalled-the-forge-s-event-loop was merged into main as ccaea3d.

Review

Sign in to review this change.
4 files changed +72 −38
src/checks/service.ts +40 −22
@@ -12,7 +12,8 @@
1212 */
1313
1414 import { pullDir, runDir } from "../core/pulls.ts";
15 import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
15 import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
16 import { chmod, lstat, readdir, rm } from "node:fs/promises";
1617 import { tmpdir } from "node:os";
1718 import { join } from "node:path";
1819 import type { App } from "../app.ts";
@@ -317,8 +318,9 @@ export function requeueAbandonedChecks(app: App, staleMs: number): number {
317318 appendRunLog(app.config.dataDir, run.id, run.summary);
318319 announceRun(app, run);
319320 }
320 // On the same timer: what a dead or lapsed run left on the disk.
321 sweepCheckouts(app);
321 // On the same timer: what a dead or lapsed run left on the disk. Removing
322 // a checkout is thousands of unlinks: off the loop, never in front of /healthz (#7688).
323 sweepSoon(app);
322324 return swept.requeued.length + swept.failed.length + swept.cancelled.length;
323325 }
324326
@@ -328,32 +330,48 @@ export function requeueAbandonedChecks(app: App, staleMs: number): number {
328330 * permission, so permissions are restored and the removal tried again. A
329331 * removal that still fails is logged, never swallowed.
330332 */
331 export function removeRunDir(path: string): void {
333 export async function removeRunDir(path: string): Promise<void> {
332334 try {
333 rmSync(path, { recursive: true, force: true });
335 await rm(path, { recursive: true, force: true });
334336 return;
335337 } catch {
336338 /* fall through to restore permissions */
337339 }
338 const open = (p: string): void => {
340 const open = async (p: string): Promise<void> => {
339341 try {
340 if (lstatSync(p).isSymbolicLink()) return;
341 if (lstatSync(p).isDirectory()) {
342 chmodSync(p, 0o700);
343 for (const n of readdirSync(p)) open(join(p, n));
342 if ((await lstat(p)).isSymbolicLink()) return;
343 if ((await lstat(p)).isDirectory()) {
344 await chmod(p, 0o700);
345 for (const n of await readdir(p)) await open(join(p, n));
344346 }
345347 } catch {
346348 /* gone, or not ours: the retry says */
347349 }
348350 };
349 open(path);
351 await open(path);
350352 try {
351 rmSync(path, { recursive: true, force: true });
353 await rm(path, { recursive: true, force: true });
352354 } catch (error) {
353355 log.error("could not remove a run directory", { path, error: String(error) });
354356 }
355357 }
356358
359 /** The sweep of the checkouts under way, if any: one at a time. */
360 let sweeping: Promise<void> | undefined;
361
362 /**
363 * Sweep the checkouts in the background and answer when done. A sweep
364 * already running is the one answered, not doubled; a failure is logged.
365 */
366 export function sweepSoon(app: App, now: number = Date.now()): Promise<void> {
367 sweeping ??= sweepCheckouts(app, now)

reclaimIfFull calls sweepCheckouts directly and can run beside this sweep; route it through sweepSoon so there is one sweep at a time. Note a second caller passing now gets the first sweep result.

368 .catch((error) => log.error("could not sweep the checkouts", { error: String(error) }))
369 .finally(() => {
370 sweeping = undefined;
371 });
372 return sweeping;
373 }
374
357375 /** Where a check's checkout lives while it runs. */
358376 const checksRoot = (dataDir: string): string => join(dataDir, "checks");
359377
@@ -377,7 +395,7 @@ function gateMayBeRunning(path: string, now: number): boolean {
377395 * while it is younger than a gate's deadline, and past that while its
378396 * `check_gate` row is open, for as long as a check may run.
379397 */
380 export function sweepCheckouts(app: App, now: number = Date.now()): void {
398 export async function sweepCheckouts(app: App, now: number = Date.now()): Promise<void> {
381399 const root = checksRoot(app.config.dataDir);
382400 if (!existsSync(root)) return;
383401 const since = new Date(now - CHECK_TIMEOUT_MAX * 1000);
@@ -388,7 +406,7 @@ export function sweepCheckouts(app: App, now: number = Date.now()): void {
388406 const id = name.endsWith(".home") ? name.slice(0, -5) : name;
389407 const gate = GATE_DIR.exec(id);
390408 if (gate !== null) {
391 if (!gateHolding(app.db, gate[1]!, since)) removeRunDir(join(root, name));
409 if (!gateHolding(app.db, gate[1]!, since)) await removeRunDir(join(root, name));
392410 continue;
393411 }
394412 const run = getRun(app.db, id);
@@ -396,7 +414,7 @@ export function sweepCheckouts(app: App, now: number = Date.now()): void {
396414 run !== undefined &&
397415 run.state === "running" &&
398416 !leaseLapsed(run, STALE_AFTER_MS, now);
399 if (!live) removeRunDir(join(root, name));
417 if (!live) await removeRunDir(join(root, name));
400418 }
401419 }
402420
@@ -405,9 +423,9 @@ export function sweepCheckouts(app: App, now: number = Date.now()): void {
405423 * (#6945): checkouts leaked by a crash sat on the disk until the next boot
406424 * while every check failed red on it. Answers whether it swept.
407425 */
408 export function reclaimIfFull(app: App, now: number = Date.now()): boolean {
426 export async function reclaimIfFull(app: App, now: number = Date.now()): Promise<boolean> {
409427 if (diskRecovered(app.config.dataDir)) return false;
410 sweepCheckouts(app, now);
428 await sweepCheckouts(app, now);
411429 return true;
412430 }
413431
@@ -449,7 +467,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
449467 const settle = options.landSettleMs ?? 0;
450468 let stopped = false;
451469
452 sweepCheckouts(app);
470 sweepSoon(app);
453471
454472 async function request(
455473 repo: Repo,
@@ -1068,7 +1086,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
10681086 write(`already proven green by ${earlier}`);
10691087 return { ok: true, proven: earlier };
10701088 }
1071 if (reclaimIfFull(app)) write("disk under its free floor: swept unused checkouts");
1089 if (await reclaimIfFull(app)) write("disk under its free floor: swept unused checkouts");
10721090 const dir = join(checksRoot(app.config.dataDir), run.id);
10731091 mkdirSync(dir, { recursive: true });
10741092 write(mergeLine === "" ? `checking out ${run.commitOid.slice(0, 7)}` : mergeLine);
@@ -1077,7 +1095,7 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
10771095 if (base !== undefined) {
10781096 const given = await giveHistory(bare, dir, base, run.commitOid);
10791097 if (!given.ok) {
1080 rmSync(dir, { recursive: true, force: true });
1098 await rm(dir, { recursive: true, force: true });
10811099 return { ok: false, error: given.error };
10821100 }
10831101 }
@@ -1421,8 +1439,8 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
14211439 if (key !== undefined) recordProven(app.db, key, x.id);
14221440 return { ok: true };
14231441 } finally {
1424 if (existsSync(dir)) removeRunDir(dir);
1425 removeRunDir(home);
1442 if (existsSync(dir)) await removeRunDir(dir);
1443 await removeRunDir(home);
14261444 }
14271445 }
14281446
test/checks-disk-red.test.ts +2 −2
@@ -166,10 +166,10 @@ describe("a full check machine gives back unused checkouts", () => {
166166 const orphan = join(dataDir, "checks", "orphan");
167167 mkdirSync(orphan, { recursive: true });
168168 diskReading.read = () => ({ freeBytes: 500, totalBytes: 1000 });
169 expect(reclaimIfFull(app)).toBe(false);
169 expect(await reclaimIfFull(app)).toBe(false);
170170 expect(existsSync(orphan)).toBe(true);
171171 diskReading.read = () => ({ freeBytes: 50, totalBytes: 1000 });
172 expect(reclaimIfFull(app)).toBe(true);
172 expect(await reclaimIfFull(app)).toBe(true);
173173 expect(existsSync(orphan)).toBe(false);
174174 } finally {
175175 diskReading.read = statDisk;
test/checks-home-sweep.test.ts +28 −14
@@ -13,7 +13,7 @@ import { tmpdir } from "node:os";
1313 import { join } from "node:path";
1414 import { createApp, type App } from "../src/app.ts";
1515 import { createRepo } from "../src/actions/repos.ts";
16 import { requeueAbandonedChecks, sweepCheckouts } from "../src/checks/service.ts";
16 import { requeueAbandonedChecks, sweepCheckouts, sweepSoon } from "../src/checks/service.ts";
1717 import { beginGate, endGate } from "../src/checks/gate_record.ts";
1818 import { ensureEnvData, removeEnv } from "../src/apps/data.ts";
1919 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
@@ -55,7 +55,7 @@ const populate = (id: string, readOnly = false): void => {
5555 };
5656 const run = () => createRun(app.db, { repoId, kind: "check", env: "x", commitOid: "a".repeat(40) });
5757
58 test("a finished run's home is removed, a live run's survives", () => {
58 test("a finished run's home is removed, a live run's survives", async () => {
5959 const live = run();
6060 claimRun(app.db, live.id);
6161 const done = run();
@@ -64,13 +64,13 @@ test("a finished run's home is removed, a live run's survives", () => {
6464 populate(live.id);
6565 populate(done.id);
6666 populate("no-such-run");
67 sweepCheckouts(app);
67 await sweepCheckouts(app);
6868 expect(existsSync(home(live.id))).toBe(true);
6969 expect(existsSync(home(done.id))).toBe(false);
7070 expect(existsSync(home("no-such-run"))).toBe(false);
7171 });
7272
73 test("the abandoned-checks timer sweeps a finished run's home, not a live one's", () => {
73 test("the abandoned-checks timer sweeps a finished run's home, not a live one's", async () => {
7474 const live = run();
7575 claimRun(app.db, live.id);
7676 const done = run();
@@ -79,11 +79,25 @@ test("the abandoned-checks timer sweeps a finished run's home, not a live one's"
7979 populate(live.id);
8080 populate(done.id);
8181 requeueAbandonedChecks(app, STALE_AFTER_MS);
82 // Removal is off the loop (#7688): the call returned with the home still there.
83 expect(existsSync(home(done.id))).toBe(true);
84 await sweepSoon(app);
8285 expect(existsSync(home(live.id))).toBe(true);
8386 expect(existsSync(home(done.id))).toBe(false);
8487 });
8588
86 test("the timer leaves a running gate's checkout and home alone, and removes them once no gate could be using them", () => {
89 test("sweeping a checkout hands the loop back before it finishes (#7688)", async () => {
90 const done = run();
91 claimRun(app.db, done.id);
92 finishRun(app.db, done.id, { state: "ok", summary: "ok" });
93 populate(done.id);
94 const sweep = sweepSoon(app);
95 expect(existsSync(home(done.id))).toBe(true);
96 await sweep;
97 expect(existsSync(home(done.id))).toBe(false);
98 });
99
100 test("the timer leaves a running gate's checkout and home alone, and removes them once no gate could be using them", async () => {
87101 const root = join(dataDir, "checks");
88102 const dir = join(root, "gate-0123456789ab");
89103 const home = `${dir}.home`;
@@ -92,29 +106,29 @@ test("the timer leaves a running gate's checkout and home alone, and removes the
92106 requeueAbandonedChecks(app, STALE_AFTER_MS);
93107 expect(existsSync(dir)).toBe(true);
94108 expect(existsSync(home)).toBe(true);
95 sweepCheckouts(app, Date.now() + GATE_DEADLINE_MS + 60_000);
109 await sweepCheckouts(app, Date.now() + GATE_DEADLINE_MS + 60_000);
96110 expect(existsSync(dir)).toBe(false);
97111 expect(existsSync(home)).toBe(false);
98112 });
99113
100 test("a lapsed run's home is removed", () => {
114 test("a lapsed run's home is removed", async () => {
101115 const r = run();
102116 claimRun(app.db, r.id);
103117 populate(r.id);
104 sweepCheckouts(app, Date.now() + 24 * 3600_000);
118 await sweepCheckouts(app, Date.now() + 24 * 3600_000);
105119 expect(existsSync(home(r.id))).toBe(false);
106120 });
107121
108 test("a home with a read-only file is still removed", () => {
122 test("a home with a read-only file is still removed", async () => {
109123 const r = run();
110124 claimRun(app.db, r.id);
111125 finishRun(app.db, r.id, { state: "failed", summary: "red" });
112126 populate(r.id, true);
113 sweepCheckouts(app);
127 await sweepCheckouts(app);
114128 expect(existsSync(home(r.id))).toBe(false);
115129 });
116130
117 test("tearing a preview down removes its home", () => {
131 test("tearing a preview down removes its home", async () => {
118132 ensureEnvData(dataDir, "app1", "pull-1", "empty");
119133 const h = join(dataDir, "apps", "app1", "pull-1", "home");
120134 mkdirSync(h, { recursive: true });
@@ -123,7 +137,7 @@ test("tearing a preview down removes its home", () => {
123137 expect(existsSync(h)).toBe(false);
124138 });
125139
126 test("a gate's open row keeps its checkout and home past the deadline window; a finished gate's go", () => {
140 test("a gate's open row keeps its checkout and home past the deadline window; a finished gate's go", async () => {
127141 const running = "b".repeat(40);
128142 const finished = "c".repeat(40);
129143 const open = beginGate(app.db, repoId, undefined, running);
@@ -133,13 +147,13 @@ test("a gate's open row keeps its checkout and home past the deadline window; a
133147 populate(gateRunId(oid));
134148 }
135149 // Past the young-checkout window a full suite still runs: the open row holds it.
136 sweepCheckouts(app, Date.now() + GATE_DEADLINE_MS + 60_000);
150 await sweepCheckouts(app, Date.now() + GATE_DEADLINE_MS + 60_000);
137151 expect(existsSync(join(dataDir, "checks", gateRunId(running), "node_modules"))).toBe(true);
138152 expect(existsSync(home(gateRunId(running)))).toBe(true);
139153 expect(existsSync(join(dataDir, "checks", gateRunId(finished)))).toBe(false);
140154 expect(existsSync(home(gateRunId(finished)))).toBe(false);
141155 // A row a crash left open holds no longer than a check may run.
142 sweepCheckouts(app, Date.now() + 3 * 3600_000);
156 await sweepCheckouts(app, Date.now() + 3 * 3600_000);
143157 expect(existsSync(join(dataDir, "checks", gateRunId(running)))).toBe(false);
144158 endGate(app.db, open);
145159 });
test/checks.test.ts +2
@@ -22,6 +22,7 @@ import {
2222 pickNext,
2323 requeueAbandonedChecks,
2424 startChecks,
25 sweepSoon,
2526 STARVATION_MS,
2627 type ChecksHandle,
2728 } from "../src/checks/service.ts";
@@ -1316,6 +1317,7 @@ describe("startChecks: a booting forge removes only checkouts nobody is running
13161317 for (const id of [live.id, lapsed.id, "no-such-run"])
13171318 mkdirSync(where(id), { recursive: true });
13181319 const handle = startChecks(db, { forgeUrl: "http://localhost" });
1320 await sweepSoon(db);
13191321 await handle.stop();
13201322 expect(existsSync(where(live.id))).toBe(true);
13211323 expect(existsSync(where(lapsed.id))).toBe(false);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs