mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
1 parent
ac047854ba
commit
1d7077286f
16 files changed
+147
-7
No files matched your search
+2
-2
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 토큰을 허용해요.",
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user