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

@@ -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`,