mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(import): refuse an Apple Health import the heap cannot carry
Before the multi-minute unpack starts, compare the archive's declared uncompressed export.xml size against the V8 heap limit. The streaming parse keeps resident state span-bounded, so the model is generous — a fixed 192 MiB base plus 1/64 of the XML size; the 8 GiB extraction cap needs ~320 MiB, which any default heap clears. The check only refuses the truly impossible (an operator-pinned tiny heap against a huge export) and does so with an actionable failureReason naming NODE_OPTIONS=--max-old-space-size, instead of an OOM-killed worker and a job stuck in a running state (#775). Document the memory model, the insufficient_memory refusal, and the interrupted-import reconcile in the self-hosting scaling guide.
This commit is contained in:
1 parent
dd02f983cb
commit
50ff90a409
4 files changed
+179
-1
No files matched your search
@@ -56,6 +56,36 @@ know:
|
||||
Without a shared staging volume, imports in split mode cannot
|
||||
complete.
|
||||
|
||||
## Apple Health import memory
|
||||
|
||||
The import parses `export.xml` as a stream: the archive is never
|
||||
buffered whole, records flush to the database in fixed-size batches,
|
||||
and what stays resident scales with the data's calendar span, not its
|
||||
record count. Multi-year exports with millions of records parse within
|
||||
the default Node.js heap.
|
||||
|
||||
Before parsing starts, the worker compares the archive's declared
|
||||
uncompressed size against the runtime heap limit. If the limit is too
|
||||
low to carry the export — realistically only when
|
||||
`--max-old-space-size` was pinned far below the default — the import
|
||||
fails immediately with a `failureReason` starting with
|
||||
`insufficient_memory`, naming the required and available amounts.
|
||||
Raise the limit on the container that runs the worker and re-upload:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
NODE_OPTIONS: --max-old-space-size=1024
|
||||
```
|
||||
|
||||
An import interrupted by a container restart or an out-of-memory kill
|
||||
does not stay stuck: the worker reconciles non-terminal import jobs at
|
||||
boot and every 15 minutes, flipping orphaned rows to `failed`
|
||||
(`interrupted_by_restart`) so the UI offers a retry instead of an
|
||||
endless spinner.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
|
||||
@@ -142,6 +142,30 @@ describe("extractExportXml", () => {
|
||||
expect(extracted.toString("utf8")).toBe(payload.toString("utf8"));
|
||||
});
|
||||
|
||||
// (issue #775) — the preflight hook sees the declared sizes before
|
||||
// any extraction work starts; throwing there must abort the extract
|
||||
// with the hook's error.
|
||||
it("aborts before extraction when the preflight hook throws", async () => {
|
||||
const payload = Buffer.from(
|
||||
`<?xml version="1.0"?><HealthData><ExportDate value="2026-05-15"/></HealthData>`,
|
||||
);
|
||||
const zip = buildMinimalZip("apple_health_export/export.xml", payload);
|
||||
const tmp = mkdtempSync(join(tmpdir(), "healthlog-unzip-"));
|
||||
const zipPath = join(tmp, "export.zip");
|
||||
writeFileSync(zipPath, zip);
|
||||
|
||||
let seenDeclaredBytes = -1;
|
||||
await expect(
|
||||
extractExportXml(zipPath, {
|
||||
preflight: ({ declaredXmlBytes }) => {
|
||||
seenDeclaredBytes = declaredXmlBytes;
|
||||
throw new Error("insufficient_memory: preflight refused");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/insufficient_memory/);
|
||||
expect(seenDeclaredBytes).toBe(payload.length);
|
||||
});
|
||||
|
||||
it("throws when the export.xml member is missing", async () => {
|
||||
const payload = Buffer.from("noop");
|
||||
const zip = buildMinimalZip("other-file.txt", payload);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* (issue #775) Memory preflight for the Apple Health import. The
|
||||
* streaming parse keeps memory bounded, so the preflight is generous —
|
||||
* it exists to refuse only the truly impossible combination (a tiny
|
||||
* pinned heap against a huge declared export) with an actionable
|
||||
* reason, instead of letting the worker die mid-parse and leave the
|
||||
* job stuck in a running state.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
IMPORT_MEMORY_PREFLIGHT_BASE_BYTES,
|
||||
IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR,
|
||||
INSUFFICIENT_MEMORY_REASON_PREFIX,
|
||||
importMemoryPreflightReason,
|
||||
} from "../apple-health-import-worker";
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const GIB = 1024 * MIB;
|
||||
|
||||
describe("importMemoryPreflightReason", () => {
|
||||
it("passes a large real-world export against any default heap", () => {
|
||||
// A 3 GiB export.xml (238 MB export.zip scale) against a 1 GiB
|
||||
// default heap — the reported self-hoster setup — must NOT be
|
||||
// refused: the streaming parse carries it.
|
||||
expect(importMemoryPreflightReason(3 * GIB, 1 * GIB)).toBeNull();
|
||||
});
|
||||
|
||||
it("passes the largest extractable export against the default heap", () => {
|
||||
// 8 GiB is the archive-level extraction cap; the model needs
|
||||
// ~320 MiB for it, far below any default heap.
|
||||
expect(importMemoryPreflightReason(8 * GIB, 1 * GIB)).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses when the pinned heap cannot carry the declared export", () => {
|
||||
const reason = importMemoryPreflightReason(8 * GIB, 256 * MIB);
|
||||
expect(reason).not.toBeNull();
|
||||
expect(reason).toMatch(
|
||||
new RegExp(`^${INSUFFICIENT_MEMORY_REASON_PREFIX}: `),
|
||||
);
|
||||
// The reason must be actionable for the operator: name the knob.
|
||||
expect(reason).toContain("NODE_OPTIONS=--max-old-space-size=");
|
||||
// And honest about the numbers involved.
|
||||
expect(reason).toContain("8192 MiB uncompressed");
|
||||
expect(reason).toContain("256 MiB");
|
||||
});
|
||||
|
||||
it("scales the requirement with the declared XML size", () => {
|
||||
const declared = 4 * GIB;
|
||||
const required =
|
||||
IMPORT_MEMORY_PREFLIGHT_BASE_BYTES +
|
||||
Math.ceil(declared / IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR);
|
||||
expect(importMemoryPreflightReason(declared, required)).toBeNull();
|
||||
expect(importMemoryPreflightReason(declared, required - 1)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stays comfortably under the 1000-char failureReason column slice", () => {
|
||||
const reason = importMemoryPreflightReason(8 * GIB, 1);
|
||||
expect(reason).not.toBeNull();
|
||||
expect((reason as string).length).toBeLessThan(500);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
|
||||
*/
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { getHeapStatistics } from "node:v8";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import type { Job } from "pg-boss";
|
||||
@@ -115,6 +116,59 @@ const APPLE_HEALTH_ECG_MAX_SAMPLES = 2_000_000;
|
||||
/** Concurrency cap per worker host. */
|
||||
export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1;
|
||||
|
||||
/**
|
||||
* (issue #775) Memory preflight for the parse. The streaming pipeline
|
||||
* keeps per-record work off the heap, so what remains resident scales
|
||||
* with data SPAN (cumulative day-buckets, per-type counters, batch
|
||||
* buffers), not record count — a small fraction of the XML size. The
|
||||
* model below is deliberately generous: a fixed base for parser +
|
||||
* batches + framework, plus 1/64 of the declared XML size for the
|
||||
* span-bounded fold state. An 8 GiB export (the archive-level cap)
|
||||
* needs ~320 MiB under this model — any default Node heap clears it.
|
||||
* The preflight only refuses the truly impossible, e.g. an operator
|
||||
* who pinned --max-old-space-size far below that, and it refuses
|
||||
* BEFORE minutes of unpack work and with an actionable reason instead
|
||||
* of an OOM-killed worker and a job stuck in "running".
|
||||
*/
|
||||
export const IMPORT_MEMORY_PREFLIGHT_BASE_BYTES = 192 * 1024 * 1024;
|
||||
export const IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR = 64;
|
||||
|
||||
/**
|
||||
* Machine-readable prefix of the preflight refusal. The web UI keys
|
||||
* its translated message off this prefix (mirroring the bare
|
||||
* `interrupted_by_restart` code the reconcile writes); the rest of the
|
||||
* string stays honest English for the status API and logs.
|
||||
*/
|
||||
export const INSUFFICIENT_MEMORY_REASON_PREFIX = "insufficient_memory";
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Returns `null` when the runtime heap can carry the declared export,
|
||||
* otherwise the `failureReason` string for the refused import.
|
||||
* `heapLimitBytes` is injectable for tests; production reads the real
|
||||
* V8 limit, which reflects `NODE_OPTIONS=--max-old-space-size`.
|
||||
*/
|
||||
export function importMemoryPreflightReason(
|
||||
declaredXmlBytes: number,
|
||||
heapLimitBytes: number = getHeapStatistics().heap_size_limit,
|
||||
): string | null {
|
||||
const requiredBytes =
|
||||
IMPORT_MEMORY_PREFLIGHT_BASE_BYTES +
|
||||
Math.ceil(declaredXmlBytes / IMPORT_MEMORY_PREFLIGHT_XML_DIVISOR);
|
||||
if (heapLimitBytes >= requiredBytes) return null;
|
||||
const requiredMib = Math.ceil(requiredBytes / MIB);
|
||||
const suggestedMib = Math.max(512, requiredMib);
|
||||
return (
|
||||
`${INSUFFICIENT_MEMORY_REASON_PREFIX}: export.xml declares ` +
|
||||
`${Math.ceil(declaredXmlBytes / MIB)} MiB uncompressed; parsing it needs ` +
|
||||
`roughly ${requiredMib} MiB of heap but the Node.js heap limit is ` +
|
||||
`${Math.floor(heapLimitBytes / MIB)} MiB. Raise it with ` +
|
||||
`NODE_OPTIONS=--max-old-space-size=${suggestedMib} on the container ` +
|
||||
`that runs the worker, then upload the export again.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.28.33 (issue #486) — job-level pg-boss overrides for the import
|
||||
* sends. The queue defaults (retryLimit 2, expireInSeconds 900) are
|
||||
@@ -325,7 +379,15 @@ export async function handleAppleHealthImport(
|
||||
elapsedMs: 0,
|
||||
});
|
||||
|
||||
const unzip = await extractExportXml(uploadPath);
|
||||
const unzip = await extractExportXml(uploadPath, {
|
||||
// Refuse an export the heap demonstrably cannot carry BEFORE the
|
||||
// multi-minute unpack, with an actionable reason instead of an
|
||||
// OOM-killed worker (issue #775).
|
||||
preflight: ({ declaredXmlBytes }) => {
|
||||
const reason = importMemoryPreflightReason(declaredXmlBytes);
|
||||
if (reason) throw new Error(reason);
|
||||
},
|
||||
});
|
||||
extractedXmlPath = unzip.xmlPath;
|
||||
|
||||
// Phase 2 + 3: parsing + upserting (the parser tracks both
|
||||
|
||||
Reference in new issue
Block a user