mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
9941db1549
commit
07f9615dba
20 files changed
+313
-26
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 토큰을 허용해요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user