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

A roll's old forge stops writing before the new one starts its work: write pause under 30 s #7826

Merged opened this

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

Merged

mason/forge:mason/7799-a-roll-s-old-forge-stops-writing-before → main 3 commits fork

  • Merged. mason/7799-a-roll-s-old-forge-stops-writing-before was merged into main as 767d396.

Review

Sign in to review this change.
10 files changed +322 −16
charts/app/templates/deployment.yaml +5 −4
@@ -32,10 +32,11 @@ spec:
3232 annotations:
3333 plat.sh/user-env-rev: {{ .Values.userEnvRev | default "init" | quote }}
3434 spec:
35 # The crew drains for three minutes, then runs are cut and resume on
36 # the next pod. Checks and merge-queue gates under way drain for
37 # FORGE_CHECKS_DRAIN_S (660 s, a full gate's ten minutes and more), so
38 # a roll does not cut a gate off; a minute more than that here (#6722).
35 # The crew, checks and merge-queue gates under way get ten seconds
36 # (FORGE_CREW_DRAIN_S, FORGE_CHECKS_DRAIN_S), then are handed back
37 # queued to resume on the next pod: the old pod stops writing at once,
38 # so the new one's queue work waits seconds, not minutes (#7799). The
39 # long grace stays for SSH sessions that are still connected.
3940 terminationGracePeriodSeconds: 720
4041 {{- with .Values.imagePullSecrets }}
4142 imagePullSecrets:
docs/APPS.md +2 −1
@@ -1500,7 +1500,8 @@ a link to the milestone (`docs/PLANNING.md`); its progress never does.
15001500 | `FORGE_CREW_CONCURRENCY` | 1 per 3 GiB above 2 GiB, 2–16 | agent runs at once (live: Settings → Capacity) |
15011501 | `FORGE_CHECKS_CONCURRENCY` | `1` | checks at once (live: Settings → Capacity) |
15021502 | `FORGE_CHECKS_PLACEMENT` | `process` | `job` runs a check's commands as a cluster Job (see Placement above) |
1503 | `FORGE_CHECKS_DRAIN_S` | `660` | seconds a roll lets running checks finish first |
1503 | `FORGE_CHECKS_DRAIN_S` | `10` | seconds a roll lets running checks finish first |
1504 | `FORGE_CREW_DRAIN_S` | `10` | seconds a roll lets crew runs finish before they are queued again |
15041505 | `FORGE_CHECK_SHARDS` | the machine's cores | test shards per check (live: Settings → Capacity) |
15051506 | `FORGE_CELLS_EGRESS` | `enforce` | `report` lets undeclared egress through on 80/443 (live: Settings → Capacity) |
15061507 | `FORGE_CELL_CGROUP` | `/sys/fs/cgroup` at a namespace root | the delegated cgroup v2 whose children cap each cell's memory; `off` watches only |
docs/ops/ROLL.md +21 −6
@@ -155,12 +155,27 @@ this, frozen.
155155 ## Gates in flight across a roll (#6722)
156156
157157 A roll does not turn a merge-queue gate red. On SIGTERM the old forge
158 starts no new gate and lets the gates under way finish for
159 `FORGE_CHECKS_DRAIN_S` (660 s: a full gate takes about ten minutes). The
160 chart's `terminationGracePeriodSeconds` is 720 for that reason, a minute
161 past the drain; lower one and lower the other with it, or the kubelet kills
162 the pod while its gates still run. The old and new pods share the node for
163 up to those 12 minutes, which section 1's rule already sizes for.
158 starts no new gate and gives the gates under way `FORGE_CHECKS_DRAIN_S`
159 (10 s) to finish, the crew's runs `FORGE_CREW_DRAIN_S` (10 s); what is left
160 is handed back queued. The old forge stops writing within seconds, so the
161 new forge's `waitForPeer` (`src/runs/peer.ts`) returns at once and the
162 release's write pause stays under 30 s (#7799). Before that the drain was
163 660 s and the pause as long. Both budgets are read in `src/drain.ts`; junk
164 falls back to 10 s.
165
166 A gate cut off at the drain is `interrupted`, never red, and the new forge
167 gates the same candidate again from the persisted queue
168 (`test/merge-queue-restart.test.ts`: "the run gone, the same candidate is
169 gated again" and the #6722 red case). A gate that outlives the drain on a
170 roll is therefore redone, not lost; set `FORGE_CHECKS_DRAIN_S` higher to
171 trade a longer write pause for fewer redone gates.
172
173 If a crew run ignores the abort at the end of its 10 s, the old forge's
174 leases stay renewed and the new forge logs "another forge still works this
175 database" and keeps serving pages but holds its queue work. It starts when
176 the run's process finally ends, or after `PEER_WAIT_MAX_MS` (720 s, the
177 pod's grace, after which the platform has stopped the old pod). Pages answer
178 throughout; only queue work is late.
164179
165180 What the drain does not finish is no verdict, never red:
166181
src/drain.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * How long a SIGTERM'd forge lets its work finish before it hands it back
3 * queued (#7799). The new forge holds its own queue work until the old one
4 * stops writing, so these budgets are the write pause of a release: seconds,
5 * not the ten minutes a full gate takes. What is cut off is queued again and
6 * resumes on the new forge.
7 */
8
9 /** Seconds, for checks, merge-queue gates and crew runs alike, unless the environment says. */
10 export const DRAIN_DEFAULT_S = 10;
11
12 export interface DrainBudgets {
13 readonly checksMs: number;
14 readonly crewMs: number;
15 }
16
17 /** A positive number of seconds, else the default: junk never lengthens a roll. */
18 function seconds(value: string | undefined): number {
19 const parsed = Number(value);
20 return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) || DRAIN_DEFAULT_S : DRAIN_DEFAULT_S;
21 }
22
23 /** `FORGE_CHECKS_DRAIN_S` and `FORGE_CREW_DRAIN_S`, in milliseconds. */
24 export function drainBudgets(env: Readonly<Record<string, string | undefined>>): DrainBudgets {
25 return {
26 checksMs: seconds(env["FORGE_CHECKS_DRAIN_S"]) * 1000,
27 crewMs: seconds(env["FORGE_CREW_DRAIN_S"]) * 1000,
28 };
29 }
src/main.ts +7 −4
@@ -25,6 +25,7 @@ import { startUpstreamWatch } from "./spawn/upstream.ts";
2525 import { pruneSessions } from "./core/sessions.ts";
2626 import { runDelivery } from "./core/webhooks.ts";
2727 import { isHookName, runHookClient } from "./git/hooks.ts";
28 import { drainBudgets } from "./drain.ts";
2829 import { drainStreams } from "./http/sse.ts";
2930 import { serve } from "./http/server.ts";
3031 import { checkDue } from "./actions/upstream_follow.ts";
@@ -405,9 +406,11 @@ const checks =
405406 redHoldMs: RED_HOLD_MS,
406407 landSettleMs: LAND_SETTLE_MS,
407408 // A roll lets running checks and merge-queue gates finish this long
408 // before cutting them off: a full gate takes about ten minutes, and
409 // the pod's grace (charts/app, 720 s) is longer still (#6722).
410 drainMs: positive(process.env.FORGE_CHECKS_DRAIN_S, 660) * 1000,
409 // before cutting them off, and no longer: the new forge holds its
410 // own queue work until this one stops writing, so the write pause
411 // of a release is this budget (#7799). A gate cut off is queued
412 // again and resumes on the new forge.
413 drainMs: drainBudgets(process.env).checksMs,
411414 // Main is verified after landing at most this often (#7786).
412415 verifyEveryMs: VERIFY_EVERY_MS,
413416 });
@@ -730,7 +733,7 @@ function drain(signal: NodeJS.Signals): Promise<unknown> {
730733 linger,
731734 prompts.stop(),
732735 notifier.stop(),
733 crew?.stop({ drain: true }),
736 crew?.stop({ drain: true, drainMs: drainBudgets(process.env).crewMs }),
734737 // Routers still send this process requests for the linger's length;
735738 // its apps answer them, and stop only after (#4496).
736739 (linger ?? Promise.resolve()).then(() => apps?.stop()).finally(() => presence?.stop()),
src/runs/peer.ts +1 −1
@@ -21,7 +21,7 @@ import { HEARTBEAT_MS, heldRuns } from "./lease.ts";
2121
2222 /** Two renewals missed: the lease is not being kept. */
2323 export const PEER_FRESH_MS = 2 * HEARTBEAT_MS;
24 /** The pod's grace (charts/app, 720 s): an old pod is gone by then. */
24 /** The pod's grace (charts/app, 720 s): an old pod is gone by then. A drain is 10 s (`src/drain.ts`); this is the ceiling for a run that ignores it. */
2525 export const PEER_WAIT_MAX_MS = 720_000;
2626 const POLL_MS = 5_000;
2727
test/crew-interrupted.test.ts +59
@@ -238,6 +238,65 @@ sleep 120
238238 expect(again[0]!.summary).toContain("carries on");
239239 }, 40_000);
240240
241 test("a draining crew hands a long run back queued within its budget (#7799)", async () => {
242 await crew?.stop();
243 crew = undefined;
244 // The run the test before put back on the queue would take the one slot.
245 for (const left of listQueued(app.db, "agent")) cancelRun(app.db, left.id, "cleared for the next test");
246 const created = await createRepo(app, alice, { name: "thicket", visibility: "private" });
247 if (!created.ok) throw new Error(String(created.error));
248 const repo = created.value;
249 const seeded = await commitToBranch(app, alice, repo, {
250 message: "Start",
251 files: [{ path: "README.md", content: "# thicket\n" }],
252 });
253 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
254 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
255 const issued = openIssue(app, alice, repo, { title: "Clear", body: "" });
256 if (!issued.ok) throw new Error(String(issued.error));
257 const issue = issued.value;
258 const long = join(dataDir, "long-claude");
259 writeFileSync(
260 long,
261 `#!/bin/sh
262 if [ "$1" = "--version" ]; then echo "2.1.0 (fake)"; exit 0; fi
263 cat > /dev/null &
264 echo '{"type":"system","subtype":"init","session_id":"long-1","model":"fake"}'
265 sleep 120
266 `,
267 );
268 chmodSync(long, 0o755);
269 const queuedRun = createRun(app.db, {
270 repoId: repo.id,
271 kind: "agent",
272 issueId: issue.id,
273 actorId: bot.id,
274 reason: "assigned",
275 });
276 crew = await startCrew(app, {
277 forgeUrl: servers.url,
278 localUrl: servers.url,
279 binary: long,
280 concurrency: 1,
281 budgetUsd: 1,
282 runTimeoutMs: 60_000,
283 });
284 const deadline = Date.now() + 20_000;
285 while (getRun(app.db, queuedRun.id)?.state !== "running" && Date.now() < deadline) await Bun.sleep(100);
286 expect(getRun(app.db, queuedRun.id)?.state).toBe("running");
287
288 const started = Date.now();
289 await crew.stop({ drain: true, drainMs: 1_000 });
290 crew = undefined;
291 expect(Date.now() - started).toBeLessThan(15_000);
292
293 const ended = getRun(app.db, queuedRun.id)!;
294 expect(ended.state === "cancelled" || ended.state === "failed").toBe(true);
295 const again = listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued");
296 expect(again).toHaveLength(1);
297 expect(again[0]!.summary).toContain("carries on");
298 }, 40_000);
299
241300 test("a draining crew lets a run in flight finish and land; nothing is queued again", async () => {
242301 await crew?.stop();
243302 crew = undefined;
test/drain-budgets.test.ts added +19
@@ -0,0 +1,19 @@
1 import { expect, test } from "bun:test";
2 import { drainBudgets } from "../src/drain.ts";
3
4 test("a roll's drain defaults to ten seconds for checks and for the crew", () => {
5 expect(drainBudgets({})).toEqual({ checksMs: 10_000, crewMs: 10_000 });
6 });
7
8 test("each variable overrides its own budget", () => {
9 expect(drainBudgets({ FORGE_CHECKS_DRAIN_S: "660" })).toEqual({ checksMs: 660_000, crewMs: 10_000 });
10 expect(drainBudgets({ FORGE_CREW_DRAIN_S: "30" })).toEqual({ checksMs: 10_000, crewMs: 30_000 });
11 });
12
13 test("junk falls back to the default", () => {
14 for (const junk of ["", "abc", "-5", "0", "0.4", "NaN", "Infinity"])
15 expect(drainBudgets({ FORGE_CHECKS_DRAIN_S: junk, FORGE_CREW_DRAIN_S: junk })).toEqual({
16 checksMs: 10_000,
17 crewMs: 10_000,
18 });
19 });
test/gate-drain.test.ts +59
@@ -17,6 +17,7 @@ import { getPull } from "../src/core/pulls.ts";
1717 import type { Repo } from "../src/core/repos.ts";
1818 import { createUser, type User } from "../src/core/users.ts";
1919 import { repoDir } from "../src/git/repos.ts";
20 import { peerRuns } from "../src/runs/peer.ts";
2021
2122 describe("a gate under way when the forge rolls", () => {
2223 let dataDir: string;
@@ -93,3 +94,61 @@ describe("a gate under way when the forge rolls", () => {
9394 expect(checks.adopt?.(pull.headOid)).toBeUndefined();
9495 }, 60_000);
9596 });
97
98 describe("the drain is seconds, not minutes (#7799)", () => {
99 test("a gate still running at the drain's end is handed back queued within it, and no run is left for a peer to wait on", async () => {
100 const dataDir = await mkdtemp(join(tmpdir(), "forge-gate-short-"));
101 const cells = process.env["FORGE_CELLS"];
102 process.env["FORGE_CELLS"] = "off";
103 const app = createApp({ dataDir, port: 0 });
104 const checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 1, drainMs: 1_000 });
105 try {
106 const a = await createUser(app.db, {
107 origin: "seed",
108 trusted: true,
109 username: "alice",
110 email: "[email protected]",
111 password: "a-good-passphrase",
112 adminIfFirst: true,
113 });
114 if (!a.ok) throw new Error(a.error);
115 const made = await createRepo(app, a.value, { name: "short", visibility: "public" });
116 if (!made.ok) throw new Error(String(made.error));
117 const repo = made.value;
118 const seeded = await commitToBranch(app, a.value, repo, {
119 message: "Start",
120 files: [{ path: "forge.json", content: JSON.stringify({ check: { run: "true", fast: "true", full: "sleep 30" } }) }],
121 });
122 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
123 const branched = await commitToBranch(app, a.value, repo, {
124 branch: "feature",
125 message: "Touch b",
126 files: [{ path: "b.txt", content: "b\n" }],
127 });
128 if (!branched.ok) throw new Error(JSON.stringify(branched.error));
129 const opened = await openPull(app, a.value, repo, { title: "Feature", baseBranch: "main", headBranch: "feature" });
130 if (!opened.ok) throw new Error(String(opened.error));
131 const pull = getPull(app.db, repo.id, opened.value.number)!;
132 const gating = gateCandidate(app, repo, pull, repoDir(app.reposRoot, repo.ownerName, repo.name), pull.headOid);
133 let adopted = checks.adopt?.(pull.headOid);
134 for (let i = 0; i < 400 && adopted === undefined; i++) {
135 await Bun.sleep(5);
136 adopted = checks.adopt?.(pull.headOid);
137 }
138 expect(adopted).toBeDefined();
139 const started = Date.now();
140 await checks.stop({ drain: true });
141 expect(Date.now() - started).toBeLessThan(15_000);
142 const verdict = await gating;
143 expect(verdict.lands).toBe(false);
144 expect("interrupted" in verdict).toBe(true);
145 expect((await adopted!).interrupted).toBe(true);
146 expect(peerRuns(app.db)).toBe(0);
147 } finally {
148 app.close();
149 if (cells === undefined) delete process.env["FORGE_CELLS"];
150 else process.env["FORGE_CELLS"] = cells;
151 await rm(dataDir, { recursive: true, force: true });
152 }
153 }, 60_000);
154 });
test/roll-write-pause.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * Two forges on one data directory across a roll (#7799). The old one has a
3 * check running and a merge-queue gate in flight; on SIGTERM it gives them
4 * its drain budget, then hands them back queued and lets go. The new one,
5 * booted beside it, holds its own queue work only until then: `waitForPeer`
6 * returns well inside 30 s, and the check is queued again for the new forge.
7 */
8
9 import { expect, test } from "bun:test";
10 import { mkdtemp, rm, writeFile } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join, resolve } from "node:path";
13 import { createApp } from "../src/app.ts";
14
15 const SRC = resolve(import.meta.dir, "../src");
16
17 /** The old forge in miniature: the real checks service and gate lane on the shared data, nothing else. */
18 const oldForge = `
19 const { createApp } = require(${JSON.stringify(SRC + "/app.ts")});
20 const { openPull } = require(${JSON.stringify(SRC + "/actions/pulls.ts")});
21 const { commitToBranch, createRepo } = require(${JSON.stringify(SRC + "/actions/repos.ts")});
22 const { gateCandidate } = require(${JSON.stringify(SRC + "/checks/landing.ts")});
23 const { startChecks } = require(${JSON.stringify(SRC + "/checks/service.ts")});
24 const { getPull } = require(${JSON.stringify(SRC + "/core/pulls.ts")});
25 const { createUser } = require(${JSON.stringify(SRC + "/core/users.ts")});
26 const { repoDir } = require(${JSON.stringify(SRC + "/git/repos.ts")});
27 const { drainBudgets } = require(${JSON.stringify(SRC + "/drain.ts")});
28 (async () => {
29 const app = createApp({ dataDir: process.argv[2], port: 0 });
30 const checks = startChecks(app, { forgeUrl: "http://127.0.0.1:1", concurrency: 2, drainMs: drainBudgets(process.env).checksMs });
31 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
32 const repo = (await createRepo(app, a.value, { name: "roll", visibility: "public" })).value;
33 await commitToBranch(app, a.value, repo, { message: "Start", files: [{ path: "forge.json", content: JSON.stringify({ check: { run: "sleep 120", fast: "true", full: "sleep 120" } }) }] });
34 await commitToBranch(app, a.value, repo, { branch: "feature", message: "Touch", files: [{ path: "b.txt", content: "b\\n" }] });
35 const opened = await openPull(app, a.value, repo, { title: "Feature", baseBranch: "main", headBranch: "feature" });
36 const pull = getPull(app.db, repo.id, opened.value.number);
37 void gateCandidate(app, repo, pull, repoDir(app.reposRoot, repo.ownerName, repo.name), pull.headOid);
38 process.on("SIGTERM", async () => {
39 await checks.stop({ drain: true });
40 app.close();
41 console.log("old forge let go");
42 process.exit(0);
43 });
44 const t = setInterval(() => {
45 const running = app.db.query("SELECT COUNT(*) AS n FROM run WHERE state = 'running'").get().n;
46 const gating = checks.adopt(pull.headOid) !== undefined;
47 if (running > 0 && gating) { clearInterval(t); console.log("old forge up"); }
48 }, 100);
49 })();
50 `;
51
52 test("the new forge starts its queue work within 30 s of SIGTERM to the old, with a gate and a check still in flight", async () => {
53 const dataDir = await mkdtemp(join(tmpdir(), "forge-rollpause-"));
54 const script = join(dataDir, "..", `${dataDir.split("/").pop()}-old.js`);
55 await writeFile(script, oldForge);
56 const old = Bun.spawn(["bun", script, dataDir], {
57 env: { ...(process.env as Record<string, string>), FORGE_CELLS: "off", FORGE_CHECKS_DRAIN_S: "3" },
58 stdout: "pipe",
59 stderr: "inherit",
60 });
61 const oldOut = old.stdout.getReader();
62 let new_ = undefined as ReturnType<typeof Bun.spawn> | undefined;
63 try {
64 let said = "";
65 const upDeadline = Date.now() + 60_000;
66 while (!said.includes("old forge up") && Date.now() < upDeadline) {
67 const { value, done } = await oldOut.read();
68 if (done) break;
69 said += new TextDecoder().decode(value);
70 }
71 expect(said).toContain("old forge up");
72
73 const web = 20000 + Math.floor(Math.random() * 20000);
74 const env: Record<string, string> = {
75 ...(process.env as Record<string, string>),
76 FORGE_DATA: dataDir,
77 PORT: String(web),
78 FORGE_SSH_PORT: "0",
79 FORGE_APPS: "0",
80 FORGE_CELLS: "off",
81 };
82 delete env.FORGE_URL;
83 const forge = Bun.spawn(["bun", "run", "src/main.ts"], { env, stdout: "pipe", stderr: "pipe" });
84 new_ = forge;
85 let out = "";
86 const collect = async (stream: ReadableStream<Uint8Array>) => {
87 for await (const chunk of stream) out += new TextDecoder().decode(chunk);
88 };
89 void collect(forge.stdout as ReadableStream<Uint8Array>);
90 void collect(forge.stderr as ReadableStream<Uint8Array>);
91 for (let i = 0; i < 300 && !out.includes("another forge still works this database"); i++) await Bun.sleep(100);
92 expect(out).toContain("another forge still works this database");
93
94 const signalled = Date.now();
95 old.kill("SIGTERM");
96 for (let i = 0; i < 300 && !out.includes("the other forge let go of this database"); i++) await Bun.sleep(100);
97 const pause = Date.now() - signalled;
98 if (pause >= 30_000) console.log(out);
99 expect(out).toContain("the other forge let go of this database");
100 expect(pause).toBeLessThan(30_000);
101 await old.exited;
102
103 // The check the old forge cut off is queued again, for the new one to take.
104 forge.kill();
105 await forge.exited;
106 const app = createApp({ dataDir, port: 0 });
107 try {
108 const states = app.db.query<{ state: string }, []>("SELECT state FROM run WHERE kind = 'check'").all().map((r) => r.state);
109 expect(states.length).toBeGreaterThan(0);
110 expect(states).not.toContain("failed");
111 } finally {
112 app.close();
113 }
114 } finally {
115 old.kill("SIGKILL");
116 new_?.kill("SIGKILL");
117 await rm(dataDir, { recursive: true, force: true });
118 await rm(script, { force: true });
119 }
120 }, 120_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs