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

The forge answers, signs people in, builds and releases with the DigitalOcean node switched off #11807

Merged opened this in answer to

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

Merged

mason/forge:mason/11686-the-forge-answers-signs-people-in-builds → main 1 commit fork

  • Merged. mason/11686-the-forge-answers-signs-people-in-builds was merged into main as 2596ebe.

Review

Sign in to review this change.
5 files changed +57 −1
docs/features/release-keeper.md +4
@@ -15,6 +15,10 @@ keyboard. It does what the steward's `release.sh`, `verify-after.sh` and
1515 - no merge-queue gate is running;
1616 - no snapshot is being taken, unless it has run two hours and is stuck
1717 (a release drains the process the snapshot runs in; #9653);
18 - the disk under the forge's data is less than 80% full (a release
19 writes a new tree beside the live one; at 80% the tick says
20 "the disk is N% full; releases pause at 80% until room is made" and
21 cuts on the first tick with room; #11686);
1822 - 30 minutes have passed since the last cut;
1923 - no hold stands.
2024
src/keeper/cadence.ts +13
@@ -9,6 +9,7 @@
99 * ∧ no snapshot is being taken, unless it has run `SNAPSHOT_WAIT_MS`
1010 * ∧ now − the last cut ≥ the minimum interval
1111 * ∧ no hold stands (a failed verify's, or an owner's)
12 * ∧ the forge's disk is less than `RELEASE_DISK_USED_MAX` full
1213 *
1314 * The first rule that refuses names itself, so the record says why not.
1415 * The version is the newest tag's with its patch number plus one.
@@ -25,6 +26,14 @@ export const MIN_INTERVAL_MS = 30 * 60_000;
2526 */
2627 export const SNAPSHOT_WAIT_MS = 2 * 3_600_000;
2728
29 /**
30 * A release writes a new tree and its install beside the live one, so at
31 * this share of the forge's disk used no release is cut (#11686, item 6:
32 * a full disk took the forge down once). The cut waits; nothing is held,
33 * and the next tick with room cuts.
34 */
35 export const RELEASE_DISK_USED_MAX = 0.8;
36
2837 export interface CadenceInput {
2938 /** main's head commit. */
3039 readonly main: string;
@@ -44,6 +53,8 @@ export interface CadenceInput {
4453 readonly lastCutAt: number | undefined;
4554 /** The reason of a standing hold, when one stands. */
4655 readonly hold: string | undefined;
56 /** The share of the forge's disk in use, 0..1; undefined when it could not be read. */
57 readonly diskUsed?: number | undefined;
4758 readonly now: number;
4859 readonly minIntervalMs?: number;
4960 }
@@ -69,6 +80,8 @@ export function decideCut(input: CadenceInput): Cadence {
6980 if (input.gating > 0) return no(`the merge queue is landing (${input.gating} gate${input.gating === 1 ? "" : "s"} running)`);
7081 if (input.snapshotMs !== undefined && input.snapshotMs < SNAPSHOT_WAIT_MS)
7182 return no(`a snapshot is being taken (${Math.floor(input.snapshotMs / 60_000)} min in); a release would cut it short`);
83 if (input.diskUsed !== undefined && input.diskUsed >= RELEASE_DISK_USED_MAX)
84 return no(`the disk is ${Math.round(input.diskUsed * 100)}% full; releases pause at ${RELEASE_DISK_USED_MAX * 100}% until room is made`);
7285 const wait = input.minIntervalMs ?? MIN_INTERVAL_MS;
7386 if (input.lastCutAt !== undefined && input.now - input.lastCutAt < wait)
7487 return no(`the last cut was ${Math.floor((input.now - input.lastCutAt) / 60_000)} min ago; the interval is ${wait / 60_000} min`);
src/keeper/keeper.ts +3
@@ -56,6 +56,8 @@ export interface KeeperPorts {
5656 readonly gating: () => number;
5757 /** How long the snapshot being taken has run, ms; undefined when none is (#9653). */
5858 readonly snapshotting?: () => number | undefined;
59 /** The share of the forge's disk in use, 0..1; undefined when unread (#11686). */
60 readonly diskUsed?: () => number | undefined;
5961 readonly deployer: Deployer;
6062 /** Make the annotated tag `v<version>` on `sha` and send it where the release workflow starts; why not. */
6163 readonly tag: (version: string, sha: string) => Promise<string | undefined>;
@@ -213,6 +215,7 @@ export async function tickStep(p: KeeperPorts): Promise<Step> {
213215 inFlight: false,
214216 gating: p.gating(),
215217 snapshotMs: p.snapshotting?.(),
218 diskUsed: p.diskUsed?.(),
216219 lastCutAt: last === undefined ? undefined : Date.parse(last.cutAt),
217220 hold: blocking(standingHolds(p.db)),
218221 now: p.now(),
src/keeper/wire.ts +5
@@ -35,6 +35,7 @@ import { kubeDeployer } from "./deploy.ts";
3535 import { swapper } from "./swapper.ts";
3636 import type { KeeperPorts } from "./keeper.ts";
3737 import { timedGet, verifyUnderLoad } from "./verify.ts";
38 import { diskUnder } from "../spawn/disk_floor.ts";
3839
3940 const LABEL = /^[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/;
4041 const OID = /^[0-9a-f]{40}$/;
@@ -123,6 +124,10 @@ export function keeperPorts(app: App, input: { forgeUrl: string; deployment: str
123124 },
124125 gating: gatesInFlight,
125126 snapshotting: () => snapshotsOf(app)?.takingMs(),
127 diskUsed: () => {
128 const disk = diskUnder(app.config.dataDir);
129 return disk === undefined || !(disk.totalBytes > 0) ? undefined : 1 - disk.freeBytes / disk.totalBytes;
130 },
126131 deployer,
127132 async tag(version, sha) {
128133 if (!VERSION.test(version) || !OID.test(sha)) return "not a version and a commit";
test/release-keeper-cadence.test.ts +32 −1
@@ -1,8 +1,10 @@
11 // When the release keeper cuts (#7557): each rule refuses on its own, and a cut is a tag, never a commit to main.
22 import { describe, expect, test } from "bun:test";
3 import { decideCut, MIN_INTERVAL_MS, nextVersion, SNAPSHOT_WAIT_MS, type CadenceInput } from "../src/keeper/cadence.ts";
3 import { decideCut, MIN_INTERVAL_MS, nextVersion, RELEASE_DISK_USED_MAX, SNAPSHOT_WAIT_MS, type CadenceInput } from "../src/keeper/cadence.ts";
44 import { tick } from "../src/keeper/keeper.ts";
55 import { listKeeperReleases, recordCut } from "../src/keeper/record.ts";
6 import { keeperPorts } from "../src/keeper/wire.ts";
7 import { provideDiskForTest } from "../src/kernel/disk_reading.ts";
68 import { A, B, keeperForge } from "./helpers/keeper.ts";
79
810 const NOW = Date.parse("2026-10-27T12:00:00.000Z");
@@ -56,6 +58,35 @@ describe("cadence", () => {
5658 expect(await tick({ ...f.ports, snapshotting: () => undefined })).toContain("cut 0.7.284");
5759 });
5860
61 test("at 80% of the disk used no release is cut; under it, or unread, the cut goes (#11686)", async () => {
62 expect(RELEASE_DISK_USED_MAX).toBe(0.8);
63 expect(decideCut({ ...ready, diskUsed: 0.8 })).toEqual({ cut: false, why: "the disk is 80% full; releases pause at 80% until room is made" });
64 expect(decideCut({ ...ready, diskUsed: 0.93 }).cut).toBe(false);
65 expect(decideCut({ ...ready, diskUsed: 0.79 })).toEqual({ cut: true, version: "0.7.284" });
66 expect(decideCut({ ...ready, diskUsed: undefined })).toEqual({ cut: true, version: "0.7.284" });
67 const f = await keeperForge();
68 expect(await tick({ ...f.ports, diskUsed: () => 0.85 })).toBe("the disk is 85% full; releases pause at 80% until room is made");
69 expect(f.world.tags).toEqual([]);
70 expect(f.state.set).toEqual([]);
71 expect(await tick({ ...f.ports, diskUsed: () => 0.7 })).toContain("cut 0.7.284");
72 });
73
74 test("the forge's keeper reads the disk under its data directory for the rule", async () => {
75 const f = await keeperForge();
76 const ports = keeperPorts(f.app, { forgeUrl: "http://forge.test", deployment: "forge", container: "forge", swap: true });
77 if (typeof ports === "string") throw new Error(ports);
78 const seen: string[] = [];
79 provideDiskForTest((dir) => (seen.push(dir), { freeBytes: 150, totalBytes: 1000 }));
80 try {
81 expect(ports.diskUsed?.()).toBeCloseTo(0.85);
82 expect(seen).toEqual([f.app.config.dataDir]);
83 provideDiskForTest(() => ({ freeBytes: 0, totalBytes: 0 }));
84 expect(ports.diskUsed?.()).toBeUndefined();
85 } finally {
86 provideDiskForTest(() => ({ freeBytes: 900 * 1024 ** 3, totalBytes: 1000 * 1024 ** 3 }));
87 }
88 });
89
5990 test("a tag the keeper rolled back is settled: the fix after it may be cut", () => {
6091 expect(decideCut({ ...ready, newest: { version: "0.7.284", sha: A }, live: "0.7.283", settled: true })).toEqual({ cut: true, version: "0.7.285" });
6192 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs