From 07f9615dbaed93c234e66f0aed224ce2c574435f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 19:36:06 +0200 Subject: [PATCH] feat(ops): name 1536 MB as the supported memory floor The getting-started requirements now say a host needs 3 GB of RAM, 4 GB recommended, and drop the advice to lower APP_MEMORY_LIMIT to 1g on a 2 GB host. The compose and .env example comments say the same. At boot the app reads its cgroup memory limit (v2, then v1) and logs one line when it is below MEMORY_FLOOR_MB. The support report adds the container limit and a note below the floor, and Admin > System status shows "below the supported floor" next to the existing low-heap flag. The app keeps running either way. --- .env.production.example | 8 +- docker-compose.yml | 7 +- docs/self-hosting/getting-started.md | 18 ++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + .../api/admin/status/__tests__/route.test.ts | 43 +++++++++- src/app/api/admin/status/route.ts | 18 +++- src/components/admin/_shared.tsx | 15 +++- .../admin/system-status-section.tsx | 19 ++++- src/instrumentation.ts | 9 ++ .../support/__tests__/memory-floor.test.ts | 69 ++++++++++++++++ .../support-report-redaction.test.ts | 25 ++++++ src/lib/support/heap-limit.ts | 82 +++++++++++++++++++ src/lib/support/report.ts | 18 ++++ 20 files changed, 313 insertions(+), 26 deletions(-) create mode 100644 src/lib/support/__tests__/memory-floor.test.ts diff --git a/.env.production.example b/.env.production.example index 0428201a7..ecf31e293 100644 --- a/.env.production.example +++ b/.env.production.example @@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="" # TRUST_CF_CONNECTING_IP="1" # Memory cap of the app container (read by docker-compose.yml, not by the app). -# Node sizes its heap from it: 1536m (the default) gives 792 MB, 1g only 524 MB, -# which a long-used account fills in normal operation. Lower it on a host with -# 2 GB or less, raise it on a busy instance. +# Node sizes its heap from it: 1536m (the default) gives 792 MB. 1536m is the +# supported floor: below it a long-used account fills the heap in normal +# operation, and the boot log, the support report and Admin > System status +# say the app runs below the supported floor. The host needs 3 GB of RAM, +# 4 GB recommended. Raise it on a busy instance; do not lower it. # APP_MEMORY_LIMIT="1536m" # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index fb778bee4..af4954609 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -503,9 +503,10 @@ services: # workload can grow large enough to starve Postgres on a shared host. # Node sizes its heap from this limit: 1g gives a 524 MB heap, which a # long-used account fills in normal operation, so garbage collection - # stalls requests for seconds. 1536m gives 792 MB. The support report and - # Admin > System status flag a heap below 700 MB. On a host with 2 GB or less, lower it with - # APP_MEMORY_LIMIT in .env; on a busy instance, raise it. + # stalls requests for seconds. 1536m gives 792 MB and is the supported + # floor (host: 3 GB RAM, 4 GB recommended). Below it the boot log, the + # support report and Admin > System status say so. On a busy instance, + # raise it with APP_MEMORY_LIMIT in .env; do not lower it. mem_limit: "${APP_MEMORY_LIMIT:-1536m}" healthcheck: test: diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e9ee7aa1f..0541c2333 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -11,14 +11,16 @@ already installed, longer if a TLS-fronted public hostname is in scope pulls a multi-arch image from GitHub Container Registry (linux/amd64 - linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work unchanged. -- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with - measurement history; the Apple Health import worker can briefly - hold a 1.5 GB upload in `/tmp` while it parses. The app container is - capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node then sizes its - heap at about 792 MB). On a 2 GB host that leaves too little for - Postgres and the system, so set `APP_MEMORY_LIMIT=1g` in `.env` - (a heap of about 524 MB); a long-used account then runs closer to - that heap, and the support report and Admin > System status say so. +- **3 GB RAM (4 GB recommended), 10 GB free disk.** The Postgres data + volume grows with measurement history; the Apple Health import worker + can briefly hold a 1.5 GB upload in `/tmp` while it parses. The app + container is capped at `APP_MEMORY_LIMIT`, `1536m` by default (Node + then sizes its heap at about 792 MB), and Postgres and the system need + the rest. 1536 MB is the supported floor for the app container: do not + lower it to fit a smaller host. Below it, a long-used account or a large + import can fill the heap and restart the app; the app still starts, but + the boot log, the support report and Admin > System status say it runs + below the supported floor. On a busy instance, raise it. - **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever integration endpoints you plan to enable (Withings, OpenAI, etc.). The opt-in environmental-context module additionally reaches diff --git a/messages/de.json b/messages/de.json index b8afe311c..ba1ca12ef 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap-Grenze", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen", + "memoryBelowFloor": "Container {mb} MB, unter der unterstützten Untergrenze von {floor} MB. APP_MEMORY_LIMIT={floor}m in .env setzen", "offlineGeoOff": "Aus", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-Endpunkte und API-Tokens erlauben.", diff --git a/messages/en.json b/messages/en.json index 8ec8c0e61..3a6dfe846 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heap limit", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, below the supported floor of {floor} MB. Set APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Off", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Allow API endpoints and API tokens.", diff --git a/messages/es.json b/messages/es.json index 80928dc2d..0196a1f7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Límite de heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env", + "memoryBelowFloor": "Contenedor {mb} MB, por debajo del mínimo admitido de {floor} MB: define APP_MEMORY_LIMIT={floor}m en .env", "offlineGeoOff": "Desactivado", "offlineGeoChosen": "En línea ({host})", "apiGlobal": "Permitir endpoints de API y tokens de API.", diff --git a/messages/fr.json b/messages/fr.json index 4f7058a57..d7109278c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite du tas", "heapLimitValue": "{mb} Mo", "heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env", + "memoryBelowFloor": "Conteneur {mb} Mo, sous le minimum pris en charge de {floor} Mo : définissez APP_MEMORY_LIMIT={floor}m dans .env", "offlineGeoOff": "Désactivé", "offlineGeoChosen": "En ligne ({host})", "apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.", diff --git a/messages/it.json b/messages/it.json index a1dc08274..91db78f4b 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limite heap", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env", + "memoryBelowFloor": "Container {mb} MB, sotto il minimo supportato di {floor} MB: imposta APP_MEMORY_LIMIT={floor}m in .env", "offlineGeoOff": "Disattivato", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Consentire endpoint API e token API.", diff --git a/messages/ko.json b/messages/ko.json index 8712f3f1a..0708547d5 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7575,6 +7575,7 @@ "heapLimitLabel": "힙 한도", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요", + "memoryBelowFloor": "컨테이너 {mb} MB, 지원 최소치 {floor} MB 미만입니다. .env에 APP_MEMORY_LIMIT={floor}m을 설정하세요", "offlineGeoOff": "꺼짐", "offlineGeoChosen": "온라인 ({host})", "apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.", diff --git a/messages/nl.json b/messages/nl.json index dc0c5e5f0..c5e9d9a13 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Heaplimiet", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, laag. Stel APP_MEMORY_LIMIT=1536m in via .env", + "memoryBelowFloor": "Container {mb} MB, onder het ondersteunde minimum van {floor} MB. Stel APP_MEMORY_LIMIT={floor}m in via .env", "offlineGeoOff": "Uit", "offlineGeoChosen": "Online ({host})", "apiGlobal": "API-endpoints en API-tokens toestaan.", diff --git a/messages/pl.json b/messages/pl.json index 625abe2a4..6d55a5d6d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7600,6 +7600,7 @@ "heapLimitLabel": "Limit sterty", "heapLimitValue": "{mb} MB", "heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env", + "memoryBelowFloor": "Kontener {mb} MB, poniżej obsługiwanego minimum {floor} MB: ustaw APP_MEMORY_LIMIT={floor}m w .env", "offlineGeoOff": "Wyłączone", "offlineGeoChosen": "Online ({host})", "apiGlobal": "Zezwól na punkty końcowe API i tokeny API.", diff --git a/src/app/api/admin/status/__tests__/route.test.ts b/src/app/api/admin/status/__tests__/route.test.ts index 3bf2f8165..46a6b2a86 100644 --- a/src/app/api/admin/status/__tests__/route.test.ts +++ b/src/app/api/admin/status/__tests__/route.test.ts @@ -44,12 +44,19 @@ vi.mock("@/lib/jobs/worker-status", () => ({ })), })); -const heapMock = vi.hoisted(() => ({ mb: 792 })); +const heapMock = vi.hoisted(() => ({ + mb: 792, + container: 1536 as number | null, +})); vi.mock("@/lib/support/heap-limit", async () => { const actual = await vi.importActual< typeof import("@/lib/support/heap-limit") >("@/lib/support/heap-limit"); - return { ...actual, heapLimitMb: () => heapMock.mb }; + return { + ...actual, + heapLimitMb: () => heapMock.mb, + containerMemoryLimitMb: () => heapMock.container, + }; }); const geoMock = vi.hoisted(() => ({ @@ -200,12 +207,40 @@ describe("GET /api/admin/status (integration health summary)", () => { const low = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(low.data.memory).toEqual({ heapLimitMb: 524, low: true }); + expect(low.data.memory).toMatchObject({ heapLimitMb: 524, low: true }); heapMock.mb = 792; const enough = (await (await GET()).json()) as { data: { memory: { heapLimitMb: number; low: boolean } }; }; - expect(enough.data.memory).toEqual({ heapLimitMb: 792, low: false }); + expect(enough.data.memory).toMatchObject({ heapLimitMb: 792, low: false }); + }); + + it("flags a container memory limit below the supported floor", async () => { + type Memory = { + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; + heapMock.container = 1024; + const below = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(below.data.memory).toMatchObject({ + containerLimitMb: 1024, + floorMb: 1536, + belowFloor: true, + }); + heapMock.container = 1536; + const atFloor = (await (await GET()).json()) as { + data: { memory: Memory }; + }; + expect(atFloor.data.memory.belowFloor).toBe(false); + // No limit, or none readable: nothing to flag. + heapMock.container = null; + const none = (await (await GET()).json()) as { data: { memory: Memory } }; + expect(none.data.memory).toMatchObject({ + containerLimitMb: null, + belowFloor: false, + }); + heapMock.container = 1536; }); it("reports named failing queues through failingJobs only", async () => { diff --git a/src/app/api/admin/status/route.ts b/src/app/api/admin/status/route.ts index a8d9968d8..0cfd3a0c4 100644 --- a/src/app/api/admin/status/route.ts +++ b/src/app/api/admin/status/route.ts @@ -9,7 +9,13 @@ import { readFailingQueues, } from "@/lib/jobs/job-failures"; import { readFileSync } from "node:fs"; -import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit"; +import { + containerMemoryLimitMb, + heapLimitMb, + isBelowMemoryFloor, + isHeapLimitLow, + MEMORY_FLOOR_MB, +} from "@/lib/support/heap-limit"; import { geoLookupMode, geoProviderChosen, @@ -77,6 +83,8 @@ export const GET = apiHandler(async () => { // The process's heap ceiling, and whether it is the 1 GB-container size // that a long-used account can fill (`heap-limit.ts`). const heapLimit = heapLimitMb(); + // And the container's own limit against the supported floor. + const containerLimit = containerMemoryLimitMb(); return apiSuccess({ version: process.env.npm_package_version || "0.1.0", @@ -85,7 +93,13 @@ export const GET = apiHandler(async () => { buildTime: BUILD_TIME, startTime: START_TIME, database: "connected", - memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) }, + memory: { + heapLimitMb: heapLimit, + low: isHeapLimitLow(heapLimit), + containerLimitMb: containerLimit, + floorMb: MEMORY_FLOOR_MB, + belowFloor: isBelowMemoryFloor(containerLimit), + }, worker: { running: workerStatus.running, startedAt: workerStatus.startedAt, diff --git a/src/components/admin/_shared.tsx b/src/components/admin/_shared.tsx index 69f00d639..09bd986b8 100644 --- a/src/components/admin/_shared.tsx +++ b/src/components/admin/_shared.tsx @@ -61,8 +61,19 @@ export interface SystemStatus { buildTime: string; startTime: string; database: string; - /** The process's heap ceiling; `low` when it is below what a long-used account needs. */ - memory: { heapLimitMb: number; low: boolean }; + /** + * The process's heap ceiling; `low` when it is below what a long-used + * account needs. `containerLimitMb` is the container's memory limit + * (`null` when there is none or it cannot be read), `belowFloor` when it is + * under the supported floor of `floorMb`. + */ + memory: { + heapLimitMb: number; + low: boolean; + containerLimitMb: number | null; + floorMb: number; + belowFloor: boolean; + }; worker: WorkerStatus; /** * Queues whose jobs exhausted their retries recently. `null` means the diff --git a/src/components/admin/system-status-section.tsx b/src/components/admin/system-status-section.tsx index 8f520e46c..c23d4c0d9 100644 --- a/src/components/admin/system-status-section.tsx +++ b/src/components/admin/system-status-section.tsx @@ -164,11 +164,22 @@ export function SystemStatusSection() { icon={MemoryStick} label={t("admin.heapLimitLabel")} value={ - status.memory.low - ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) - : t("admin.heapLimitValue", { mb: status.memory.heapLimitMb }) + status.memory.belowFloor && status.memory.containerLimitMb + ? t("admin.memoryBelowFloor", { + mb: status.memory.containerLimitMb, + floor: status.memory.floorMb, + }) + : status.memory.low + ? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb }) + : t("admin.heapLimitValue", { + mb: status.memory.heapLimitMb, + }) + } + tone={ + status.memory.belowFloor || status.memory.low + ? "warning" + : undefined } - tone={status.memory.low ? "warning" : undefined} /> { + it("is the compose default", () => { + expect(MEMORY_FLOOR_MB).toBe(1536); + }); + + it("parses cgroup limit files", () => { + expect(parseCgroupMemoryLimit(`${1536 * MiB}\n`)).toBe(1536); + expect(parseCgroupMemoryLimit(`${1024 * MiB}`)).toBe(1024); + expect(parseCgroupMemoryLimit("max\n")).toBeNull(); + // cgroup v1 reports "no limit" as a huge page-aligned sentinel. + expect(parseCgroupMemoryLimit("9223372036854771712")).toBeNull(); + expect(parseCgroupMemoryLimit("")).toBeNull(); + expect(parseCgroupMemoryLimit("garbage")).toBeNull(); + expect(parseCgroupMemoryLimit("0")).toBeNull(); + }); + + it("reads cgroup v2 first, then v1, and gives null without either", () => { + const files: Record = { + "/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MiB}`, + }; + const read = (path: string) => { + if (path in files) return files[path]; + throw new Error("ENOENT"); + }; + expect(containerMemoryLimitMb(read)).toBe(1024); + files["/sys/fs/cgroup/memory.max"] = `${2048 * MiB}`; + expect(containerMemoryLimitMb(read)).toBe(2048); + files["/sys/fs/cgroup/memory.max"] = "max"; + expect(containerMemoryLimitMb(read)).toBeNull(); + expect( + containerMemoryLimitMb(() => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + }); + + it("flags only a known limit under the floor", () => { + expect(isBelowMemoryFloor(1024)).toBe(true); + expect(isBelowMemoryFloor(1535)).toBe(true); + expect(isBelowMemoryFloor(1536)).toBe(false); + expect(isBelowMemoryFloor(4096)).toBe(false); + expect(isBelowMemoryFloor(null)).toBe(false); + }); + + it("logs exactly one line at boot below the floor, none otherwise", () => { + const warn = vi.fn(); + expect(logMemoryFloorAtBoot(1024, warn)).toBe(1024); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch( + /^\[boot\] The container memory limit of 1024 MB is below the supported floor of 1536 MB\./, + ); + expect(warn.mock.calls[0][0]).not.toContain("\n"); + warn.mockClear(); + logMemoryFloorAtBoot(1536, warn); + logMemoryFloorAtBoot(null, warn); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/support/__tests__/support-report-redaction.test.ts b/src/lib/support/__tests__/support-report-redaction.test.ts index 72bc7f1ea..f42c18c92 100644 --- a/src/lib/support/__tests__/support-report-redaction.test.ts +++ b/src/lib/support/__tests__/support-report-redaction.test.ts @@ -57,6 +57,7 @@ function seededFacts(): SupportReportFacts { node: "v22.11.0", processType: "web,worker", heapLimitMb: 792, + containerMemoryLimitMb: 1536, uptimeSeconds: 7260, }, database: { @@ -219,6 +220,30 @@ describe("support report redaction", () => { expect(enough).not.toContain("Heap limit note"); }); + it("says when the container runs below the supported memory floor", () => { + const facts = seededFacts(); + const below = redactSupportText( + renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: 1024 }, + }), + CONTEXT, + ); + expect(below).toContain("- Container memory limit: 1024 MB"); + expect(below).toContain( + "- Memory note: Below the supported floor. The container memory limit of 1024 MB is below the supported floor of 1536 MB.", + ); + const atFloor = renderSupportReport(facts); + expect(atFloor).toContain("- Container memory limit: 1536 MB"); + expect(atFloor).not.toContain("Memory note"); + const unknown = renderSupportReport({ + ...facts, + build: { ...facts.build, containerMemoryLimitMb: null }, + }); + expect(unknown).toContain("- Container memory limit: none or unreadable"); + expect(unknown).not.toContain("Memory note"); + }); + it("the seeds really are in the unredacted text", () => { const raw = renderSupportReport(seededFacts()); for (const value of FORBIDDEN) expect(raw).toContain(value); diff --git a/src/lib/support/heap-limit.ts b/src/lib/support/heap-limit.ts index 80f0dcfc6..dcf6a512b 100644 --- a/src/lib/support/heap-limit.ts +++ b/src/lib/support/heap-limit.ts @@ -9,6 +9,7 @@ * system status say so in one sentence, because the number alone ("Heap * limit: 524 MB") reads as a fact rather than as the cause (#1239). */ +import { readFileSync } from "node:fs"; import { getHeapStatistics } from "node:v8"; /** Below this, the heap is flagged. Between the 1g (524) and 1536m (792) heaps. */ @@ -38,3 +39,84 @@ export function lowHeapLimitAdvice(limitMb: number): string { "docker compose up -d." ); } + +/** + * The smallest container memory limit HealthLog supports: the compose + * default. Below it the heap is too small for a long-used account and a + * large import, so the boot log, the support report and Admin > System + * status say the host runs below the supported floor. The app keeps + * running; the floor is advice, not a gate. + */ +export const MEMORY_FLOOR_MB = 1536; + +/** + * The cgroup files that hold a container's memory limit: cgroup v2 first, + * then v1. A v1 host without a limit reports a huge sentinel value rather + * than "max", so anything at or above 1 TiB counts as unlimited. + */ +const CGROUP_LIMIT_FILES = [ + "/sys/fs/cgroup/memory.max", + "/sys/fs/cgroup/memory/memory.limit_in_bytes", +] as const; +const UNLIMITED_BYTES = 1024 ** 4; + +/** Parse one cgroup limit file's content; `null` for no limit or garbage. */ +export function parseCgroupMemoryLimit(raw: string): number | null { + const text = raw.trim(); + if (text === "" || text === "max") return null; + if (!/^\d+$/.test(text)) return null; + const bytes = Number(text); + if (!Number.isFinite(bytes) || bytes <= 0 || bytes >= UNLIMITED_BYTES) { + return null; + } + return megabytes(bytes); +} + +/** + * This process's container memory limit in megabytes, or `null` when there + * is none or it cannot be read (no cgroup, not Linux, unlimited). + */ +export function containerMemoryLimitMb( + read: (path: string) => string = (path) => readFileSync(path, "utf8"), +): number | null { + for (const file of CGROUP_LIMIT_FILES) { + let raw: string; + try { + raw = read(file); + } catch { + continue; + } + return parseCgroupMemoryLimit(raw); + } + return null; +} + +/** Whether a known container limit is below the supported floor. */ +export function isBelowMemoryFloor(limitMb: number | null): boolean { + return limitMb !== null && limitMb < MEMORY_FLOOR_MB; +} + +/** The sentence the boot log and the support report use for it. */ +export function memoryFloorAdvice(limitMb: number): string { + return ( + `The container memory limit of ${limitMb} MB is below the supported ` + + `floor of ${MEMORY_FLOOR_MB} MB. HealthLog keeps running, but a ` + + "long-used account or a large import can fill the heap and restart " + + `the app. Set APP_MEMORY_LIMIT=${MEMORY_FLOOR_MB}m or more in .env and ` + + "run docker compose up -d; the host needs 3 GB of RAM, 4 GB recommended." + ); +} + +/** + * Log one line at boot when the container runs below the floor. Never + * throws. Returns the limit it found, for tests. + */ +export function logMemoryFloorAtBoot( + limitMb: number | null = containerMemoryLimitMb(), + warn: (line: string) => void = console.warn, +): number | null { + if (isBelowMemoryFloor(limitMb)) { + warn(`[boot] ${memoryFloorAdvice(limitMb as number)}`); + } + return limitMb; +} diff --git a/src/lib/support/report.ts b/src/lib/support/report.ts index 1160b0327..d186ee20b 100644 --- a/src/lib/support/report.ts +++ b/src/lib/support/report.ts @@ -22,10 +22,13 @@ import { prisma } from "@/lib/db"; import { failureCondition } from "@/lib/jobs/failure-condition"; import { + containerMemoryLimitMb, heapLimitMb, + isBelowMemoryFloor, isHeapLimitLow, lowHeapLimitAdvice, megabytes, + memoryFloorAdvice, } from "@/lib/support/heap-limit"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc"; @@ -85,6 +88,8 @@ export interface SupportReportFacts { node: string; processType: string; heapLimitMb: number; + /** The container's memory limit; `null` when there is none or it cannot be read. */ + containerMemoryLimitMb: number | null; uptimeSeconds: number; }; database: { @@ -617,6 +622,7 @@ export async function collectSupportReportFacts( node: process.version, processType: getProcessType(), heapLimitMb: heapLimitMb(), + containerMemoryLimitMb: containerMemoryLimitMb(), uptimeSeconds: Math.round(process.uptime()), }, database, @@ -730,6 +736,18 @@ export function renderSupportReport(facts: SupportReportFacts): string { row("Built at", facts.build.builtAt); row("Node", facts.build.node); row("Process type", facts.build.processType); + row( + "Container memory limit", + facts.build.containerMemoryLimitMb === null + ? "none or unreadable" + : `${facts.build.containerMemoryLimitMb} MB`, + ); + if (isBelowMemoryFloor(facts.build.containerMemoryLimitMb)) { + row( + "Memory note", + `Below the supported floor. ${memoryFloorAdvice(facts.build.containerMemoryLimitMb as number)}`, + ); + } row("Heap limit", `${facts.build.heapLimitMb} MB`); if (isHeapLimitLow(facts.build.heapLimitMb)) { row("Heap limit note", lowHeapLimitAdvice(facts.build.heapLimitMb));