feat(admin): flag a heap limit below 700 MB with the setting to raise

The support report and the system status now say in one sentence when the
heap is the 1 GB-container size and point at APP_MEMORY_LIMIT.

Refs #1239
This commit is contained in:
Marc-André Bombeck committed 2026-10-18 13:10:20 +02:00
1 parent ac047854ba
commit 1d7077286f
16 files changed
+147 -7

No files matched your search

+2 -2
View File
@@ -492,8 +492,8 @@ 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 and a weekly backup can crash the process.
# 1536m gives 792 MB. On a host with 2 GB or less, lower it with
# 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.
mem_limit: "${APP_MEMORY_LIMIT:-1536m}"
healthcheck:
+1 -1
View File
@@ -18,7 +18,7 @@ already installed, longer if a TLS-fronted public hostname is in scope
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 weekly backup with it.
that heap, and the support report and Admin > System status say so.
- **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
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Geodaten",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}). GeoLite2-Datenbanken einbinden und GEOLITE2_DIR setzen",
"heapLimitLabel": "Heap-Grenze",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, zu knapp. APP_MEMORY_LIMIT=1536m in .env setzen",
"offlineGeoOff": "Aus",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "API-Endpunkte und API-Tokens erlauben.",
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Geo data",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}). Mount GeoLite2 databases and set GEOLITE2_DIR",
"heapLimitLabel": "Heap limit",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, low. Set APP_MEMORY_LIMIT=1536m in .env",
"offlineGeoOff": "Off",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Allow API endpoints and API tokens.",
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Datos geográficos",
"offlineGeoEnabled": "Sin conexión (GeoLite2)",
"offlineGeoFallback": "En línea ({host}): monta las bases GeoLite2 y define GEOLITE2_DIR",
"heapLimitLabel": "Límite de heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, escaso: define APP_MEMORY_LIMIT=1536m en .env",
"offlineGeoOff": "Desactivado",
"offlineGeoChosen": "En línea ({host})",
"apiGlobal": "Permitir endpoints de API y tokens de API.",
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Données géographiques",
"offlineGeoEnabled": "Hors ligne (GeoLite2)",
"offlineGeoFallback": "En ligne ({host}) : montez les bases GeoLite2 et définissez GEOLITE2_DIR",
"heapLimitLabel": "Limite du tas",
"heapLimitValue": "{mb} Mo",
"heapLimitLow": "{mb} Mo, trop juste : définissez APP_MEMORY_LIMIT=1536m dans .env",
"offlineGeoOff": "Désactivé",
"offlineGeoChosen": "En ligne ({host})",
"apiGlobal": "Autoriser les points de terminaison d'API et les jetons d'API.",
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Dati geografici",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}): monta i database GeoLite2 e imposta GEOLITE2_DIR",
"heapLimitLabel": "Limite heap",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, poco: imposta APP_MEMORY_LIMIT=1536m in .env",
"offlineGeoOff": "Disattivato",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Consentire endpoint API e token API.",
+3
View File
@@ -7546,6 +7546,9 @@
"offlineGeoLabel": "위치 데이터",
"offlineGeoEnabled": "오프라인 (GeoLite2)",
"offlineGeoFallback": "온라인 ({host}). GeoLite2 데이터베이스를 마운트하고 GEOLITE2_DIR을 설정하세요",
"heapLimitLabel": "힙 한도",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, 부족합니다. .env에 APP_MEMORY_LIMIT=1536m을 설정하세요",
"offlineGeoOff": "꺼짐",
"offlineGeoChosen": "온라인 ({host})",
"apiGlobal": "API 엔드포인트와 API 토큰을 허용해요.",
+3
View File
@@ -7571,6 +7571,9 @@
"offlineGeoLabel": "Dane geograficzne",
"offlineGeoEnabled": "Offline (GeoLite2)",
"offlineGeoFallback": "Online ({host}): podłącz bazy GeoLite2 i ustaw GEOLITE2_DIR",
"heapLimitLabel": "Limit sterty",
"heapLimitValue": "{mb} MB",
"heapLimitLow": "{mb} MB, za mało: ustaw APP_MEMORY_LIMIT=1536m w .env",
"offlineGeoOff": "Wyłączone",
"offlineGeoChosen": "Online ({host})",
"apiGlobal": "Zezwól na punkty końcowe API i tokeny API.",
@@ -44,6 +44,14 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
const heapMock = vi.hoisted(() => ({ mb: 792 }));
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 };
});
const geoMock = vi.hoisted(() => ({
lookup: "online" as "offline" | "online" | "off",
chosen: false,
@@ -187,6 +195,19 @@ describe("GET /api/admin/status (integration health summary)", () => {
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
it("flags a heap limit too small for a long-used account", async () => {
heapMock.mb = 524;
const low = (await (await GET()).json()) as {
data: { memory: { heapLimitMb: number; low: boolean } };
};
expect(low.data.memory).toEqual({ 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 });
});
it("reports named failing queues through failingJobs only", async () => {
vi.mocked(readFailingQueues).mockResolvedValueOnce([
{
+5
View File
@@ -9,6 +9,7 @@ import {
readFailingQueues,
} from "@/lib/jobs/job-failures";
import { readFileSync } from "node:fs";
import { heapLimitMb, isHeapLimitLow } from "@/lib/support/heap-limit";
import {
geoLookupMode,
geoProviderChosen,
@@ -73,6 +74,9 @@ export const GET = apiHandler(async () => {
// reports. `null` = no queue schema to ask (web-only deployment); an empty
// array = asked, nothing is failing.
const failingJobs = await readFailingQueues();
// 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();
return apiSuccess({
version: process.env.npm_package_version || "0.1.0",
@@ -81,6 +85,7 @@ export const GET = apiHandler(async () => {
buildTime: BUILD_TIME,
startTime: START_TIME,
database: "connected",
memory: { heapLimitMb: heapLimit, low: isHeapLimitLow(heapLimit) },
worker: {
running: workerStatus.running,
startedAt: workerStatus.startedAt,
+2
View File
@@ -61,6 +61,8 @@ 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 };
worker: WorkerStatus;
/**
* Queues whose jobs exhausted their retries recently. `null` means the
@@ -13,6 +13,7 @@ import {
Key,
Loader2,
Map,
MemoryStick,
Server,
Users,
} from "lucide-react";
@@ -156,6 +157,16 @@ export function SystemStatusSection() {
}
tone={status.database === "connected" ? "success" : "destructive"}
/>
<StatusItem
icon={MemoryStick}
label={t("admin.heapLimitLabel")}
value={
status.memory.low
? t("admin.heapLimitLow", { mb: status.memory.heapLimitMb })
: t("admin.heapLimitValue", { mb: status.memory.heapLimitMb })
}
tone={status.memory.low ? "warning" : undefined}
/>
<StatusItem
icon={Clock}
label={t("admin.startedAt")}
@@ -200,6 +200,25 @@ describe("support report redaction", () => {
expect(text).toContain("environment-fetch");
});
it("says what to raise when the heap limit is low, and keeps it through redaction", () => {
const facts = seededFacts();
const low = redactSupportText(
renderSupportReport({
...facts,
build: { ...facts.build, heapLimitMb: 524 },
}),
CONTEXT,
);
expect(low).toContain("- Heap limit: 524 MB");
expect(low).toContain(
"- Heap limit note: The heap limit of 524 MB is below 700 MB.",
);
expect(low).toContain("APP_MEMORY_LIMIT=1536m in .env");
expect(low).toContain("docker compose up -d.");
const enough = redactSupportText(renderSupportReport(facts), CONTEXT);
expect(enough).not.toContain("Heap limit note");
});
it("the seeds really are in the unredacted text", () => {
const raw = renderSupportReport(seededFacts());
for (const value of FORBIDDEN) expect(raw).toContain(value);
@@ -256,6 +275,21 @@ describe("error classes never carry the message", () => {
expect(errorClassOf("job timed out: did not finish within 1 h")).toBe(
"timeout",
);
// A statement past its timeout, as Prisma words it: the SQLSTATE is the
// class, not the bare "Error" it used to read as.
expect(
errorClassOf(
"\nInvalid `prisma.measurement.findMany()` invocation:\n\n\nDatabase error. Code: `57014`. Message: `canceling statement due to statement timeout`",
),
).toBe("statement_timeout");
expect(
errorClassOf(
"compaction-tombstone-purge: stopped while deleting a batch (lock_timeout)",
),
).toBe("lock_timeout");
expect(errorClassOf("timeout exceeded when trying to connect")).toBe(
"pool_timeout",
);
expect(errorClassOf("anything at all, an email a@b.co")).toBe("Error");
expect(errorClassOf(null)).toBe("unknown");
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Whether this process's heap is below what a long-used account needs.
*
* Node sizes its heap from the container's memory limit: the 1 GB limit older
* compose files set gives 524 MB, the current default of 1536m gives 792 MB.
* At 524 MB a long-used account fills the heap in normal operation, garbage
* collection stalls requests, and a large import can run the process out of
* memory, which restarts the whole instance. The support report and the admin
* 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 { getHeapStatistics } from "node:v8";
/** Below this, the heap is flagged. Between the 1g (524) and 1536m (792) heaps. */
export const LOW_HEAP_LIMIT_MB = 700;
/** V8's heap limit for this process, in megabytes. */
export function heapLimitMb(): number {
return Math.round(getHeapStatistics().heap_size_limit / 1024 / 1024);
}
export function isHeapLimitLow(limitMb: number): boolean {
return limitMb < LOW_HEAP_LIMIT_MB;
}
/** The support report's sentence for a low heap limit. */
export function lowHeapLimitAdvice(limitMb: number): string {
return (
`The heap limit of ${limitMb} MB is below ${LOW_HEAP_LIMIT_MB} MB. The ` +
"usual cause is a 1 GB container memory limit. A long-used account can " +
"fill this heap, which slows requests and can restart the app. Set " +
"APP_MEMORY_LIMIT=1536m in .env (about 792 MB of heap) and run " +
"docker compose up -d."
);
}
+15 -4
View File
@@ -18,9 +18,14 @@
*
* The report is English on purpose: it is read by whoever answers the issue.
*/
import { getHeapStatistics } from "node:v8";
import { prisma } from "@/lib/db";
import { failureCondition } from "@/lib/jobs/failure-condition";
import {
heapLimitMb,
isHeapLimitLow,
lowHeapLimitAdvice,
} from "@/lib/support/heap-limit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { isOidcConfigured, isOidcOnly } from "@/lib/auth/oidc";
import { getLoggingConfig } from "@/lib/logging/config";
@@ -183,6 +188,11 @@ export function errorClassOf(text: string | null | undefined): string {
const parts: string[] = [];
const named = /^([A-Z][A-Za-z0-9]{1,60}(?:Error|Exception))\b/.exec(trimmed);
if (named) parts.push(named[1]);
// The database condition (`statement_timeout`, `lock_timeout`, ...): a
// Prisma error carries it only as a SQLSTATE inside the message, and
// without it a statement timeout read as the bare class "Error" (#1239).
const condition = failureCondition(trimmed);
if (condition) return [...parts, condition].join(" ");
const code =
/\b(P\d{4})\b/.exec(trimmed) ??
/\b(E[A-Z][A-Z_]{2,30})\b/.exec(trimmed) ??
@@ -605,9 +615,7 @@ export async function collectSupportReportFacts(
builtAt: process.env.NEXT_PUBLIC_APP_BUILT_AT?.trim() || null,
node: process.version,
processType: getProcessType(),
heapLimitMb: Math.round(
getHeapStatistics().heap_size_limit / 1024 / 1024,
),
heapLimitMb: heapLimitMb(),
uptimeSeconds: Math.round(process.uptime()),
},
database,
@@ -722,6 +730,9 @@ export function renderSupportReport(facts: SupportReportFacts): string {
row("Node", facts.build.node);
row("Process type", facts.build.processType);
row("Heap limit", `${facts.build.heapLimitMb} MB`);
if (isHeapLimitLow(facts.build.heapLimitMb)) {
row("Heap limit note", lowHeapLimitAdvice(facts.build.heapLimitMb));
}
row(
"Uptime",
`${Math.floor(facts.build.uptimeSeconds / 3600)} h ${Math.floor((facts.build.uptimeSeconds % 3600) / 60)} min`,