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

Once needs are split into broken and todo, memory below its floor is a broken need and /health answers ok:false #12385

Merged opened this

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

Merged

scout/forge:scout/12300-once-needs-are-split-into-broken-and-tod → main 5 commits fork

  • Merged. scout/12300-once-needs-are-split-into-broken-and-tod was merged into main as 2d10bf9.

Review

Sign in to review this change.
5 files changed +121 −10
src/actions/health.ts +1
@@ -74,6 +74,7 @@ function gatherVitals(app: App): Vitals {
7474 db: app.db,
7575 dataDir: app.config.dataDir,
7676 ...(app.config.disk ? { disk: app.config.disk } : {}),
77 ...(app.config.hostFree ? { hostFree: app.config.hostFree } : {}),
7778 envs,
7879 signals: app.signals,
7980 forgeUrl: app.config.url ?? `http://localhost:${app.config.port}`,
src/app.ts +5
@@ -46,6 +46,11 @@ export interface ForgeConfig {
4646 seedSignup?: boolean;
4747 /** The disk probe vitals reads; the volume under `dataDir` when unset. Tests give it a reading. */
4848 disk?: (dir: string) => { freeBytes: number; totalBytes: number };
49 /**
50 * The host's free memory probe vitals reads against its floor; `freemem()`
51 * when unset. Tests give it a reading.
52 */
53 hostFree?: () => number;
4954 /**
5055 * The machine's memory in bytes, which the forges' sizes are summed against
5156 * (`spawn/memory_fit.ts`); its pod's limit or its own when unset. Tests give it a figure.
src/kernel/vitals.ts +33 −5
@@ -42,6 +42,7 @@ import { rollbackNeed } from "../release/launch.ts";
4242 import { behindStanding } from "../release/behind.ts";
4343 import { newestDrills } from "../core/domain_drills.ts";
4444 import { capacityValue, memoryLimitBytes, podMemoryUsedBytes } from "../core/capacity.ts";
45 import { limitValue } from "../settings/limits.ts";
4546 import { unwrittenFor, SILENT_AFTER_S } from "../http/access_log.ts";
4647 import { DOMAINS } from "./domains.ts";
4748 import { domainDrillNeeds } from "./domain_drill.ts";
@@ -82,8 +83,9 @@ export interface EnvCount {
8283 * database over 2 GiB, a drill owed, a queue waiting) the forge serves
8384 * through. Of those, vitals see `disk-full`, `disk-red`, `crash-loop` and
8485 * `red:<repoId>`; draining is added by the health answer, and the database
85 * not answering is `/healthz`'s 503. Memory below its floor is `broken` by
86 * this rule, but nothing raises it yet (#12284).
86 * not answering is `/healthz`'s 503. Memory below its floor (`memory`): the
87 * host's free memory under `forge.memory_floor_mb`, the share the forge keeps
88 * for itself (#12300).
8789 *
8890 * `disk` and `disk-floor` are `todo`: a low disk stops new starts (child
8991 * forges, apps, checks), but the forge keeps serving what is already running
@@ -94,7 +96,7 @@ export type NeedKind = "broken" | "todo";
9496
9597 /**
9698 * One cause of the forge being off: the `needs` sentence, a `cause` key that
97 * holds for as long as its condition does (`disk`, `disk-floor`, `disk-80`, `disk-70`,
99 * holds for as long as its condition does (`memory`, `disk`, `disk-floor`, `disk-80`, `disk-70`,
98100 * `db-size`, `db-growth`, `forges-memory`, `errors`, `internal`,
99101 * `queue:<kind>`, `release`, `stuck:<runId>`, `red:<repoId>`, `shared:<repoId>:<test>`), when it began by the forge's own evidence
100102 * (the oldest evidence the forge holds, else the first time this process saw
@@ -188,6 +190,23 @@ export interface VitalsInput {
188190 * machine's memory (`spawn/memory_fit.ts`); undefined with room.
189191 */
190192 readonly forgesMemory?: string | undefined;
193 /**
194 * The host's free memory, bytes; `freemem()` unless a test gives a reading.
195 * Under `bun test` (NODE_ENV=test) with none given, the memory need is not
196 * raised: the suite's own parallel load would starve the machine it runs on
197 * and turn every test's forge broken.
198 */
199 readonly hostFree?: () => number;
200 }
201
202 /** The host probe the memory need reads when none is given; none under `bun test`. */
203 const hostFreeDefault: (() => number) | undefined = process.env["NODE_ENV"] === "test" ? undefined : freemem;
204
205 const MIB = 1024 * 1024;
206
207 /** The `memory` need's sentence: the reading and the floor it is under. */
208 export function memorySentence(freeBytes: number, floorMiB: number): string {
209 return `Memory ${Math.floor(freeBytes / MIB)} MiB free, floor ${floorMiB} MiB.`;
191210 }
192211
193212 /** The device a path lives on, read from it or its nearest parent that exists. */
@@ -369,9 +388,11 @@ export type PodMemory = { readonly limitBytes: number; readonly usedBytes: numbe
369388 export function memoryReading(
370389 pod: PodMemory = { limitBytes: memoryLimitBytes(), usedBytes: podMemoryUsedBytes() },
371390 rssBytes = process.memoryUsage().rss,
391 hostFreeBytes = freemem(),
372392 ): Vitals["memory"] {
373393 const used = pod.usedBytes ?? rssBytes;
374 return { rssBytes, freeBytes: Math.min(freemem(), Math.max(0, pod.limitBytes - used)), totalBytes: pod.limitBytes };
394 const freeBytes = Math.min(hostFreeBytes, Math.max(0, pod.limitBytes - used));
395 return { rssBytes, freeBytes, totalBytes: pod.limitBytes };
375396 }
376397
377398 export function vitals(input: VitalsInput): Vitals {
@@ -400,6 +421,13 @@ export function vitals(input: VitalsInput): Vitals {
400421 degraded.push({ cause, sentence, since, href });
401422 };
402423
424 // Free memory under the floor the forge keeps for itself: what it serves with is gone.
425 const hostProbe = input.hostFree ?? hostFreeDefault;
426 const hostFreeBytes = hostProbe === undefined ? freemem() : hostProbe();
427 const memoryFloorMiB = limitValue(input.db, "forge.memory_floor_mb");
428 if (hostProbe !== undefined && memoryFloorMiB > 0 && hostFreeBytes < memoryFloorMiB * MIB)
429 need("broken", "memory", memorySentence(hostFreeBytes, memoryFloorMiB), undefined, "/settings/capacity");
430
403431 const fullAt = fullSince(input.dataDir);
404432 if (fullAt !== undefined) need("broken", "disk-full", fullSentence(input.dataDir, true), fullAt, "/system");
405433 const disk = (input.disk ?? readDisk)(input.dataDir);
@@ -690,7 +718,7 @@ export function vitals(input: VitalsInput): Vitals {
690718 todo,
691719 degraded,
692720 disk,
693 memory: memoryReading(input.podMemory),
721 memory: memoryReading(input.podMemory, undefined, hostFreeBytes),
694722 queue,
695723 lane,
696724 stuck,
test/health-broken.test.ts +80 −3
@@ -3,7 +3,7 @@
33 * now) or `todo` (would like done), and only `broken` makes ok false. This
44 * file is the guard on the broken list: a real failure filed as todo would
55 * answer ok:true and fail here. A crash loop is one: three unclean starts in
6 * ten minutes (#12285).
6 * ten minutes (#12285). Free memory under the forge's floor is another (#12300).
77 */
88 import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
99 import type { Database } from "bun:sqlite";
@@ -21,7 +21,10 @@ import { health } from "../src/http/health.ts";
2121 import { openDb } from "../src/kernel/db.ts";
2222 import { ErrorLedger } from "../src/kernel/log.ts";
2323 import { RefusedLedger } from "../src/kernel/internal_refused.ts";
24 import { vitals, type Vitals } from "../src/kernel/vitals.ts";
24 import { memorySentence, vitals, type Vitals } from "../src/kernel/vitals.ts";
25 import { limitValue } from "../src/settings/limits.ts";
26 import { tool as healthTool } from "../src/http/mcp/tools/health.ts";
27 import type { Ctx } from "../src/http/context.ts";
2528 import { DB_FILE_CAP, dbFile } from "../src/system/db_size.ts";
2629 import { quietLogs } from "./helpers/globals.ts";
2730
@@ -44,11 +47,14 @@ afterEach(() => {
4447 rmSync(dataDir, { recursive: true, force: true });
4548 });
4649
47 const read = (disk = roomy): Vitals =>
50 const MIB = 1024 * 1024;
51 const read = (disk = roomy, hostFree = () => 1e15): Vitals =>
4852 vitals({
4953 db,
5054 dataDir,
5155 disk,
56 hostFree,
57 podMemory: { limitBytes: 1e15, usedBytes: 0 },
5258 dbWalk: () => null,
5359 signals: { errors: new ErrorLedger(), refused: new RefusedLedger() },
5460 });
@@ -77,6 +83,46 @@ describe("broken makes ok false", () => {
7783 });
7884 });
7985
86 describe("memory below its floor", () => {
87 test("is broken while free memory is under the floor, and clears above it", () => {
88 const floor = limitValue(db, "forge.memory_floor_mb");
89 expect(floor).toBeGreaterThan(0);
90 const low = read(roomy, () => (floor - 76) * MIB);
91 const need = low.degraded.find((d) => d.cause === "memory")!;
92 expect(need.sentence).toBe(`Memory ${floor - 76} MiB free, floor ${floor} MiB.`);
93 expect(low.ok).toBe(false);
94 expect(low.broken).toContain(need.sentence);
95 expect(low.todo).not.toContain(need.sentence);
96 expect(low.memory.freeBytes).toBe((floor - 76) * MIB);
97 const back = read(roomy, () => floor * MIB);
98 expect(back.degraded.find((d) => d.cause === "memory")).toBeUndefined();
99 expect(back.ok).toBe(true);
100 });
101
102 test("a floor of 0 keeps none, so nothing is under it", () => {
103 db.run("INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('limit.forge.memory_floor_mb', '0', 'u1', '2026-01-01')");
104 expect(read(roomy, () => 0).broken).toEqual([]);
105 });
106
107 test("under bun test with no probe given, the host's own memory raises nothing, however high the floor", () => {
108 db.run("INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('limit.forge.memory_floor_mb', '1048576', 'u1', '2026-01-01')");
109 const v = vitals({
110 db,
111 dataDir,
112 disk: roomy,
113 podMemory: { limitBytes: 1e15, usedBytes: 0 },
114 dbWalk: () => null,
115 signals: { errors: new ErrorLedger(), refused: new RefusedLedger() },
116 });
117 expect(v.degraded.find((d) => d.cause === "memory")).toBeUndefined();
118 expect(read(roomy, () => 0).degraded.find((d) => d.cause === "memory")).toBeDefined();
119 });
120
121 test("the sentence names the reading and the floor", () => {
122 expect(memorySentence(180 * MIB, 256)).toBe("Memory 180 MiB free, floor 256 MiB.");
123 });
124 });
125
80126 describe("todo alone keeps ok true", () => {
81127 test("a database over 2 GiB", () => {
82128 writeFileSync(dbFile(dataDir), "", { mode: 0o600 });
@@ -114,6 +160,37 @@ describe("the health answer", () => {
114160 health.draining = false;
115161 expect((readHealth(app, admin) as Vitals).broken).not.toContain(DRAINING);
116162 });
163
164 test("memory under its floor answers ok:false on the API and the MCP tool, ok:true once back", () => {
165 const floor = limitValue(app.db, "forge.memory_floor_mb");
166 let free = (floor - 1) * MIB;
167 const hostFree = () => free;
168 const mem = createApp({ dataDir: dir, port: 0, hostFree, disk: () => ({ freeBytes: 1e12, totalBytes: 1e12 }) });
169 try {
170 const sentence = memorySentence(free, floor);
171 const api = readHealth(mem, admin) as Vitals;
172 expect(api.ok).toBe(false);
173 expect(api.broken).toContain(sentence);
174 const url = new URL("http://localhost/mcp");
175 const ctx: Ctx = {
176 app: mem,
177 request: new Request(url.href, { method: "POST" }),
178 url,
179 params: {},
180 auth: { user: admin, scope: undefined, via: "session" },
181 theme: undefined,
182 };
183 const mcp = healthTool.run(ctx, admin, {}) as { ok: true; value: Vitals };
184 expect(mcp.value.ok).toBe(false);
185 expect(mcp.value.broken).toContain(sentence);
186 free = (floor + 1) * MIB;
187 const again = readHealth(mem, admin) as Vitals;
188 expect(again.broken).not.toContain(sentence);
189 expect(again.degraded.some((d) => d.cause === "memory")).toBe(false);
190 } finally {
191 mem.db.close();
192 }
193 });
117194 });
118195
119196 describe("a crash loop", () => {
test/vitals.test.ts +2 −2
@@ -178,8 +178,8 @@ test("the number of queries does not grow with runs", () => {
178178 // finished checks, the red mains holding pulls' checks, the tests red on many pulls, (the checks
179179 // red on a full disk only while the disk is full), and the newest drill of every domain, the
180180 // checks no red main holds, the week's flake list, and the database's daily size readings
181 // (#12010, at most 60 rows).
182 expect(before).toBe(11);
181 // (#12010, at most 60 rows), and the memory floor the forge keeps for itself (#12300).
182 expect(before).toBe(12);
183183 });
184184
185185 test("errors are counted, bounded, redacted, and a burst is said", async () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs