Files
HealthLog/src/lib/export/streamed-backup.ts
T
Marc-André Bombeck 900a5c4f1f perf: stop keeping folded samples and re-reading duplicates
The nightly folds soft-deleted every raw Apple Health sample they folded
into an hourly or daily stats row and kept it for 75 days. Nothing ever
read those rows as deletions; they only held a re-upload of the sample
off, and they were most of the measurements table and its indexes.

Ingest now refuses such a re-upload directly: a raw sample of a dense or
mean type, old enough to have been folded, whose hour or day a live
stats row covers, is a duplicate with reason folded_window. The batch
route and the Apple Health export import apply the same rule. With that
in place the dense retention and the daily-mean consolidation delete the
raw rows outright, and a new compaction-tombstone-purge job removes the
backlog: 5,000 rows a batch, one account at a time under the restore
lock, a short pause between batches, resumable because the predicate is
idempotent, queued at every boot and continuing itself while it makes
progress. A person's own deletions are never in its predicate. The sync
feed leaves compaction tombstones out and a restore does not write them
back; all three classify through the same function.

The batch route answers an exact re-sent sample without a transaction:
one read over the batch's sample ids, live or deleted, decides what the
reconciler would have decided, at the same point in the loop, so every
status, reason, counter and the cross-source merge stay as they were. A
batch of duplicates only no longer queues PR detection, a reminder sweep
or the detection audit row. Caches are evicted only for a foreground or
manual sync or a batch that wrote a hand-entered reading, and marked
stale otherwise; the targets cache follows the same split.

Also:
- POST /api/admin/maintenance/measurements queues VACUUM (ANALYZE) and a
  REINDEX INDEX CONCURRENTLY per index, largest first, on a connection of
  its own; it refuses while the purge runs, drops invalid leftover copies
  and stops at the first failed rebuild. Runbook in docs/ops.
- The pure volume queues keep their finished jobs for 96 hours instead
  of seven days, never less than the failure window plus a day.
- work_mem is set per app connection from DATABASE_WORK_MEM (16MB), also
  when the statement timeout is disabled.
- The PR-detection fallback tick re-scans only accounts whose readings
  or workouts changed since the previous tick.
- Type discovery for the status refill walks the live-rows index as a
  loose index scan instead of grouping every live row.
- The host metrics chart polls every five minutes, like its sampler.
- The batch route records per-type HealthKit arrivals.
2026-10-08 08:41:25 +02:00

179 lines
6.2 KiB
TypeScript

/**
* A backup file read as a stream: every section but the measurements in
* memory, the measurements handed over in batches.
*
* Why. The measurements are the one section that grows with a record without
* bound. For an account with 1.25 million of them the disaster-recovery JSON
* is 662 MB, which cannot exist as one JavaScript string, and the parsed rows
* alone would not fit a 1 GB container beside the running app (#1031). Every
* other section of the same file is a few megabytes.
*
* So the file is read twice. The first read keeps everything except the
* measurements, and checks each measurement against the same element schema
* the whole-file schema uses, counting them. Nothing is written on the
* strength of an unchecked row: a restore reads the file this way before it
* deletes anything. The second read, from `forEachMeasurementBatch`, hands
* the measurements over in order, in batches, for the writer.
*
* `raw` is the file exactly as `JSON.parse` would give it, with
* `measurements` replaced by an empty array when the file has that section
* (and still absent when it does not, which is what the section-presence
* check reads). Run it through `parseBackupPayload` for the rest of the
* validation; together that is the whole-file check.
*/
import { BackupKeyIdCollector } from "@/lib/export/backup-key-ids";
import { scanBackupJson } from "@/lib/export/backup-json-scan";
import type {
BackupMeasurement,
backupMeasurementSchema,
} from "@/lib/validations/backup";
import { BACKUP_SCHEMA_VERSION } from "@/lib/validations/backup-summary";
type MeasurementSchema = typeof backupMeasurementSchema;
/**
* The element schema, loaded with the rest of `backup.ts` on the first read
* rather than at import: the module sits on the boot path of the restore job
* and the schema is ~12 MB of heap (`backup-summary.ts`).
*/
async function loadMeasurementSchema(): Promise<MeasurementSchema> {
return (await import("@/lib/validations/backup")).backupMeasurementSchema;
}
/** The file's bytes, from the start, every time it is called. */
export type BackupSource = () => AsyncIterable<Uint8Array>;
const MEASUREMENTS = "measurements";
const STREAMED = new Set([MEASUREMENTS]);
/** A measurement the element schema refused, with where it is in the file. */
export class StreamedBackupInvalidError extends Error {
readonly path: string;
constructor(path: string, message: string) {
super(`${path}: ${message}`);
this.name = "StreamedBackupInvalidError";
this.path = path;
}
}
export interface StreamedBackup {
/** The file without its measurements (see the module comment). */
raw: Record<string, unknown>;
/** How many measurements the file carries. */
measurementCount: number;
/** The file's size in bytes. */
bytes: number;
/**
* The application key ids the file's inner ciphertext was written under,
* gathered on the first read (`assessBackupKeys` holds them against this
* host's keys).
*/
keys: BackupKeyIdCollector;
/**
* Read the file again and hand its measurements over in file order, in
* batches of at most `size`, each row parsed by the element schema.
* Awaits `fn` before reading further.
*/
forEachMeasurementBatch(
size: number,
fn: (batch: BackupMeasurement[]) => Promise<void>,
): Promise<void>;
}
function parseMeasurement(
schema: MeasurementSchema,
element: unknown,
index: number,
): BackupMeasurement {
const parsed = schema.safeParse(element);
if (!parsed.success) {
const issue = parsed.error.issues[0];
throw new StreamedBackupInvalidError(
[MEASUREMENTS, index, ...(issue?.path ?? [])].join("."),
issue?.message ?? "invalid measurement",
);
}
return parsed.data;
}
/**
* First read of a backup file. Rejects with `BackupJsonError` for input that
* is not a JSON object and with `StreamedBackupInvalidError` for a
* measurement the schema refuses.
*/
export interface ReadStreamedBackupOptions {
/**
* Called after each measurement passes the element schema, with how many
* have so far and the parsed row. For a caller that reports progress or
* gathers a small index over the rows (the restore collects the live
* `stats:` ids it classifies compaction tombstones against); keep it cheap.
*/
onMeasurementChecked?: (checked: number, row: BackupMeasurement) => void;
}
export async function readStreamedBackup(
source: BackupSource,
options: ReadStreamedBackupOptions = {},
): Promise<StreamedBackup> {
const schema = await loadMeasurementSchema();
let firstWithoutId: number | null = null;
let bytes = 0;
const keys = new BackupKeyIdCollector();
async function* counted() {
for await (const chunk of source()) {
bytes += chunk.byteLength;
yield chunk;
}
}
const { document, streamedCounts } = await scanBackupJson(counted(), {
streamKeys: STREAMED,
onElement: (_key, element, index) => {
const row = parseMeasurement(schema, element, index);
if (!row.id && firstWithoutId === null) firstWithoutId = index;
keys.visit(element, MEASUREMENTS);
options.onMeasurementChecked?.(index + 1, row);
},
});
// The whole-file schema's cross-row rule, applied the same way: a canonical
// v2 file must give every measurement a stable id.
if (
document.schemaVersion === BACKUP_SCHEMA_VERSION &&
firstWithoutId !== null
) {
throw new StreamedBackupInvalidError(
`${MEASUREMENTS}.${firstWithoutId}.id`,
"Canonical v2 measurements require a stable id",
);
}
keys.visit(document);
const skipKeys = new Set(
Object.keys(document).filter((key) => key !== MEASUREMENTS),
);
return {
raw: document,
measurementCount: streamedCounts[MEASUREMENTS] ?? 0,
bytes,
keys,
async forEachMeasurementBatch(size, fn) {
let batch: BackupMeasurement[] = [];
await scanBackupJson(source(), {
streamKeys: STREAMED,
skipKeys,
onElement: async (_key, element, index) => {
batch.push(parseMeasurement(schema, element, index));
if (batch.length >= size) {
const full = batch;
batch = [];
await fn(full);
}
},
});
if (batch.length > 0) await fn(batch);
},
};
}