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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-21 19:36:06 +02:00
1 parent 9941db1549
commit 07f9615dba
20 files changed
+313 -26

No files matched your search

+5 -3
View File
@@ -533,9 +533,11 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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"
# -----------------------------------------------------------------------------
+4 -3
View File
@@ -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:
+10 -8
View File
@@ -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
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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 토큰을 허용해요.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
@@ -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 () => {
+16 -2
View File
@@ -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,
+13 -2
View File
@@ -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
+15 -4
View File
@@ -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}
/>
<StatusItem
icon={Clock}
+9
View File
@@ -70,6 +70,15 @@ export async function register() {
// A diagnostic must never break boot.
}
// One line when the container's memory limit is below the supported
// floor. The app keeps running.
try {
const { logMemoryFloorAtBoot } = await import("@/lib/support/heap-limit");
logMemoryFloorAtBoot();
} catch {
// A diagnostic must never break boot.
}
const { shouldRunWorker } = await import("@/lib/process-type");
if (!shouldRunWorker()) {
// Split deployment: web requests still produce queue jobs, but this
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import {
containerMemoryLimitMb,
isBelowMemoryFloor,
logMemoryFloorAtBoot,
MEMORY_FLOOR_MB,
parseCgroupMemoryLimit,
} from "@/lib/support/heap-limit";
const MiB = 1024 * 1024;
describe("memory floor", () => {
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<string, string> = {
"/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();
});
});
@@ -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);
+82
View File
@@ -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;
}
+18
View File
@@ -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));