mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(rollups): v1.4.35.1 — auto-converge rollups on worker boot
Removes the operator step that was implicit in the v1.4.35 foundation. Self-hosters no longer need to run the backfill script after upgrade. New `rollup-full-backfill` queue + handler folds one user per job at serial concurrency. `enqueueBootTimeRollupBackfill` runs once at `startReminderWorker` boot, discovers accounts with measurements but zero rollup coverage, and enqueues one full-fold job per uncovered user with a singleton key so fast restarts don't pile up duplicates. Best-effort: discovery errors surface through the helper's return value and are logged via workerLog; the worker boot never fails because the backfill missed. Tests: 4280 → 4285 unit + 228 → 230 integration. tsc + lint clean.
This commit is contained in:
1 parent
ba9dd3fd2f
commit
01ae3b542a
6 files changed
+392
-2
No files matched your search
@@ -1,5 +1,45 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.35.1] — 2026-05-17 — Auto-converging rollup backfill on worker boot
|
||||
|
||||
Follow-on to v1.4.35. Removes the operator action that was implicit
|
||||
in the foundation release: instead of self-hosters having to run
|
||||
`pnpm tsx scripts/backfill-rollups.ts` after upgrading, the worker
|
||||
boot now discovers any account with measurements but no rollup
|
||||
coverage and enqueues a one-shot full fold via pg-boss. Idempotent
|
||||
across reboots (the discovery query only matches uncovered users) and
|
||||
zero reader-path impact (serial concurrency, runs in the background).
|
||||
|
||||
### Boot-time backfill
|
||||
|
||||
- **New `rollup-full-backfill` queue** (`ROLLUP_FULL_BACKFILL_QUEUE`,
|
||||
concurrency = 1) carrying `{ userId, enqueuedAt }`. The handler
|
||||
invokes `recomputeUserRollups(userId)` with the default 5-year
|
||||
window across all four granularities — equivalent to the manual
|
||||
CLI run, but inside the worker process so no `tsx` install
|
||||
gymnastics in production images.
|
||||
- **`enqueueBootTimeRollupBackfill`** runs once at
|
||||
`startReminderWorker` boot after every `boss.work` subscription is
|
||||
in place. The discovery query finds users with at least one row in
|
||||
`measurements` and zero rows in `measurement_rollups`, then sends
|
||||
one job per uncovered account with `singletonKey:
|
||||
boot-backfill|{userId}` so a fast restart while a fold is queued
|
||||
doesn't pile up duplicates.
|
||||
- **Best-effort:** any error during discovery is captured in the
|
||||
return value and logged through `workerLog`; the worker boot never
|
||||
fails because the backfill missed.
|
||||
|
||||
### Tests
|
||||
|
||||
- 5 new unit cases pin the helper's contract (silent no-op without
|
||||
boss, one job per uncovered user, singleton-key coalesce counted
|
||||
as `skipped`, error surfacing, zero-users path). 2 new integration
|
||||
cases verify against the real testcontainer: only the uncovered
|
||||
user lands on the queue when a third already-folded user co-exists;
|
||||
zero jobs enqueued when every account is already covered.
|
||||
- Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm
|
||||
typecheck` + `pnpm lint` clean.
|
||||
|
||||
## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap
|
||||
|
||||
Foundation release for the persistent `measurement_rollups` cache
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.35",
|
||||
"version": "1.4.35.1",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -53,9 +53,13 @@ import {
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_FULL_BACKFILL_CONCURRENCY,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
ROLLUP_RECOMPUTE_CONCURRENCY,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
recomputeUserRollups,
|
||||
type RollupFullBackfillPayload,
|
||||
type RollupRecomputePayload,
|
||||
} from "@/lib/measurements/rollups";
|
||||
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
|
||||
@@ -1466,6 +1470,7 @@ export async function startReminderWorker() {
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
];
|
||||
|
||||
for (const q of allQueues) {
|
||||
@@ -1666,6 +1671,54 @@ export async function startReminderWorker() {
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.35.1 — boot-time full-fold worker. The boot enqueue helper
|
||||
// below sends one job per uncovered user; this handler runs the
|
||||
// full `recomputeUserRollups` against the default 5-year window
|
||||
// across every granularity. Serial concurrency so the populator
|
||||
// never crowds the dashboard request pool.
|
||||
await boss.work<RollupFullBackfillPayload>(
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
{ localConcurrency: ROLLUP_FULL_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { rowsUpserted, durationMs } = await recomputeUserRollups(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.35.1 — fire-and-forget boot-time backfill discovery. Finds
|
||||
// every user with measurements but no rollup coverage and enqueues
|
||||
// a full-fold per account. Idempotent across reboots: the discovery
|
||||
// query only matches accounts with zero rollup rows, so once a
|
||||
// fold completes the user drops off the list. Errors are returned
|
||||
// through the helper's result value — the worker boot never fails
|
||||
// because of a backfill miss.
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeRollupBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[rollup-full-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else if (enqueued > 0 || skipped > 0) {
|
||||
workerLog(
|
||||
"info",
|
||||
`[rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[rollup-full-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,10 @@ void mocks.findMany;
|
||||
void queryRaw;
|
||||
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
collapseToTypeDayKeys,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
enqueueRollupRecompute,
|
||||
ensureUserRollupsFresh,
|
||||
recomputeBucketsForMeasurement,
|
||||
@@ -280,3 +282,70 @@ describe("ensureUserRollupsFresh", () => {
|
||||
expect(result.recomputed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("enqueueBootTimeRollupBackfill", () => {
|
||||
it("is a silent no-op when no boss is attached", async () => {
|
||||
getGlobalBossMock.mockReturnValue(null);
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(queryRaw).not.toHaveBeenCalled();
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enqueues one full-fold job per user who has measurements but no rollups", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([
|
||||
{ id: "user-a" },
|
||||
{ id: "user-b" },
|
||||
{ id: "user-c" },
|
||||
]);
|
||||
bossSend
|
||||
.mockResolvedValueOnce("job-a")
|
||||
.mockResolvedValueOnce("job-b")
|
||||
.mockResolvedValueOnce("job-c");
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 3, skipped: 0, error: null });
|
||||
expect(bossSend).toHaveBeenCalledTimes(3);
|
||||
// The queue name is the boot-fold queue, not the per-bucket queue.
|
||||
for (const call of bossSend.mock.calls) {
|
||||
expect(call[0]).toBe(ROLLUP_FULL_BACKFILL_QUEUE);
|
||||
}
|
||||
// Singleton key per user — coalesces across rapid reboots.
|
||||
expect(bossSend.mock.calls[0][2].singletonKey).toBe("boot-backfill|user-a");
|
||||
expect(bossSend.mock.calls[1][2].singletonKey).toBe("boot-backfill|user-b");
|
||||
expect(bossSend.mock.calls[2][2].singletonKey).toBe("boot-backfill|user-c");
|
||||
});
|
||||
|
||||
it("counts a `boss.send` returning null as 'skipped' (singleton coalesce)", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([{ id: "user-a" }, { id: "user-b" }]);
|
||||
bossSend.mockResolvedValueOnce(null).mockResolvedValueOnce("job-b");
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 1, skipped: 1, error: null });
|
||||
});
|
||||
|
||||
it("returns the error message when the discovery query throws", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockRejectedValueOnce(new Error("pool exhausted"));
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(result.error).toBe("pool exhausted");
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { enqueued: 0 } when no users need backfill", async () => {
|
||||
getGlobalBossMock.mockReturnValue({ send: bossSend });
|
||||
queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result).toEqual({ enqueued: 0, skipped: 0, error: null });
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,28 @@ export interface RollupRecomputePayload {
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.35.1 — boot-time full-fold queue. The write-path hooks keep
|
||||
* the DAY bucket current per measurement, and `ensureUserRollupsFresh`
|
||||
* warm-on-read covers the trailing 90-day DAY window on cold mount,
|
||||
* but neither path folds the deep WEEK / MONTH / YEAR history for an
|
||||
* account that has measurements pre-dating the v1.4.35 deploy. This
|
||||
* queue exists so the worker boot can enqueue one full
|
||||
* `recomputeUserRollups(userId)` per uncovered user — no operator
|
||||
* action needed on self-hosted instances.
|
||||
*/
|
||||
export const ROLLUP_FULL_BACKFILL_QUEUE = "rollup-full-backfill";
|
||||
|
||||
/** Boot-fold worker concurrency. Serial so we don't crowd the pool. */
|
||||
export const ROLLUP_FULL_BACKFILL_CONCURRENCY = 1;
|
||||
|
||||
/** Payload the boot-time backfill helper enqueues. */
|
||||
export interface RollupFullBackfillPayload {
|
||||
userId: string;
|
||||
/** Wall-clock kick-off for debugging only. */
|
||||
enqueuedAt: string;
|
||||
}
|
||||
|
||||
/** Postgres `date_trunc` unit per granularity. */
|
||||
const DATE_TRUNC_UNIT: Record<RollupGranularity, string> = {
|
||||
DAY: "day",
|
||||
@@ -600,6 +622,85 @@ export async function ensureUserRollupsFresh(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.35.1 — boot-time enqueue helper. Finds users with measurements
|
||||
* but zero rollup coverage and enqueues one full-fold job per account
|
||||
* onto `ROLLUP_FULL_BACKFILL_QUEUE`. Designed to be called once at
|
||||
* worker boot so every self-hosted instance auto-converges on the
|
||||
* persistent rollup tier without operator intervention.
|
||||
*
|
||||
* Idempotent across reboots:
|
||||
* - the discovery query only matches accounts with no rollup rows at
|
||||
* all, so once the fold completes the user drops off the list and
|
||||
* subsequent boots skip them
|
||||
* - pg-boss `singletonKey` coalesces duplicate sends within the
|
||||
* queue, so a fast restart while a backfill is queued doesn't
|
||||
* double up
|
||||
*
|
||||
* Best-effort: errors are swallowed and reported through the return
|
||||
* value so the worker boot never fails because of a backfill miss.
|
||||
*/
|
||||
export async function enqueueBootTimeRollupBackfill(): Promise<{
|
||||
enqueued: number;
|
||||
skipped: number;
|
||||
error: string | null;
|
||||
}> {
|
||||
const boss = getGlobalBoss();
|
||||
if (!boss) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
// Users with at least one measurement but no rollup row yet. The
|
||||
// `LIMIT 1` inside each `EXISTS` keeps the planner from materialising
|
||||
// the full child relation just to answer existence — important on
|
||||
// power-user accounts with 100k+ measurements.
|
||||
const users = await prisma.$queryRaw<Array<{ id: string }>>`
|
||||
SELECT u."id"
|
||||
FROM users u
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM measurements m WHERE m."user_id" = u."id" LIMIT 1
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM measurement_rollups r WHERE r."user_id" = u."id" LIMIT 1
|
||||
)
|
||||
`;
|
||||
|
||||
if (users.length === 0) {
|
||||
return { enqueued: 0, skipped: 0, error: null };
|
||||
}
|
||||
|
||||
let enqueued = 0;
|
||||
let skipped = 0;
|
||||
for (const { id } of users) {
|
||||
const payload: RollupFullBackfillPayload = {
|
||||
userId: id,
|
||||
enqueuedAt: new Date().toISOString(),
|
||||
};
|
||||
const jobId = await boss.send(ROLLUP_FULL_BACKFILL_QUEUE, payload, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
// Coalesce: if a backfill for this user is already queued and
|
||||
// we restart, pg-boss returns null instead of duplicating.
|
||||
singletonKey: `boot-backfill|${id}`,
|
||||
});
|
||||
if (jobId) {
|
||||
enqueued += 1;
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
return { enqueued, skipped, error: null };
|
||||
} catch (err) {
|
||||
return {
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse a list of `(type, day)` keys to the set of distinct
|
||||
* `(type, dayStart)` pairs. Used by the batch-ingest hooks so a 500-
|
||||
|
||||
@@ -27,6 +27,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
recomputeBucketsForMeasurement,
|
||||
recomputeUserRollups,
|
||||
ALL_GRANULARITIES,
|
||||
@@ -38,12 +40,25 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Stub boss-instance with a vi.fn so we can swap the return per test.
|
||||
// The integration suite runs with `isolate: false`, so the mock state
|
||||
// is shared with sibling files (e.g. admin-backups-audit.test.ts also
|
||||
// mocks boss-instance). Re-setting the implementation inside
|
||||
// beforeEach restores our default before each case.
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: () => null, // no pg-boss in the test container
|
||||
getGlobalBoss: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
|
||||
const bossSend = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
bossSend.mockReset();
|
||||
// Default: pg-boss not attached. Individual tests that exercise the
|
||||
// enqueue path override this with a stub that records sends.
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -503,4 +518,116 @@ describe("measurement rollups — integration", () => {
|
||||
// = 81.6667 → round2 = 81.67.
|
||||
expect(after.summaries.WEIGHT.mean).toBe(81.67);
|
||||
});
|
||||
|
||||
// ─── v1.4.35.1 boot-time backfill ──────────────────────────
|
||||
//
|
||||
// Self-hosted instances upgrading to v1.4.35 onwards must auto-
|
||||
// converge on the persistent rollup tier without operator action.
|
||||
// The discovery query matches users with measurements but zero
|
||||
// rollup rows; one full-fold job is enqueued per such account.
|
||||
|
||||
it("enqueues one full-fold job per user with measurements but no rollups", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
// Three users: two with measurements (one already-folded, one
|
||||
// uncovered), one with no measurements at all (skipped entirely).
|
||||
const covered = await prisma.user.create({
|
||||
data: {
|
||||
username: "boot-backfill-covered",
|
||||
email: "boot-backfill-covered@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const uncovered = await prisma.user.create({
|
||||
data: {
|
||||
username: "boot-backfill-uncovered",
|
||||
email: "boot-backfill-uncovered@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const empty = await prisma.user.create({
|
||||
data: {
|
||||
username: "boot-backfill-empty",
|
||||
email: "boot-backfill-empty@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
void empty;
|
||||
|
||||
const measuredAt = new Date("2026-04-01T08:00:00.000Z");
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: covered.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt,
|
||||
},
|
||||
{
|
||||
userId: uncovered.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Pre-fold the covered user so the discovery query skips them.
|
||||
await recomputeUserRollups(covered.id, { granularities: ["DAY"] });
|
||||
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send: bossSend } as never);
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
// Only the uncovered user lands on the queue. The covered user
|
||||
// already has DAY rollups; the empty user has no measurements.
|
||||
expect(result.enqueued).toBe(1);
|
||||
expect(result.skipped).toBe(0);
|
||||
expect(result.error).toBeNull();
|
||||
expect(bossSend).toHaveBeenCalledTimes(1);
|
||||
expect(bossSend.mock.calls[0][0]).toBe(ROLLUP_FULL_BACKFILL_QUEUE);
|
||||
expect(bossSend.mock.calls[0][1]).toMatchObject({ userId: uncovered.id });
|
||||
expect(bossSend.mock.calls[0][2].singletonKey).toBe(
|
||||
`boot-backfill|${uncovered.id}`,
|
||||
);
|
||||
|
||||
// Reset boss stub for downstream tests.
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
});
|
||||
|
||||
it("returns { enqueued: 0 } when every user is already covered", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "boot-backfill-allcovered",
|
||||
email: "boot-backfill-allcovered@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-04-01T08:00:00.000Z"),
|
||||
},
|
||||
});
|
||||
await recomputeUserRollups(user.id, { granularities: ["DAY"] });
|
||||
|
||||
bossSend.mockResolvedValue("job-id");
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send: bossSend } as never);
|
||||
|
||||
const result = await enqueueBootTimeRollupBackfill();
|
||||
|
||||
expect(result.enqueued).toBe(0);
|
||||
expect(bossSend).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null as never);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user