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

/system and /api/v1/health read the pod's memory limit, not the host's #12353

Merged opened this

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

Merged

torque/forge:torque/12347-system-and-api-v1-health-read-the-pod-s → main 5 commits fork 3 threads · 3 unresolved

  • Merged. torque/12347-system-and-api-v1-health-read-the-pod-s was merged into main as e209d31.

Review

Sign in to review this change.
8 files changed +134 −19
src/core/capacity.ts +16
@@ -322,6 +322,22 @@ export function memoryLimitBytes(): number {
322322 return totalmem();
323323 }
324324
325 /**
326 * What this pod's cgroup holds in memory now: `memory.current` less the inactive file cache
327 * (the kernel gives that back under pressure, as `MemAvailable` counts it). Null when
328 * there is no cgroup v2 memory controller here or the files do not read.
329 */
330 export function podMemoryUsedBytes(dir = "/sys/fs/cgroup"): number | null {
331 try {
332 const current = Number(readFileSync(`${dir}/memory.current`, "utf8").trim());
333 if (!Number.isFinite(current) || current < 0) return null;
334 const inactive = /^inactive_file (\d+)$/m.exec(readFileSync(`${dir}/memory.stat`, "utf8"));
335 return Math.max(0, current - (inactive ? Number(inactive[1]) : 0));
336 } catch {
337 return null;
338 }
339 }
340
325341 /** The value stored by an admin, if any. */
326342 export function storedCapacity(
327343 db: Database,
src/kernel/vitals.ts +29 −9
@@ -14,7 +14,7 @@ import { fullSentence, fullSince } from "./full_disk.ts";
1414 import { readDisk, type DiskReading } from "./disk_reading.ts";
1515 import { DISK_FLOOR, roomy } from "../spawn/disk_floor.ts";
1616 import { dirname } from "node:path";
17 import { freemem, totalmem } from "node:os";
17 import { freemem } from "node:os";
1818 import { refusedInternal, type RefusedLedger } from "./internal_refused.ts";
1919 import { recentErrors, redactSecrets, type ErrorLedger, type ErrorLine } from "./log.ts";
2020 import {
@@ -41,7 +41,7 @@ import { running as runningVersion } from "./version.ts";
4141 import { rollbackNeed } from "../release/launch.ts";
4242 import { behindStanding } from "../release/behind.ts";
4343 import { newestDrills } from "../core/domain_drills.ts";
44 import { capacityValue } from "../core/capacity.ts";
44 import { capacityValue, memoryLimitBytes, podMemoryUsedBytes } from "../core/capacity.ts";
4545 import { unwrittenFor, SILENT_AFTER_S } from "../http/access_log.ts";
4646 import { DOMAINS } from "./domains.ts";
4747 import { domainDrillNeeds } from "./domain_drill.ts";
@@ -108,8 +108,13 @@ export interface Vitals {
108108 readonly disk: { freeBytes: number; totalBytes: number };
109109 readonly memory: {
110110 rssBytes: number;
111 hostFreeBytes: number;
112 hostTotalBytes: number;
111 /**
112 * Memory left under `totalBytes`: the pod's limit less its own usage where a cgroup
113 * limit is set, else the host's free memory.
114 */
115 freeBytes: number;
116 /** The pod's memory limit where one is set (cgroup), else the host's memory. */
117 totalBytes: number;
113118 };
114119 readonly queue: Record<string, number>;
115120 readonly lane: Lane;
@@ -143,6 +148,8 @@ export interface VitalsInput {
143148 readonly disk?: (dir: string) => DiskReading;
144149 /** The free-disk share below which the disk is a need; `DISK_FREE_MIN` unless a test says. */
145150 readonly diskFreeMin?: number;
151 /** The pod's memory limit and usage; read from the cgroup unless a test gives them. */
152 readonly podMemory?: PodMemory;
146153 /**
147154 * The last walk of the database file, asked for only while a db-size need
148155 * fires; `dbSize.last` unless a test gives one.
@@ -321,6 +328,23 @@ export function laneSentence(
321328 return `${head}; the longest running is ${name(longest)} at ${minutes(longest)} min (limit ${BUDGETS.gate / 60_000})${more}.`;
322329 }
323330
331 /** The pod's memory as `memoryReading` takes it; a test gives its own. */
332 export type PodMemory = { readonly limitBytes: number; readonly usedBytes: number | null };
333
334 /**
335 * This process's memory beside the pod's own limit where one is set: a forge in 512 MiB
336 * is not on a 64 GiB host (#12347). Free is the limit less what the pod's cgroup holds
337 * (this process's resident memory when the cgroup cannot be read), never more than the host
338 * has free.
339 */
340 export function memoryReading(
341 pod: PodMemory = { limitBytes: memoryLimitBytes(), usedBytes: podMemoryUsedBytes() },
342 rssBytes = process.memoryUsage().rss,
343 ): Vitals["memory"] {
344 const used = pod.usedBytes ?? rssBytes;
345 return { rssBytes, freeBytes: Math.min(freemem(), Math.max(0, pod.limitBytes - used)), totalBytes: pod.limitBytes };
346 }
347
324348 export function vitals(input: VitalsInput): Vitals {
325349 const now = input.now ?? Date.now();
326350 const at = new Date(now).toISOString();
@@ -616,11 +640,7 @@ export function vitals(input: VitalsInput): Vitals {
616640 needs,
617641 degraded,
618642 disk,
619 memory: {
620 rssBytes: process.memoryUsage().rss,
621 hostFreeBytes: freemem(),
622 hostTotalBytes: totalmem(),
623 },
643 memory: memoryReading(input.podMemory),
624644 queue,
625645 lane,
626646 stuck,
src/system/live.ts +5 −3
@@ -8,7 +8,8 @@
88 import type { App } from "../app.ts";
99 import { cellRoots } from "../apps/contain.ts";
1010 import { HOST, readRaw, type Paths, type Raw } from "./proc.ts";
11 import { buildSystem, forAgent, type Roots, type SystemView } from "./view.ts";
11 import { buildSystem, forAgent, type PodLimits, type Roots, type SystemView } from "./view.ts";
12 import { cpuLimit, memoryLimitBytes, podMemoryUsedBytes } from "../core/capacity.ts";
1213 import { during } from "../perf/jobs.ts";
1314
1415 const FRESH_MS = 1_000;
@@ -38,10 +39,11 @@ const realSchedule: Schedule = (tick, ms) => {
3839 return () => clearInterval(t);
3940 };
4041
41 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number; schedule?: Schedule } = {}): Live {
42 export function createLive(options: { paths?: Paths; roots?: () => Roots; self?: number; pushMs?: number; schedule?: Schedule; limits?: () => PodLimits } = {}): Live {
4243 const paths = options.paths ?? HOST;
4344 const roots = options.roots ?? (() => cellRoots());
4445 const self = options.self ?? process.pid;
46 const limits = options.limits ?? (() => ({ cpus: cpuLimit(), memoryBytes: memoryLimitBytes(), usedBytes: podMemoryUsedBytes() }));
4547 let prev: Raw | undefined;
4648 let last: Raw | undefined;
4749 let view: SystemView | undefined;
@@ -79,7 +81,7 @@ export function createLive(options: { paths?: Paths; roots?: () => Roots; self?:
7981 // A first reading has no rate: its rates are null until a second reading is taken.
8082 prev = last;
8183 last = raw;
82 const built = buildSystem(prev, last, roots(), self);
84 const built = buildSystem(prev, last, roots(), self, limits());
8385 history = built.cpu.cores.map((c, i) => [...(history[i] ?? []), c ?? 0].slice(-HISTORY));
8486 view = { ...built, cpu: { ...built.cpu, history } };
8587 return view;
src/system/view.ts +24 −3
@@ -32,6 +32,11 @@ export type SystemView = {
3232 cpu: {
3333 busyPct: number | null;
3434 cores: (number | null)[];
35 /**
36 * The cores this pod may use (cgroup `cpu.max` where set), else the host's;
37 * absent when not given.
38 */
39 limitCores?: number;
3540 /** Each core's last readings, percent, oldest first (kept by `live.ts`; empty from `buildSystem`). */
3641 history: number[][];
3742 };
@@ -97,7 +102,18 @@ function groupOf(procs: readonly Proc[], roots: Roots, self: number): Map<number
97102 const rate = (a: number | undefined, b: number | undefined, s: number): number | null =>
98103 a === undefined || b === undefined || s <= 0 || b < a ? null : (b - a) / s;
99104
100 export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self: number): SystemView {
105 /** The pod's own limits, which the host's numbers are held under (#12347). */
106 export type PodLimits = {
107 cpus: number;
108 memoryBytes: number;
109 /**
110 * What the pod's cgroup holds now (`podMemoryUsedBytes`); null when unreadable, then the
111 * host's is shown.
112 */
113 usedBytes?: number | null;
114 };
115
116 export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self: number, limits?: PodLimits): SystemView {
101117 const seconds = prev === undefined ? 0 : Math.max(0, (next.at - prev.at) / 1000);
102118 const cores = next.cores.map((c, i) => {
103119 const p = prev?.cores[i];
@@ -152,14 +168,19 @@ export function buildSystem(prev: Raw | undefined, next: Raw, roots: Roots, self
152168 cpu: {
153169 busyPct: known.length === 0 ? null : Math.round((known.reduce((a, b) => a + b, 0) / known.length) * 10) / 10,
154170 cores,
171 ...(limits ? { limitCores: Math.min(limits.cpus, cores.length || limits.cpus) } : {}),
outdated

Host used memory capped at the limit: on a 512 MiB pod on a 64 GiB host with 60 GiB used this reads "512 MiB of 512 MiB", always full. Use the pod cgroup's own memory.current.

155172 history: [],
156173 },
157174 temps: next.temps,
158175 hottestC: next.temps.length === 0 ? null : Math.max(...next.temps.map((t) => t.celsius)),
159176 load: next.load,
160177 memory: mem && {
161 totalBytes: mem.total,
162 usedBytes: mem.total - mem.available,
178 totalBytes: limits ? Math.min(mem.total, limits.memoryBytes) : mem.total,
179 // Under a pod limit, the pod's own usage: the host's used memory is not the pod's.
180 usedBytes:
181 limits && limits.memoryBytes < mem.total
182 ? Math.min(limits.memoryBytes, limits.usedBytes ?? 0)
183 : mem.total - mem.available,
163184 swapUsedBytes: mem.swapTotal - mem.swapFree,
164185 swapTotalBytes: mem.swapTotal,
165186 },
src/tui/screens/system.ts +1 −1
@@ -107,7 +107,7 @@ export function systemLines(v: SystemView): string[] {
107107 (c, i) => `${pad(`cpu${i}`, 6)} ${pad(c === null ? "—" : `${Math.round(c)}%`, 4, "right")} ${spark(v.cpu.history[i] ?? [])}`,
108108 );
109109 return [
110 `CPU ${pct(v.cpu.busyPct)} over ${v.cpu.cores.length} cores · load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`,
110 `CPU ${pct(v.cpu.busyPct)} over ${v.cpu.limitCores ?? v.cpu.cores.length} cores · load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`,
111111 ...cores,
112112 ...(v.cpu.cores.length > CORES ? [`… and ${v.cpu.cores.length - CORES} more cores`] : []),
113113 `hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`} · memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"} · ${v.processes} processes`,
src/web/pages/system.ts +2 −2
@@ -446,12 +446,12 @@ export function regions(v: SystemView): Record<string, string> {
446446 const out: Record<string, Html> = {
447447 "sys-dup": dup,
448448 summary: html`${pill(`CPU ${pct(v.cpu.busyPct)}`, v.cpu.busyPct !== null && v.cpu.busyPct >= 90 ? "danger" : undefined)}
449 ${pill(`${String(v.cpu.cores.length)} cores`)}
449 ${pill(`${String(v.cpu.limitCores ?? v.cpu.cores.length)} cores`)}
450450 ${pill(`load ${v.load ? v.load.map((l) => l.toFixed(2)).join(" ") : "—"}`)}
451451 ${pill(`hottest ${v.hottestC === null ? "—" : `${v.hottestC.toFixed(0)}°C`}`)}
452452 ${pill(`memory ${v.memory ? `${bytes(v.memory.usedBytes)} of ${bytes(v.memory.totalBytes)}` : "—"}`)}
453453 ${pill(`${String(v.processes)} processes`)}`,
454 cores: html`${v.cpu.cores.map((c, i) => meterRow(`cpu${String(i)}`, c, 100, c === null ? "—" : `${c.toFixed(0)}%`, spark(v.cpu.history[i] ?? [])))}`,
454 cores: html`${v.cpu.limitCores !== undefined && v.cpu.limitCores < v.cpu.cores.length ? html`<li class="small">This pod may use ${String(v.cpu.limitCores)} of the host's ${String(v.cpu.cores.length)} cores; the rows below are the host's.</li>` : null}${v.cpu.cores.map((c, i) => meterRow(`cpu${String(i)}`, c, 100, c === null ? "—" : `${c.toFixed(0)}%`, spark(v.cpu.history[i] ?? [])))}`,
455455 mem: html`${v.memory
456456 ? html`${meterRow("mem", v.memory.usedBytes, v.memory.totalBytes, `${bytes(v.memory.usedBytes)} / ${bytes(v.memory.totalBytes)}`)}
457457 ${meterRow("swap", v.memory.swapUsedBytes, v.memory.swapTotalBytes, v.memory.swapTotalBytes === 0 ? "none" : `${bytes(v.memory.swapUsedBytes)} / ${bytes(v.memory.swapTotalBytes)}`)}`
test/system-crew.test.ts +31
@@ -1,6 +1,9 @@
11 import { expect, test } from "bun:test";
22 import type { Proc, Raw } from "../src/system/proc.ts";
33 import { buildSystem, forAgent } from "../src/system/view.ts";
4 import { createLive } from "../src/system/live.ts";
5 import { regions } from "../src/web/pages/system.ts";
6 import { systemLines } from "../src/tui/screens/system.ts";
47
58 const proc = (pid: number, ppid: number): Proc => ({ pid, ppid, comm: "bun", ticks: 0, rss: 1 << 20, age: 5, tests: [] });
69 const raw: Raw = {
@@ -18,3 +21,31 @@ test("a crew run's tree is a crew:<name> group, hidden by name from agents", ()
1821 expect(seen.label).toBe("crew:private-1");
1922 expect(seen.top).toEqual([]);
outdated

Covers buildSystem only; nothing proves live.ts passes the limits, and the page and TUI pills (limitCores ?? cores.length) have no test.

2023 });
24
25 const GiB = 1024 ** 3;
26 const hostRaw: Raw = {
27 ...raw,
28 cores: Array.from({ length: 80 }, () => ({ busy: 0, total: 0 })),
29 memory: { total: 64 * GiB, available: 4 * GiB, swapTotal: 0, swapFree: 0 },
30 };
31
32 test("a limited pod shows its own memory use, not the host's (#12347)", () => {
33 const v = buildSystem(undefined, hostRaw, [], 1, { cpus: 2, memoryBytes: GiB / 2, usedBytes: GiB / 4 });
34 expect(v.cpu.limitCores).toBe(2);
35 expect(v.memory!.totalBytes).toBe(GiB / 2);
36 expect(v.memory!.usedBytes).toBe(GiB / 4); // host uses 60 GiB; not "512 MiB of 512 MiB"
37 expect(buildSystem(undefined, hostRaw, [], 1).memory!.usedBytes).toBe(60 * GiB);
38 });
39
40 test("live passes the pod's limits to the view, and the page and TUI say the limit", () => {
41 const l = createLive({ paths: { proc: "/nonexistent", sys: "/nonexistent" }, roots: () => [], self: 1,
42 limits: () => ({ cpus: 2, memoryBytes: GiB / 2, usedBytes: GiB / 4 }) });
43 const v = l.read(1_000_000);
44 expect(v.cpu.limitCores).toBe(2);
45 const cores = Array.from({ length: 8 }, () => 0);
46 const view = { ...v, cpu: { ...v.cpu, cores }, memory: { totalBytes: GiB / 2, usedBytes: GiB / 4, swapUsedBytes: 0, swapTotalBytes: 0 } };
47 expect(regions(view)["summary"]).toContain("2 cores");
48 expect(regions(view)["summary"]).not.toContain("8 cores");
49 expect(regions(view)["cores"]).toContain("the rows below are the host's");
50 expect(systemLines(view)[0]).toContain("over 2 cores");
51 });
test/vitals.test.ts +26 −1
@@ -4,7 +4,7 @@ import { laneSentence } from "../src/kernel/vitals.ts";
44 import { afterAll, beforeAll, expect, test } from "bun:test";
55 import { writeFileSync } from "node:fs";
66 import { accessDir, flushAccess, recordAccess } from "../src/http/access_log.ts";
7 import { mkdtemp, rm } from "node:fs/promises";
7 import { mkdtemp, rm, writeFile } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createApp, type App } from "../src/app.ts";
@@ -535,3 +535,28 @@ test("an access log that cannot write is a need past five minutes; an idle forge
535535 await rm(dir, { recursive: true, force: true });
536536 }
537537 });
538
539 test("vitals memory follows the pod's injected limit and usage (#12347)", async () => {
540 const { memoryReading, vitals } = await import("../src/kernel/vitals.ts");
541 const { podMemoryUsedBytes } = await import("../src/core/capacity.ts");
542 const MiB = 1024 * 1024;
543 expect(memoryReading({ limitBytes: 512 * MiB, usedBytes: 100 * MiB }, 50 * MiB)).toMatchObject({
outdated

This restates the implementation using the same functions; it passes with the old totalmem() whenever no cgroup limit is set. Inject the limit and assert vitals().memory follows it.

544 totalBytes: 512 * MiB,
545 rssBytes: 50 * MiB,
546 });
547 expect(memoryReading({ limitBytes: 512 * MiB, usedBytes: 100 * MiB }, 50 * MiB).freeBytes).toBeLessThanOrEqual(412 * MiB);
548 // Unreadable cgroup: the process's own resident memory stands in.
549 expect(memoryReading({ limitBytes: 512 * MiB, usedBytes: null }, 500 * MiB).freeBytes).toBeLessThanOrEqual(12 * MiB);
550 const v = vitals({ db: app.db, signals: app.signals, dataDir, disk: roomy, podMemory: { limitBytes: 123 * MiB, usedBytes: 23 * MiB } });
551 expect(v.memory.totalBytes).toBe(123 * MiB);
552 expect(v.memory.freeBytes).toBeLessThanOrEqual(100 * MiB);
553 const dir = await mkdtemp(join(tmpdir(), "cg-"));
554 try {
555 await writeFile(join(dir, "memory.current"), "1000\n");
556 await writeFile(join(dir, "memory.stat"), "anon 5\ninactive_file 300\n");
557 expect(podMemoryUsedBytes(dir)).toBe(700);
558 expect(podMemoryUsedBytes(join(dir, "none"))).toBeNull();
559 } finally {
560 await rm(dir, { recursive: true, force: true });
561 }
562 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs