mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(jobs): decompose reminder-worker into domain queue registrars
Split the 2143-LOC startReminderWorker boot file into five domain registrars under src/lib/jobs/reminder/ (integration-sync, status, rollup, reminders, maintenance), each owning its queue-name constants, its allQueues membership, its schedule tuples, and its boss.work bindings. A shared createAndSchedule helper provisions every queue before scheduling it. The boot file is now a 115-LOC registration table that composes the registrars and runs the boot one-shots + discovery enqueues. Every queue declaration, schedule, boss.work binding, and boot-discovery enqueue is preserved verbatim — verified by diffing the old monolith against the union of the registrars (66/66 queues, 66/66 bindings, identical schedule tuples and boot enqueues). The diff caught one withings-oauth-state-cleanup binding that was dropped mid-refactor and restored it. Update the 18 queue-registration guard tests to follow the wiring into its registrar module so the dead-queue protection moves with the code rather than weakening: each grep guard now reads its queue's registrar, the stress-strain-retention guard unions the allQueues blocks across the status + rollup registrars, and the intake-slot-dedup schedules regex tracks the new module-level indentation.
This commit is contained in:
1 parent
a3217ff767
commit
9bd46ff25d
27 files changed
+2429
-2108
No files matched your search
@@ -13,8 +13,11 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the coach-memory-refresh wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the status registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-status.ts");
|
||||
const source = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
function allQueuesBlock(): string {
|
||||
const m = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
|
||||
@@ -13,9 +13,18 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
// v1.18.1 — the reminder-dispatch wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the reminders registrar. The dead-queue guard
|
||||
// follows the wiring there (the handler module is still concatenated for the
|
||||
// handler-symbol assertions).
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-reminders.ts",
|
||||
);
|
||||
const source =
|
||||
readFileSync(REMINDER_WORKER_PATH, "utf8") +
|
||||
readFileSync(REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(
|
||||
join(__dirname, "..", "reminder", "mood-cycle-checks.ts"),
|
||||
"utf8",
|
||||
|
||||
@@ -14,8 +14,11 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the drain-cumulative wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the rollup registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts");
|
||||
const source = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — drainPerSampleCumulative nightly schedule", () => {
|
||||
it("declares the drain queue at the documented Berlin cadence", () => {
|
||||
|
||||
@@ -17,9 +17,18 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC
|
||||
// reminder-worker boot file into domain registrars; the Fitbit queues live in
|
||||
// the integration-sync registrar. The dead-queue guard follows the wiring there
|
||||
// (the handler module is still concatenated for the import-routine assertions).
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-integration-sync.ts",
|
||||
);
|
||||
const source =
|
||||
readFileSync(REMINDER_WORKER_PATH, "utf8") +
|
||||
readFileSync(REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(join(__dirname, "..", "reminder", "fitbit-sync.ts"), "utf8");
|
||||
|
||||
const FITBIT_QUEUE_CONSTS = [
|
||||
|
||||
@@ -242,8 +242,10 @@ describe("geo-backfill scheduling contract (v1.4.37)", () => {
|
||||
// shape end-to-end.
|
||||
const fs = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
// v1.18.1 — the geo-backfill wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the maintenance registrar.
|
||||
const workerSrc = await fs.readFile(
|
||||
path.resolve(__dirname, "..", "reminder-worker.ts"),
|
||||
path.resolve(__dirname, "..", "reminder", "register-maintenance.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
const handlerSrc = await fs.readFile(
|
||||
|
||||
@@ -928,8 +928,11 @@ describe("forceWarmUser — on-demand single-user warm (v1.8.7.1)", () => {
|
||||
});
|
||||
|
||||
describe("queue registration", () => {
|
||||
// v1.18.1 — the insight-pregenerate wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the status registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder-worker.ts"),
|
||||
path.resolve(__dirname, "../reminder/register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -133,8 +133,11 @@ describe("enqueueStatusGeneration", () => {
|
||||
});
|
||||
|
||||
describe("queue registration", () => {
|
||||
// v1.18.1 — the insight-status-generate wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the status registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder-worker.ts"),
|
||||
path.resolve(__dirname, "../reminder/register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -13,8 +13,16 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the intake-slot-dedup wiring + boot discovery moved out of the
|
||||
// 2143-LOC reminder-worker boot file into the maintenance registrar. The
|
||||
// dead-queue guard follows the wiring there.
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-maintenance.ts",
|
||||
);
|
||||
const source = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — intake-slot-dedup wiring", () => {
|
||||
it("imports the queue symbols from the intake-slot-dedup module", () => {
|
||||
@@ -65,8 +73,11 @@ describe("reminder-worker — intake-slot-dedup wiring", () => {
|
||||
// v1.15.20 — the schedules tuple grew an optional send-options slot
|
||||
// (retry policy for the insight queues); match on the assignment, not
|
||||
// the exact tuple type.
|
||||
// v1.18.1 — `schedules` is now a module-level const in the registrar, so it
|
||||
// closes at column 0 (`\n];`) rather than the monolith's function-body
|
||||
// two-space indent.
|
||||
const schedulesMatch = source.match(
|
||||
/const schedules:[^=]*=\s*\[([\s\S]*?)\n \];/,
|
||||
/const schedules:[^=]*=\s*\[([\s\S]*?)\n\];/,
|
||||
);
|
||||
expect(schedulesMatch).not.toBeNull();
|
||||
expect(schedulesMatch![1]).toMatch(
|
||||
|
||||
@@ -12,8 +12,11 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the mean-consolidation wiring + boot discovery moved out of the
|
||||
// 2143-LOC reminder-worker boot file into the rollup registrar. The dead-queue
|
||||
// guard follows the wiring there.
|
||||
const REGISTRAR_PATH = join(__dirname, "..", "reminder", "register-rollup.ts");
|
||||
const source = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — mean-consolidation wiring", () => {
|
||||
it("imports the queue symbols from the mean-consolidation module", () => {
|
||||
|
||||
@@ -13,8 +13,15 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// v1.18.1 — the reminder-dispatch wiring (measurement reminder +
|
||||
// reminder-satisfy) moved out of the 2143-LOC reminder-worker boot file into
|
||||
// the reminders registrar. The dead-queue guard follows the wiring there (the
|
||||
// handler module is still concatenated for the handler-symbol assertions).
|
||||
const source =
|
||||
readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
|
||||
readFileSync(
|
||||
join(__dirname, "..", "reminder", "register-reminders.ts"),
|
||||
"utf8",
|
||||
) +
|
||||
readFileSync(
|
||||
join(__dirname, "..", "reminder", "mood-cycle-checks.ts"),
|
||||
"utf8",
|
||||
|
||||
@@ -48,8 +48,10 @@ describe("cleanupExpiredMeasurementTombstones", () => {
|
||||
});
|
||||
|
||||
describe("reminder-worker — measurement-tombstone-cleanup schedule", () => {
|
||||
// v1.18.1 — the cleanup wiring moved out of the 2143-LOC reminder-worker
|
||||
// boot file into the maintenance registrar. The dead-queue guard follows it.
|
||||
const source = readFileSync(
|
||||
join(__dirname, "..", "reminder-worker.ts"),
|
||||
join(__dirname, "..", "reminder", "register-maintenance.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -14,8 +14,15 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot
|
||||
// file into domain registrars; the Nightscout / Polar / Oura queues live in the
|
||||
// integration-sync registrar. The dead-queue guard follows the wiring there
|
||||
// (the handler module is still concatenated for the handler-symbol assertions).
|
||||
const source =
|
||||
readFileSync(join(__dirname, "..", "reminder-worker.ts"), "utf8") +
|
||||
readFileSync(
|
||||
join(__dirname, "..", "reminder", "register-integration-sync.ts"),
|
||||
"utf8",
|
||||
) +
|
||||
readFileSync(join(__dirname, "..", "reminder", "nightscout-sync.ts"), "utf8");
|
||||
|
||||
describe("reminder-worker — Nightscout sync queue", () => {
|
||||
|
||||
@@ -176,8 +176,11 @@ describe("runPeriodNarrativeWarm", () => {
|
||||
});
|
||||
|
||||
describe("queue registration", () => {
|
||||
// v1.18.1 — the period-narrative wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the status registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder-worker.ts"),
|
||||
path.resolve(__dirname, "../reminder/register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -16,8 +16,16 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const workerSource = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot
|
||||
// file into domain registrars; the recovery-score queue lives in the status
|
||||
// registrar. The dead-queue guard follows the wiring there.
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-status.ts",
|
||||
);
|
||||
const workerSource = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
const BATCH_ROUTE_PATH = join(
|
||||
__dirname,
|
||||
|
||||
@@ -12,8 +12,16 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC
|
||||
// reminder-worker boot file into domain registrars; the sleep-timeline backfill
|
||||
// lives in the integration-sync registrar. The dead-queue guard follows it.
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-integration-sync.ts",
|
||||
);
|
||||
const source = readFileSync(REGISTRAR_PATH, "utf8");
|
||||
|
||||
describe("reminder-worker — sleep-timeline backfill queue", () => {
|
||||
it("registers the queue in allQueues (v1.4.37 dead-queue guard)", () => {
|
||||
|
||||
@@ -175,8 +175,11 @@ describe("findStatusCronCandidates — gates", () => {
|
||||
});
|
||||
|
||||
describe("mood-status queue registration (dead-queue guard)", () => {
|
||||
// v1.18.1 — the nightly status-ladder wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the status registrar. The dead-queue guard
|
||||
// follows the wiring there.
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder-worker.ts"),
|
||||
path.resolve(__dirname, "../reminder/register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -14,16 +14,37 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
const source = readFileSync(REMINDER_WORKER_PATH, "utf8");
|
||||
// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot
|
||||
// file into domain registrars. Stress + Strain live in the status registrar;
|
||||
// the dense intra-day retention queue (and the nightly fold onto the
|
||||
// drain-cumulative tick) lives in the rollup registrar. Concatenate both so the
|
||||
// dead-queue guard follows the wiring into each.
|
||||
const STATUS_REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-status.ts",
|
||||
);
|
||||
const ROLLUP_REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-rollup.ts",
|
||||
);
|
||||
const source =
|
||||
readFileSync(STATUS_REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(ROLLUP_REGISTRAR_PATH, "utf8");
|
||||
|
||||
const DENSE_RETENTION_PATH = join(__dirname, "..", "dense-intraday-retention.ts");
|
||||
const denseRetentionSource = readFileSync(DENSE_RETENTION_PATH, "utf8");
|
||||
|
||||
function allQueuesBlock(): string {
|
||||
const m = source.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(m).not.toBeNull();
|
||||
return m![1];
|
||||
// Each registrar owns its own `const allQueues = [...]`; union every block so
|
||||
// a queue registered in either the status or rollup registrar satisfies the
|
||||
// membership assertion.
|
||||
const blocks = [...source.matchAll(/const allQueues\s*=\s*\[([\s\S]*?)\];/g)];
|
||||
expect(blocks.length).toBeGreaterThan(0);
|
||||
return blocks.map((m) => m[1]).join("\n");
|
||||
}
|
||||
|
||||
describe("reminder-worker — stress-score wiring", () => {
|
||||
|
||||
@@ -12,9 +12,18 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
// v1.18.1 — the TLS-pin-monitor wiring moved out of the 2143-LOC
|
||||
// reminder-worker boot file into the maintenance registrar. The dead-queue
|
||||
// guard follows the wiring there (the ops-handlers module is still concatenated
|
||||
// for the handler-body `runTlsPinMonitor` assertion).
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-maintenance.ts",
|
||||
);
|
||||
const workerSource =
|
||||
readFileSync(REMINDER_WORKER_PATH, "utf8") +
|
||||
readFileSync(REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(join(__dirname, "..", "reminder", "ops-handlers.ts"), "utf8");
|
||||
|
||||
describe("reminder-worker — tls-pin-monitor wiring", () => {
|
||||
|
||||
@@ -13,9 +13,18 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
// v1.18.1 — the queue wiring + boot discovery moved out of the 2143-LOC
|
||||
// reminder-worker boot file into domain registrars; the WHOOP queues live in
|
||||
// the integration-sync registrar. The dead-queue guard follows the wiring there
|
||||
// (the handler module is still concatenated for the import-routine assertions).
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-integration-sync.ts",
|
||||
);
|
||||
const source =
|
||||
readFileSync(REMINDER_WORKER_PATH, "utf8") +
|
||||
readFileSync(REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(join(__dirname, "..", "reminder", "whoop-sync.ts"), "utf8");
|
||||
|
||||
const WHOOP_QUEUE_CONSTS = [
|
||||
|
||||
@@ -14,10 +14,19 @@ import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const REMINDER_WORKER_PATH = join(__dirname, "..", "reminder-worker.ts");
|
||||
// v1.18.1 — the queue wiring moved out of the 2143-LOC reminder-worker boot
|
||||
// file into domain registrars; the Withings queues live in the integration-sync
|
||||
// registrar. The dead-queue guard follows the wiring there (the handler module
|
||||
// is still concatenated for the import-routine assertions below).
|
||||
const REGISTRAR_PATH = join(
|
||||
__dirname,
|
||||
"..",
|
||||
"reminder",
|
||||
"register-integration-sync.ts",
|
||||
);
|
||||
|
||||
const source =
|
||||
readFileSync(REMINDER_WORKER_PATH, "utf8") +
|
||||
readFileSync(REGISTRAR_PATH, "utf8") +
|
||||
readFileSync(join(__dirname, "..", "reminder", "withings-sync.ts"), "utf8");
|
||||
|
||||
describe("reminder-worker — Withings activity + sleep v2 queues", () => {
|
||||
|
||||
+44
-2072
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,509 @@
|
||||
/**
|
||||
* Integration-sync queue registrar.
|
||||
*
|
||||
* Owns the third-party-provider sync queues — Withings (fallback / activity /
|
||||
* sleep), WHOOP (recovery / sleep / workout / cycle + backfill + OAuth-state
|
||||
* cleanup), Fitbit (sync + backfill + OAuth-state cleanup), Nightscout, Polar,
|
||||
* Oura, MoodLog, and the two one-shot backfills (sleep-timeline, lab-biomarker)
|
||||
* — plus their boot-time discovery enqueues.
|
||||
*
|
||||
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
|
||||
* cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`,
|
||||
* and a `boss.work(QUEUE, …, handler)` binding drains it. The queue-wiring
|
||||
* guards (`withings-queues`, `whoop-queues`, `fitbit-queues`,
|
||||
* `nightscout-queues`, `sleep-timeline-queues`) read THIS module.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import {
|
||||
WHOOP_BACKFILL_QUEUE,
|
||||
WHOOP_BACKFILL_CONCURRENCY,
|
||||
runWhoopBackfillForUser,
|
||||
enqueueBootTimeWhoopBackfill,
|
||||
type WhoopBackfillPayload,
|
||||
} from "@/lib/jobs/whoop-backfill";
|
||||
import {
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
FITBIT_BACKFILL_CONCURRENCY,
|
||||
runFitbitBackfillForUser,
|
||||
enqueueBootTimeFitbitBackfill,
|
||||
type FitbitBackfillPayload,
|
||||
} from "@/lib/jobs/fitbit-backfill";
|
||||
import {
|
||||
SLEEP_TIMELINE_BACKFILL_QUEUE,
|
||||
SLEEP_TIMELINE_BACKFILL_CONCURRENCY,
|
||||
runSleepTimelineBackfillForUser,
|
||||
enqueueBootTimeSleepTimelineBackfill,
|
||||
type SleepTimelineBackfillPayload,
|
||||
} from "@/lib/jobs/sleep-timeline-backfill";
|
||||
import {
|
||||
LAB_BIOMARKER_BACKFILL_QUEUE,
|
||||
LAB_BIOMARKER_BACKFILL_CONCURRENCY,
|
||||
runLabBiomarkerBackfillForUser,
|
||||
enqueueBootTimeLabBiomarkerBackfill,
|
||||
type LabBiomarkerBackfillPayload,
|
||||
} from "@/lib/jobs/lab-biomarker-backfill";
|
||||
import { workerLog } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
import {
|
||||
WithingsSyncPayload,
|
||||
WithingsActivitySyncPayload,
|
||||
WithingsSleepSyncPayload,
|
||||
handleWithingsFallbackSync,
|
||||
handleWithingsActivitySync,
|
||||
handleWithingsSleepSync,
|
||||
} from "./withings-sync";
|
||||
import {
|
||||
WhoopSyncPayload,
|
||||
handleWhoopRecoverySync,
|
||||
handleWhoopSleepSync,
|
||||
handleWhoopWorkoutSync,
|
||||
handleWhoopCycleSync,
|
||||
} from "./whoop-sync";
|
||||
import { MoodLogSyncPayload, handleMoodLogSync } from "./moodlog-sync";
|
||||
import {
|
||||
WithingsOAuthStateCleanupPayload,
|
||||
handleWithingsOAuthStateCleanup,
|
||||
WhoopOAuthStateCleanupPayload,
|
||||
handleWhoopOAuthStateCleanup,
|
||||
} from "./cleanup-handlers";
|
||||
import { NightscoutSyncPayload, handleNightscoutSync } from "./nightscout-sync";
|
||||
import { PolarSyncPayload, handlePolarSync } from "./polar-sync";
|
||||
import { OuraSyncPayload, handleOuraSync } from "./oura-sync";
|
||||
import {
|
||||
FitbitSyncPayload,
|
||||
handleFitbitSync,
|
||||
FitbitOAuthStateCleanupPayload,
|
||||
handleFitbitOAuthStateCleanup,
|
||||
} from "./fitbit-sync";
|
||||
|
||||
const WITHINGS_SYNC_QUEUE = "withings-fallback-sync";
|
||||
|
||||
const WITHINGS_SYNC_CRON = "0 * * * *"; // every 60 minutes
|
||||
// v1.4.25 W17b/c — webhook-primary + cron-safety-net for activity and
|
||||
// sleep v2. The webhook handler enqueues per-user jobs on appli=16 / 44
|
||||
// notifications; the crons below are the catch-net for the 1 % of
|
||||
// notifications Withings drops. Offset 15-minute cadence per the
|
||||
// research recommendation so the two queues don't lockstep against the
|
||||
// existing measure cron at :00.
|
||||
|
||||
const WITHINGS_ACTIVITY_QUEUE = "withings-activity-sync";
|
||||
|
||||
const WITHINGS_ACTIVITY_CRON = "0 * * * *"; // every hour at :00
|
||||
|
||||
const WITHINGS_SLEEP_QUEUE = "withings-sleep-sync";
|
||||
|
||||
const WITHINGS_SLEEP_CRON = "15 * * * *"; // every hour at :15
|
||||
|
||||
const MOODLOG_SYNC_QUEUE = "moodlog-sync";
|
||||
|
||||
const MOODLOG_SYNC_CRON = "30 * * * *"; // every hour at :30
|
||||
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots
|
||||
// at :20 between the audit-log cleanup (:15) and the mood-reminder
|
||||
// cleanup (:25), inside the existing 02:xx-03:xx maintenance window.
|
||||
// Rows are normally consumed by the callback handler in single-use
|
||||
// fashion; this sweep picks up the long tail where a user closed the
|
||||
// Withings approval tab without bouncing back to the callback URL.
|
||||
|
||||
const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup";
|
||||
|
||||
const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *";
|
||||
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net, mirroring
|
||||
// the Withings activity/sleep crons. Recovery / sleep / workout each have a
|
||||
// WHOOP webhook (`*.updated`) that enqueues the matching per-resource job; the
|
||||
// crons below are the catch-net for dropped deliveries. Cycle has NO webhook,
|
||||
// so its cron is the only driver. Minutes are staggered off the Withings crons
|
||||
// (:00/:15) to spread DB load.
|
||||
|
||||
const WHOOP_RECOVERY_SYNC_QUEUE = "whoop-recovery-sync";
|
||||
|
||||
const WHOOP_RECOVERY_SYNC_CRON = "5 * * * *"; // every hour at :05
|
||||
|
||||
const WHOOP_SLEEP_SYNC_QUEUE = "whoop-sleep-sync";
|
||||
|
||||
const WHOOP_SLEEP_SYNC_CRON = "20 * * * *"; // every hour at :20
|
||||
|
||||
const WHOOP_WORKOUT_SYNC_QUEUE = "whoop-workout-sync";
|
||||
|
||||
const WHOOP_WORKOUT_SYNC_CRON = "35 * * * *"; // every hour at :35
|
||||
|
||||
const WHOOP_CYCLE_SYNC_QUEUE = "whoop-cycle-sync";
|
||||
|
||||
const WHOOP_CYCLE_SYNC_CRON = "50 * * * *"; // every hour at :50 (poll-only)
|
||||
// v1.11.0 — daily sweep for the WHOOP OAuth state ledger. Slots at 03:22,
|
||||
// next to the Withings sweep (03:20), inside the maintenance window.
|
||||
|
||||
const WHOOP_OAUTH_STATE_CLEANUP_QUEUE = "whoop-oauth-state-cleanup";
|
||||
|
||||
const WHOOP_OAUTH_STATE_CLEANUP_CRON = "22 3 * * *";
|
||||
// v1.12.0 — Fitbit / Google Health poll-only sync. There is no Fitbit webhook
|
||||
// at launch (Pub/Sub deferred), so a single hourly cron drives the per-user
|
||||
// `syncUserFitbit` driver across every connection. Minute staggered off the
|
||||
// WHOOP slots (:05/:20/:35/:50) and the Withings slots (:00/:15) so the hourly
|
||||
// ticks don't pile up on one boss poll.
|
||||
|
||||
const FITBIT_SYNC_QUEUE = "fitbit-sync";
|
||||
|
||||
const FITBIT_SYNC_CRON = "8 * * * *"; // every hour at :08
|
||||
// v1.12.0 — daily sweep for the Fitbit OAuth state ledger. Slots at 03:24, next
|
||||
// to the WHOOP sweep (03:22), inside the maintenance window.
|
||||
|
||||
const FITBIT_OAUTH_STATE_CLEANUP_QUEUE = "fitbit-oauth-state-cleanup";
|
||||
|
||||
const FITBIT_OAUTH_STATE_CLEANUP_CRON = "24 3 * * *";
|
||||
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook): one hourly tick
|
||||
// pulls the recent SGV window per configured instance. :11 staggers off the
|
||||
// WHOOP (:05), Fitbit (:08), and Withings sync ticks so the hourly polls don't
|
||||
// pile up on one boss poll.
|
||||
const NIGHTSCOUT_SYNC_QUEUE = "nightscout-sync";
|
||||
|
||||
const NIGHTSCOUT_SYNC_CRON = "11 * * * *"; // every hour at :11
|
||||
// v1.17.0 (F4) — Polar + Oura OAuth poll sync. Poll-only (one hourly tick per
|
||||
// provider re-walks every connected user). :13 / :15 stagger off the other
|
||||
// hourly sync ticks (WHOOP :05, Fitbit :08, Nightscout :11) so the polls don't
|
||||
// pile up on one boss poll. The queues MUST be registered in `allQueues` below
|
||||
// or pg-boss never provisions them and the schedule silently no-ops (the
|
||||
// v1.4.37 dead-queue class).
|
||||
const POLAR_SYNC_QUEUE = "polar-sync";
|
||||
const POLAR_SYNC_CRON = "13 * * * *"; // every hour at :13
|
||||
const OURA_SYNC_QUEUE = "oura-sync";
|
||||
const OURA_SYNC_CRON = "15 * * * *"; // every hour at :15
|
||||
|
||||
// pg-boss v12 requires explicit queue creation before scheduling. Every queue
|
||||
// MUST be registered here or pg-boss never provisions it and both the webhook
|
||||
// enqueue AND the cron schedule below silently no-op (the v1.4.37 dead-queue
|
||||
// class).
|
||||
const allQueues = [
|
||||
WITHINGS_SYNC_QUEUE,
|
||||
WITHINGS_ACTIVITY_QUEUE,
|
||||
WITHINGS_SLEEP_QUEUE,
|
||||
MOODLOG_SYNC_QUEUE,
|
||||
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
// v1.11.0 — WHOOP sync queues. Webhook-primary + cron-safety-net for
|
||||
// recovery / sleep / workout; cycle is poll-only (no WHOOP webhook).
|
||||
WHOOP_RECOVERY_SYNC_QUEUE,
|
||||
WHOOP_SLEEP_SYNC_QUEUE,
|
||||
WHOOP_WORKOUT_SYNC_QUEUE,
|
||||
WHOOP_CYCLE_SYNC_QUEUE,
|
||||
// v1.11.0 — self-converging boot backfill for newly connected WHOOP
|
||||
// accounts. Discovery enqueues one full-history sync per un-backfilled
|
||||
// connection; idempotent across reboots.
|
||||
WHOOP_BACKFILL_QUEUE,
|
||||
// v1.11.0 — daily sweep for the WHOOP OAuth state ledger.
|
||||
WHOOP_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
// v1.12.0 — Fitbit / Google Health poll-only sync (no webhook at launch),
|
||||
// self-converging boot backfill, and the daily OAuth-state ledger sweep.
|
||||
FITBIT_SYNC_QUEUE,
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
// v1.17.1 — one-shot sleep-timeline backfill for WHOOP + Withings.
|
||||
// Discovery enqueues one job per connection whose sleep rows predate the
|
||||
// stamp/shape fix; the pass deletes the affected SLEEP_DURATION rows and
|
||||
// re-syncs. Idempotent across reboots.
|
||||
SLEEP_TIMELINE_BACKFILL_QUEUE,
|
||||
// v1.18.1 — one-shot backfill that links legacy free-text lab readings to
|
||||
// a user-scoped Biomarker catalog entry (group by `lower(analyte)`).
|
||||
LAB_BIOMARKER_BACKFILL_QUEUE,
|
||||
// v1.17.0 — Nightscout CGM poll sync. Poll-only (no webhook, no OAuth, no
|
||||
// backfill queue — the hourly window walks the recent SGV set).
|
||||
NIGHTSCOUT_SYNC_QUEUE,
|
||||
// v1.17.0 (F4) — Polar + Oura OAuth poll sync.
|
||||
POLAR_SYNC_QUEUE,
|
||||
OURA_SYNC_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[WITHINGS_SYNC_QUEUE, WITHINGS_SYNC_CRON],
|
||||
[WITHINGS_ACTIVITY_QUEUE, WITHINGS_ACTIVITY_CRON],
|
||||
[WITHINGS_SLEEP_QUEUE, WITHINGS_SLEEP_CRON],
|
||||
[MOODLOG_SYNC_QUEUE, MOODLOG_SYNC_CRON],
|
||||
[WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON],
|
||||
// v1.11.0 — WHOOP poll-fallback crons. Recovery/sleep/workout catch
|
||||
// dropped webhooks; cycle is the sole driver (no webhook). Staggered off
|
||||
// the Withings crons so the hourly ticks don't pile up on one boss poll.
|
||||
[WHOOP_RECOVERY_SYNC_QUEUE, WHOOP_RECOVERY_SYNC_CRON],
|
||||
[WHOOP_SLEEP_SYNC_QUEUE, WHOOP_SLEEP_SYNC_CRON],
|
||||
[WHOOP_WORKOUT_SYNC_QUEUE, WHOOP_WORKOUT_SYNC_CRON],
|
||||
[WHOOP_CYCLE_SYNC_QUEUE, WHOOP_CYCLE_SYNC_CRON],
|
||||
// v1.11.0 — daily 03:22 Europe/Berlin prune for expired WHOOP OAuth states.
|
||||
[WHOOP_OAUTH_STATE_CLEANUP_QUEUE, WHOOP_OAUTH_STATE_CLEANUP_CRON],
|
||||
// v1.12.0 — hourly Fitbit poll (:08, staggered off WHOOP/Withings) + the
|
||||
// daily 03:24 Europe/Berlin prune for expired Fitbit OAuth states.
|
||||
[FITBIT_SYNC_QUEUE, FITBIT_SYNC_CRON],
|
||||
[FITBIT_OAUTH_STATE_CLEANUP_QUEUE, FITBIT_OAUTH_STATE_CLEANUP_CRON],
|
||||
// v1.17.0 — hourly Nightscout CGM poll (:11, staggered off the other sync
|
||||
// ticks).
|
||||
[NIGHTSCOUT_SYNC_QUEUE, NIGHTSCOUT_SYNC_CRON],
|
||||
// v1.17.0 (F4) — hourly Polar (:13) + Oura (:15) OAuth polls.
|
||||
[POLAR_SYNC_QUEUE, POLAR_SYNC_CRON],
|
||||
[OURA_SYNC_QUEUE, OURA_SYNC_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register every integration-sync queue: create, schedule, and bind handlers.
|
||||
* Returns the queue names created (for the boot-level aggregate assertion).
|
||||
*/
|
||||
export async function registerIntegrationSyncQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules);
|
||||
|
||||
await boss.work<WithingsSyncPayload>(
|
||||
WITHINGS_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsFallbackSync,
|
||||
);
|
||||
await boss.work<WithingsActivitySyncPayload>(
|
||||
WITHINGS_ACTIVITY_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsActivitySync,
|
||||
);
|
||||
await boss.work<WithingsSleepSyncPayload>(
|
||||
WITHINGS_SLEEP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsSleepSync,
|
||||
);
|
||||
await boss.work<WithingsOAuthStateCleanupPayload>(
|
||||
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsOAuthStateCleanup,
|
||||
);
|
||||
// v1.11.0 — WHOOP per-resource sync handlers. Webhook-driven per-user +
|
||||
// cron full-iteration. Serial concurrency so a backfill-heavy tick never
|
||||
// crowds the request pool and stays inside WHOOP's 100 req/min app cap.
|
||||
await boss.work<WhoopSyncPayload>(
|
||||
WHOOP_RECOVERY_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopRecoverySync,
|
||||
);
|
||||
await boss.work<WhoopSyncPayload>(
|
||||
WHOOP_SLEEP_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopSleepSync,
|
||||
);
|
||||
await boss.work<WhoopSyncPayload>(
|
||||
WHOOP_WORKOUT_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopWorkoutSync,
|
||||
);
|
||||
await boss.work<WhoopSyncPayload>(
|
||||
WHOOP_CYCLE_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopCycleSync,
|
||||
);
|
||||
// v1.11.0 — self-converging WHOOP backfill. The boot enqueue below sends one
|
||||
// full-history sync per un-backfilled connection; this handler runs it and
|
||||
// stamps `backfillCompletedAt` so the discovery query drops the account.
|
||||
await boss.work<WhoopBackfillPayload>(
|
||||
WHOOP_BACKFILL_QUEUE,
|
||||
{ localConcurrency: WHOOP_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { imported } = await runWhoopBackfillForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[whoop-backfill] user=${userId} imported=${imported}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
await boss.work<WhoopOAuthStateCleanupPayload>(
|
||||
WHOOP_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWhoopOAuthStateCleanup,
|
||||
);
|
||||
// v1.12.0 — Fitbit poll-sync (cron full-iteration; no webhook). Serial
|
||||
// concurrency so a backfill-heavy tick never crowds the request pool.
|
||||
await boss.work<FitbitSyncPayload>(
|
||||
FITBIT_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleFitbitSync,
|
||||
);
|
||||
// v1.12.0 — self-converging Fitbit backfill. The boot enqueue below sends one
|
||||
// full-history sync per un-backfilled connection; this handler runs it and
|
||||
// stamps `backfillCompletedAt` so the discovery query drops the account.
|
||||
await boss.work<FitbitBackfillPayload>(
|
||||
FITBIT_BACKFILL_QUEUE,
|
||||
{ localConcurrency: FITBIT_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { imported } = await runFitbitBackfillForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[fitbit-backfill] user=${userId} imported=${imported}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
await boss.work<FitbitOAuthStateCleanupPayload>(
|
||||
FITBIT_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleFitbitOAuthStateCleanup,
|
||||
);
|
||||
// v1.17.1 — one-shot sleep-timeline backfill. The boot enqueue below sends
|
||||
// one job per (user, provider) whose sleep rows predate the stamp/shape fix;
|
||||
// this handler deletes the affected rows, re-syncs, and stamps the marker so
|
||||
// the discovery query drops the connection.
|
||||
await boss.work<SleepTimelineBackfillPayload>(
|
||||
SLEEP_TIMELINE_BACKFILL_QUEUE,
|
||||
{ localConcurrency: SLEEP_TIMELINE_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId, provider } = job.data;
|
||||
const { deleted, imported } = await runSleepTimelineBackfillForUser(
|
||||
userId,
|
||||
provider,
|
||||
);
|
||||
workerLog(
|
||||
"info",
|
||||
`[sleep-timeline-backfill] user=${userId} provider=${provider} deleted=${deleted} imported=${imported}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
// v1.18.1 — one-shot lab-biomarker backfill. The boot enqueue below sends
|
||||
// one job per user holding un-linked free-text lab readings; this handler
|
||||
// groups them by `lower(analyte)`, mints/reuses a Biomarker per group, and
|
||||
// links the rows. Idempotent — a re-run links only what is still un-linked.
|
||||
await boss.work<LabBiomarkerBackfillPayload>(
|
||||
LAB_BIOMARKER_BACKFILL_QUEUE,
|
||||
{ localConcurrency: LAB_BIOMARKER_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { markers, linked } = await runLabBiomarkerBackfillForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[lab-biomarker-backfill] user=${userId} markers=${markers} linked=${linked}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
// v1.17.0 — Nightscout CGM poll-cohort sync. The hourly cron tick (no
|
||||
// `userId`) walks every configured instance; one user's unreachable host is
|
||||
// warned, not fatal.
|
||||
await boss.work<NightscoutSyncPayload>(
|
||||
NIGHTSCOUT_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleNightscoutSync,
|
||||
);
|
||||
// v1.17.0 (F4) — Polar + Oura OAuth poll-cohort sync. The hourly cron tick
|
||||
// (no `userId`) re-walks every connected user; one user's revoked grant is
|
||||
// warned, not fatal.
|
||||
await boss.work<PolarSyncPayload>(
|
||||
POLAR_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handlePolarSync,
|
||||
);
|
||||
await boss.work<OuraSyncPayload>(
|
||||
OURA_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleOuraSync,
|
||||
);
|
||||
await boss.work<MoodLogSyncPayload>(
|
||||
MOODLOG_SYNC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodLogSync,
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget boot discovery for the self-converging integration backfills.
|
||||
* Each pass is idempotent across reboots and never fails worker boot on a miss
|
||||
* (errors come back through the helper's result value).
|
||||
*/
|
||||
export async function enqueueIntegrationSyncBootDiscovery(): Promise<void> {
|
||||
// v1.11.0 — WHOOP backfill. Finds every WHOOP connection not yet backfilled
|
||||
// and enqueues one full-history sync per account. A completed backfill stamps
|
||||
// `backfillCompletedAt`, dropping the connection from the discovery set.
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeWhoopBackfill();
|
||||
if (error) {
|
||||
workerLog("error", `[whoop-backfill] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[whoop-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[whoop-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.12.0 — Fitbit backfill. Finds every Fitbit connection not yet
|
||||
// backfilled and enqueues one full-history sync per account.
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeFitbitBackfill();
|
||||
if (error) {
|
||||
workerLog("error", `[fitbit-backfill] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[fitbit-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[fitbit-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.17.1 — one-shot sleep-timeline backfill. Finds every WHOOP + Withings
|
||||
// connection whose sleep rows predate the stamp/shape fix and enqueues one
|
||||
// job per (user, provider). A completed pass stamps `sleepTimelineBackfillAt`.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeSleepTimelineBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[sleep-timeline-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[sleep-timeline-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[sleep-timeline-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.18.1 — one-shot lab-biomarker backfill. Finds every user holding an
|
||||
// un-linked live lab reading and enqueues one job each. A completed pass
|
||||
// links every reading, dropping the user from the discovery set.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeLabBiomarkerBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[lab-biomarker-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[lab-biomarker-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[lab-biomarker-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Maintenance / ops / cleanup queue registrar.
|
||||
*
|
||||
* Owns the housekeeping tier: data backup (in-DB weekly + off-host nightly),
|
||||
* the restore drill, every ledger cleanup (rate-limit, idempotency, audit-log,
|
||||
* mood-reminder, push-attempt, measurement-tombstone), medication inventory
|
||||
* expiry, intake auto-skip, the duplicate dose-slot dedup (boot + daily-cron),
|
||||
* host-metric sampling, the rec-feedback aggregator, the geo backfill, the TLS
|
||||
* pin monitor, PR detection, and the Apple Health export ingest.
|
||||
*
|
||||
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
|
||||
* cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`,
|
||||
* and a `boss.work(QUEUE, …, handler)` binding drains it. The
|
||||
* `measurement-tombstone-cleanup`, `withings-oauth-state-cleanup`,
|
||||
* `intake-slot-dedup-queue`, `tls-pin-monitor-queue`, and `geo-backfill`
|
||||
* guards read THIS module.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON } from "@/lib/jobs/geo-backfill";
|
||||
import {
|
||||
TLS_PIN_MONITOR_QUEUE,
|
||||
TLS_PIN_MONITOR_CRON,
|
||||
} from "@/lib/jobs/tls-pin-monitor";
|
||||
import {
|
||||
PR_DETECTION_QUEUE,
|
||||
PR_DETECTION_CONCURRENCY,
|
||||
PR_DETECTION_FALLBACK_CRON,
|
||||
type PrDetectionPayload,
|
||||
} from "@/lib/jobs/pr-detection";
|
||||
import {
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
MEDICATION_INVENTORY_EXPIRE_CRON,
|
||||
type MedicationInventoryExpirePayload,
|
||||
} from "@/lib/jobs/medication-inventory-expire";
|
||||
import {
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
INTAKE_AUTO_SKIP_CRON,
|
||||
type IntakeAutoSkipPayload,
|
||||
} from "@/lib/jobs/intake-auto-skip";
|
||||
import {
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_CONCURRENCY,
|
||||
handleAppleHealthImport,
|
||||
type AppleHealthImportPayload,
|
||||
} from "@/lib/jobs/apple-health-import-worker";
|
||||
import {
|
||||
INTAKE_SLOT_DEDUP_QUEUE,
|
||||
INTAKE_SLOT_DEDUP_CONCURRENCY,
|
||||
dedupeUserIntakeSlots,
|
||||
enqueueBootTimeIntakeSlotDedup,
|
||||
type IntakeSlotDedupPayload,
|
||||
} from "@/lib/medications/intake-slot-dedup";
|
||||
import {
|
||||
handleRestoreDrill,
|
||||
RESTORE_DRILL_CRON,
|
||||
RESTORE_DRILL_QUEUE,
|
||||
} from "@/lib/jobs/restore-drill";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { workerLog } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
import {
|
||||
DataBackupPayload,
|
||||
OffhostBackupPayload,
|
||||
handleOffhostBackup,
|
||||
handleDataBackup,
|
||||
} from "./backup-handlers";
|
||||
import {
|
||||
RateLimitCleanupPayload,
|
||||
IdempotencyCleanupPayload,
|
||||
AuditLogCleanupPayload,
|
||||
MoodReminderCleanupPayload,
|
||||
handleMoodReminderCleanup,
|
||||
PushAttemptCleanupPayload,
|
||||
handlePushAttemptCleanup,
|
||||
MeasurementTombstoneCleanupPayload,
|
||||
handleMeasurementTombstoneCleanup,
|
||||
handleRateLimitCleanup,
|
||||
handleIdempotencyCleanup,
|
||||
handleAuditLogCleanup,
|
||||
} from "./cleanup-handlers";
|
||||
import {
|
||||
HostMetricSamplePayload,
|
||||
FeedbackAggregatorPayload,
|
||||
GeoBackfillPayload,
|
||||
TlsPinMonitorPayload,
|
||||
handleHostMetricSample,
|
||||
handleFeedbackAggregator,
|
||||
handleGeoBackfill,
|
||||
handleTlsPinMonitor,
|
||||
handlePrDetection,
|
||||
} from "./ops-handlers";
|
||||
import {
|
||||
handleMedicationInventoryExpire,
|
||||
handleIntakeAutoSkip,
|
||||
} from "./medication-maintenance";
|
||||
|
||||
const DATA_BACKUP_QUEUE = "data-backup";
|
||||
|
||||
const DATA_BACKUP_CRON = "0 3 * * 0"; // weekly Sunday at 03:00
|
||||
|
||||
const RATE_LIMIT_CLEANUP_QUEUE = "rate-limit-cleanup";
|
||||
|
||||
const RATE_LIMIT_CLEANUP_CRON = "*/5 * * * *"; // every 5 minutes
|
||||
|
||||
const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup";
|
||||
|
||||
const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin)
|
||||
|
||||
const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup";
|
||||
|
||||
const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin)
|
||||
// v1.15.19 — daily duplicate dose-slot dedup discovery tick. The boot-time
|
||||
// pass only ran on worker restart, so a cross-source duplicate slot created
|
||||
// between deploys (a pending REMINDER row plus a standalone API/WEB row on
|
||||
// the same instant) survived until the next reboot. The cron payload omits
|
||||
// `userId`; the handler treats that as the discovery tick and fans out one
|
||||
// per-user job (singletonKey-coalesced) exactly like the boot pass. Slots at
|
||||
// 03:28 between the mood-reminder cleanup (03:25) and the inventory expire
|
||||
// (03:30), inside the maintenance window.
|
||||
|
||||
const INTAKE_SLOT_DEDUP_CRON = "28 3 * * *";
|
||||
|
||||
const OFFHOST_BACKUP_QUEUE = "data-backup-offhost";
|
||||
// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old
|
||||
// rows are gone before they're snapshotted, but before the existing
|
||||
// in-DB DATA_BACKUP at 03:00 (Sundays only) so the off-host copy is
|
||||
// always at-or-ahead of the local one.
|
||||
|
||||
const OFFHOST_BACKUP_CRON = "30 2 * * *";
|
||||
|
||||
const HOST_METRIC_QUEUE = "host-metric-sample";
|
||||
// Per-minute cadence — matches the chart's 60s polling refetchInterval.
|
||||
|
||||
const HOST_METRIC_CRON = "* * * * *";
|
||||
// v1.4.16 phase B5e — daily rec-feedback aggregator. 04:00 Europe/Berlin
|
||||
// runs the slot AFTER all the cleanup jobs (rate-limit, idempotency,
|
||||
// audit-log) so the previous-day's noise is gone before we aggregate.
|
||||
|
||||
const FEEDBACK_AGGREGATOR_QUEUE = "feedback-aggregator";
|
||||
|
||||
const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *";
|
||||
// v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep.
|
||||
// Rows older than 90 days are behavioural footprints of mood-log gaps; we
|
||||
// keep them long enough to debug a duplicate-push report (~one billing
|
||||
// cycle) but no longer. Slots between the audit-log cleanup (03:15) and the
|
||||
// drain (03:45).
|
||||
|
||||
const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup";
|
||||
|
||||
const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *";
|
||||
|
||||
const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup";
|
||||
|
||||
const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *";
|
||||
|
||||
const MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE = "measurement-tombstone-cleanup";
|
||||
|
||||
const MEASUREMENT_TOMBSTONE_CLEANUP_CRON = "40 3 * * *";
|
||||
|
||||
const allQueues = [
|
||||
DATA_BACKUP_QUEUE,
|
||||
RATE_LIMIT_CLEANUP_QUEUE,
|
||||
IDEMPOTENCY_CLEANUP_QUEUE,
|
||||
AUDIT_LOG_CLEANUP_QUEUE,
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
RESTORE_DRILL_QUEUE,
|
||||
HOST_METRIC_QUEUE,
|
||||
FEEDBACK_AGGREGATOR_QUEUE,
|
||||
GEO_BACKFILL_QUEUE,
|
||||
// v1.12.2 ios-coord — TLS leaf SPKI-change monitor. The iOS client
|
||||
// pins the served leaf certificate; this queue probes it every 6 h and
|
||||
// alarms when the served SPKI leaves the operator's known-good set.
|
||||
TLS_PIN_MONITOR_QUEUE,
|
||||
PR_DETECTION_QUEUE,
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
// v1.4.46 — hourly auto-skip pass for stale unmarked intakes. Without
|
||||
// this entry the schedule silently no-ops and pending rows older than
|
||||
// 24 h pile up unflipped.
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
// v1.8.2 — one-time duplicate dose-slot cleanup. Boot discovery enqueues
|
||||
// one job per user holding two live intake rows that snap to the same
|
||||
// canonical slot (the pre-fix REMINDER-pending + API-taken pair). Also
|
||||
// carries a daily discovery cron (03:28). Without this entry pg-boss never
|
||||
// provisions it and both the boot enqueue and the cron silently no-op.
|
||||
INTAKE_SLOT_DEDUP_QUEUE,
|
||||
// v0.5.4 ios-coord — mood-reminder dispatch-ledger retention sweep.
|
||||
MOOD_REMINDER_CLEANUP_QUEUE,
|
||||
// v1.4.49 — push-attempt ledger cleanup. The daily schedule below would
|
||||
// silently no-op without this entry.
|
||||
PUSH_ATTEMPT_CLEANUP_QUEUE,
|
||||
// v1.7.0 — soft-deleted measurement tombstone prune. Without this entry
|
||||
// the daily schedule silently no-ops and pruned-past-retention tombstones
|
||||
// pile up forever.
|
||||
MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[DATA_BACKUP_QUEUE, DATA_BACKUP_CRON],
|
||||
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON],
|
||||
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
|
||||
[AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON],
|
||||
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
|
||||
[RESTORE_DRILL_QUEUE, RESTORE_DRILL_CRON],
|
||||
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
|
||||
[FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON],
|
||||
// v1.4.37 — hourly geo backfill. The helper is idempotent + capped
|
||||
// at 5 000 rows per pass; running it at :40 every hour catches the
|
||||
// long tail of audit rows that landed with the offline MMDB
|
||||
// missing or the online provider unreachable.
|
||||
[GEO_BACKFILL_QUEUE, GEO_BACKFILL_CRON],
|
||||
// v1.12.2 ios-coord — every-6-hour TLS leaf SPKI probe (:07 off the
|
||||
// hourly sync crons). Surfaces a pinned-leaf rotation well inside the
|
||||
// ≥11-day re-pin window the iOS release owner needs.
|
||||
[TLS_PIN_MONITOR_QUEUE, TLS_PIN_MONITOR_CRON],
|
||||
// Fallback rescan every 30 minutes — protects against ingest paths
|
||||
// that ship measurements without enqueueing a per-user job. The
|
||||
// cron payload deliberately omits a `userId` so the handler iterates
|
||||
// every user; per-user push-suppression cannot apply on the cron
|
||||
// path (the silent flag is set by the ingest hooks).
|
||||
[PR_DETECTION_QUEUE, PR_DETECTION_FALLBACK_CRON],
|
||||
// v1.4.25 W19b — daily expire-stale pass for the per-pen inventory
|
||||
// entities. Flips IN_USE rows whose 30-day clock has blown to
|
||||
// EXPIRED at 03:30 Europe/Berlin (in the existing 02:xx–03:xx
|
||||
// maintenance window, right after idempotency-cleanup).
|
||||
[MEDICATION_INVENTORY_EXPIRE_QUEUE, MEDICATION_INVENTORY_EXPIRE_CRON],
|
||||
// v1.4.46 — hourly auto-skip for medication intakes the user never
|
||||
// marked. Cron `5 * * * *` slots off the top-of-the-hour
|
||||
// reminder-check (:00) and the moodlog-sync (:30) so the three
|
||||
// hourly ticks don't pile up on the same boss poll.
|
||||
[INTAKE_AUTO_SKIP_QUEUE, INTAKE_AUTO_SKIP_CRON],
|
||||
// v0.5.4 ios-coord — daily mood-reminder dispatch-ledger retention sweep.
|
||||
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON],
|
||||
// v1.15.19 — daily 03:28 Europe/Berlin duplicate dose-slot dedup
|
||||
// discovery. The empty cron payload (no `userId`) is the handler's
|
||||
// signal to run the discovery fan-out instead of a per-user pass, so
|
||||
// cross-source duplicate slots created between deploys collapse within
|
||||
// a day instead of waiting for the next worker reboot.
|
||||
[INTAKE_SLOT_DEDUP_QUEUE, INTAKE_SLOT_DEDUP_CRON],
|
||||
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
|
||||
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
|
||||
// v1.7.0 — daily 03:40 Europe/Berlin prune for expired measurement
|
||||
// tombstones.
|
||||
[MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE, MEASUREMENT_TOMBSTONE_CLEANUP_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register every maintenance / ops / cleanup queue. Returns the queue names
|
||||
* created (for the boot-level aggregate assertion).
|
||||
*/
|
||||
export async function registerMaintenanceQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules);
|
||||
|
||||
await boss.work<DataBackupPayload>(
|
||||
DATA_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleDataBackup,
|
||||
);
|
||||
await boss.work<RateLimitCleanupPayload>(
|
||||
RATE_LIMIT_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleRateLimitCleanup,
|
||||
);
|
||||
await boss.work<IdempotencyCleanupPayload>(
|
||||
IDEMPOTENCY_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleIdempotencyCleanup,
|
||||
);
|
||||
await boss.work<AuditLogCleanupPayload>(
|
||||
AUDIT_LOG_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleAuditLogCleanup,
|
||||
);
|
||||
await boss.work<OffhostBackupPayload>(
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleOffhostBackup,
|
||||
);
|
||||
// prettier-ignore
|
||||
await boss.work(RESTORE_DRILL_QUEUE, { localConcurrency: 1 }, handleRestoreDrill);
|
||||
await boss.work<HostMetricSamplePayload>(
|
||||
HOST_METRIC_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleHostMetricSample,
|
||||
);
|
||||
await boss.work<FeedbackAggregatorPayload>(
|
||||
FEEDBACK_AGGREGATOR_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleFeedbackAggregator,
|
||||
);
|
||||
await boss.work<GeoBackfillPayload>(
|
||||
GEO_BACKFILL_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleGeoBackfill,
|
||||
);
|
||||
// v1.12.2 ios-coord — TLS leaf SPKI-change monitor. Single-flight: one
|
||||
// short outbound TLS handshake per tick, no benefit to overlapping ticks.
|
||||
await boss.work<TlsPinMonitorPayload>(
|
||||
TLS_PIN_MONITOR_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleTlsPinMonitor,
|
||||
);
|
||||
await boss.work<MoodReminderCleanupPayload>(
|
||||
MOOD_REMINDER_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodReminderCleanup,
|
||||
);
|
||||
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
|
||||
// matches every other cleanup queue; two ticks racing on the same
|
||||
// DELETE statement is wasted work and the second tick's payload
|
||||
// would be a no-op anyway.
|
||||
await boss.work<PushAttemptCleanupPayload>(
|
||||
PUSH_ATTEMPT_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handlePushAttemptCleanup,
|
||||
);
|
||||
// v1.7.0 — daily prune of expired measurement tombstones. Single-flight
|
||||
// like every other cleanup queue.
|
||||
await boss.work<MeasurementTombstoneCleanupPayload>(
|
||||
MEASUREMENT_TOMBSTONE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMeasurementTombstoneCleanup,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
handlePrDetection,
|
||||
);
|
||||
await boss.work<MedicationInventoryExpirePayload>(
|
||||
MEDICATION_INVENTORY_EXPIRE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMedicationInventoryExpire,
|
||||
);
|
||||
// v1.4.46 — hourly auto-skip for stale unmarked intakes.
|
||||
// Single-flight: two ticks racing against the same row pile is wasted
|
||||
// work, and the underlying `updateMany` is the canonical idempotent
|
||||
// shape anyway.
|
||||
await boss.work<IntakeAutoSkipPayload>(
|
||||
INTAKE_AUTO_SKIP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleIntakeAutoSkip,
|
||||
);
|
||||
// v1.4.34 — Apple Health export.zip ingest worker. localConcurrency
|
||||
// caps at 1 because the parse loop is CPU-bound and a concurrent
|
||||
// second 1 GB import would race the first for RSS.
|
||||
await boss.work<AppleHealthImportPayload>(
|
||||
APPLE_HEALTH_IMPORT_QUEUE,
|
||||
{ localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
// pg-boss v12 work callbacks always receive an array (batched
|
||||
// worker mode); for our concurrency-1 + batchSize-1 case we just
|
||||
// process each job sequentially.
|
||||
for (const job of jobs) {
|
||||
await handleAppleHealthImport(job);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.8.2 — duplicate dose-slot cleanup worker. The boot enqueue helper
|
||||
// below sends one job per user holding two live intake rows that snap
|
||||
// to the same canonical slot; this handler keeps the winner, soft-
|
||||
// deletes the losers, normalises the winner's scheduledFor, and
|
||||
// recomputes the affected compliance rollups. Serial concurrency so the
|
||||
// pass never crowds the request pool.
|
||||
//
|
||||
// v1.15.19 — the queue also carries a daily cron tick (03:28, scheduled
|
||||
// above) whose payload omits `userId`. That tick runs the SAME discovery
|
||||
// fan-out the boot pass uses, so duplicate slots created between deploys
|
||||
// collapse within a day instead of waiting for the next worker reboot.
|
||||
await boss.work<Partial<IntakeSlotDedupPayload>>(
|
||||
INTAKE_SLOT_DEDUP_QUEUE,
|
||||
{ localConcurrency: INTAKE_SLOT_DEDUP_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
if (!userId) {
|
||||
// Daily discovery tick — fan out one per-user job per account
|
||||
// still holding duplicate-slot candidates (singletonKey-coalesced,
|
||||
// identical to the boot pass).
|
||||
const result = await enqueueBootTimeIntakeSlotDedup();
|
||||
workerLog(
|
||||
"info",
|
||||
`[intake-slot-dedup] daily discovery enqueued=${result.enqueued} skipped=${result.skipped}${result.error ? ` error=${result.error}` : ""}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const summary = await dedupeUserIntakeSlots(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[intake-slot-dedup] user=${userId} slotsCollapsed=${summary.slotsCollapsed} rowsSoftDeleted=${summary.rowsSoftDeleted} rowsNormalised=${summary.rowsNormalised} daysRecomputed=${summary.daysRecomputed}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", `[intake-slot-dedup] user=${userId} failed`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget boot discovery for the duplicate dose-slot cleanup. Finds
|
||||
* every user holding two live intake rows within the drift window on the same
|
||||
* medication and enqueues one dedup job per account. Idempotent across reboots:
|
||||
* collapsed losers are soft-deleted so the `deleted_at IS NULL` discovery
|
||||
* predicate drops them. Errors are returned through the helper's result value —
|
||||
* the worker boot never fails because of a dedup miss.
|
||||
*/
|
||||
export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeIntakeSlotDedup();
|
||||
if (error) {
|
||||
workerLog("error", `[intake-slot-dedup] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[intake-slot-dedup] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[intake-slot-dedup] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Reminder-dispatch queue registrar.
|
||||
*
|
||||
* Owns the user-facing reminder ticks: the medication overdue check, the daily
|
||||
* mood reminder, the cycle reminder (period-soon + period-start-confirm), the
|
||||
* Vorsorge (measurement) reminder, and the eventful reminder-satisfy queue.
|
||||
*
|
||||
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
|
||||
* cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`,
|
||||
* and a `boss.work(QUEUE, …, handler)` binding drains it. The
|
||||
* `cycle-reminder-queue` and `measurement-reminder-queue` guards read THIS
|
||||
* module.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import {
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
REMINDER_SATISFY_CONCURRENCY,
|
||||
type ReminderSatisfyPayload,
|
||||
} from "@/lib/jobs/reminder-satisfy";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
import {
|
||||
ReminderCheckPayload,
|
||||
handleReminderCheck,
|
||||
} from "./medication-reminder-check";
|
||||
import {
|
||||
MoodReminderPayload,
|
||||
CycleReminderPayload,
|
||||
MeasurementReminderPayload,
|
||||
handleMoodReminderCheck,
|
||||
handleCycleReminderCheck,
|
||||
handleMeasurementReminderCheck,
|
||||
handleReminderSatisfy,
|
||||
} from "./mood-cycle-checks";
|
||||
|
||||
const QUEUE_NAME = "medication-reminder-check";
|
||||
|
||||
const CHECK_INTERVAL_CRON = "*/15 * * * *"; // every 15 minutes
|
||||
// v0.5.4 ios-coord — daily mood-reminder cron.
|
||||
//
|
||||
// Runs every 15 minutes so the handler can pick up users whose local
|
||||
// time has just crossed 22:00 across any IANA timezone without having
|
||||
// to schedule one cron entry per zone. The handler short-circuits when
|
||||
// the user's local hour isn't 22, so the 15-min cadence translates to
|
||||
// ~4 ticks-per-hour × 1 actual-dispatch-window-per-user = at most one
|
||||
// push per user per day. Idempotency is enforced by the
|
||||
// `MoodReminderDispatch` ledger inside the handler.
|
||||
|
||||
const MOOD_REMINDER_QUEUE = "mood-reminder-check";
|
||||
|
||||
const MOOD_REMINDER_CRON = "*/15 * * * *";
|
||||
// v1.15 — daily cycle reminder cron (period-soon + period-start-confirm).
|
||||
//
|
||||
// Runs every 15 minutes for the same reason as the mood reminder: the
|
||||
// handler short-circuits unless the candidate user's local time is the
|
||||
// cycle-reminder hour (09:00), so the 15-min cadence picks up every IANA
|
||||
// timezone crossing that hour without one cron entry per zone. At most one
|
||||
// push per event per user per local day — the `push_attempts` ledger is the
|
||||
// idempotency anchor inside the handler.
|
||||
|
||||
const CYCLE_REMINDER_QUEUE = "cycle-reminder-check";
|
||||
|
||||
const CYCLE_REMINDER_CRON = "*/15 * * * *";
|
||||
|
||||
// v1.17.1 — every-15-min tick for Vorsorge (measurement) reminders.
|
||||
// Same cadence + short-circuit shape as the mood / cycle reminders: the
|
||||
// handler only fires a reminder whose `nextDueAt` is past AND whose local
|
||||
// time is the reminder's `notifyHour`, so the 15-min cadence picks up
|
||||
// every IANA timezone crossing that hour without one cron entry per zone.
|
||||
// Dedup is the reminder's own `nextDueAt` advance — no ledger table.
|
||||
|
||||
const MEASUREMENT_REMINDER_QUEUE = "measurement-reminder-check";
|
||||
|
||||
const MEASUREMENT_REMINDER_CRON = "*/15 * * * *";
|
||||
|
||||
const allQueues = [
|
||||
QUEUE_NAME,
|
||||
// v0.5.4 ios-coord — mood-reminder cron tick. Same pg-boss v12
|
||||
// createQueue contract as the drain queue; without this entry the
|
||||
// every-15-min schedule silently no-ops and the dispatcher never fires.
|
||||
MOOD_REMINDER_QUEUE,
|
||||
// v1.15 — cycle-reminder cron tick (period-soon + period-start-confirm).
|
||||
// Without this entry the every-15-min schedule silently no-ops and the
|
||||
// cycle dispatcher never fires (the v1.4.37 dead-queue class).
|
||||
CYCLE_REMINDER_QUEUE,
|
||||
// v1.17.1 — Vorsorge (measurement) reminder cron tick. Without this entry
|
||||
// pg-boss never provisions the queue and the every-15-min schedule
|
||||
// silently no-ops (the v1.4.37 dead-queue class).
|
||||
MEASUREMENT_REMINDER_QUEUE,
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. No cron of its own (the
|
||||
// 15-min measurement-reminder check is the safety-net); enqueued by the
|
||||
// ingest paths. Must still be registered here or the worker binding
|
||||
// below never provisions the queue (v1.4.37 dead-queue lesson).
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[QUEUE_NAME, CHECK_INTERVAL_CRON],
|
||||
// v0.5.4 ios-coord — every-15-min tick for the daily mood reminder.
|
||||
// The handler short-circuits unless the candidate user's local
|
||||
// time is the 22:00 hour, so the cron costs ~one user-row scan
|
||||
// per tick for the entire opted-in cohort.
|
||||
[MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON],
|
||||
// v1.15 — every-15-min tick for the daily cycle reminder. The handler
|
||||
// short-circuits unless the candidate user's local time is the 09:00
|
||||
// hour, so the cron costs ~one prediction-row scan per tick for the
|
||||
// opted-in cohort.
|
||||
[CYCLE_REMINDER_QUEUE, CYCLE_REMINDER_CRON],
|
||||
// v1.17.1 — every-15-min tick for the Vorsorge (measurement) reminder.
|
||||
// The handler short-circuits unless a reminder is past-due AND the
|
||||
// user's local time matches the reminder's notifyHour, so the cron
|
||||
// costs ~one reminder-row scan per tick for the active cohort.
|
||||
[MEASUREMENT_REMINDER_QUEUE, MEASUREMENT_REMINDER_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register every reminder-dispatch queue. Returns the queue names created (for
|
||||
* the boot-level aggregate assertion).
|
||||
*/
|
||||
export async function registerReminderQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules);
|
||||
|
||||
await boss.work<ReminderCheckPayload>(
|
||||
QUEUE_NAME,
|
||||
{ localConcurrency: 1 },
|
||||
handleReminderCheck,
|
||||
);
|
||||
// v0.5.4 ios-coord — single-flight worker. localConcurrency=1 keeps
|
||||
// two reminder ticks from interleaving against the same user row;
|
||||
// the dedup ledger would still save us, but skipping the race here
|
||||
// avoids spurious P2002 errors in the wide-event log.
|
||||
await boss.work<MoodReminderPayload>(
|
||||
MOOD_REMINDER_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodReminderCheck,
|
||||
);
|
||||
// v1.15 — single-flight cycle-reminder worker. localConcurrency=1 keeps
|
||||
// two ticks from racing the fire-and-forget `push_attempts` ledger that
|
||||
// anchors the per-day idempotency, exactly like the mood-reminder worker.
|
||||
await boss.work<CycleReminderPayload>(
|
||||
CYCLE_REMINDER_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleCycleReminderCheck,
|
||||
);
|
||||
// v1.17.1 — single-flight Vorsorge (measurement) reminder worker.
|
||||
// localConcurrency=1 keeps two ticks from racing the `nextDueAt`
|
||||
// advance that anchors the per-cycle idempotency, exactly like the
|
||||
// mood / cycle reminder workers.
|
||||
await boss.work<MeasurementReminderPayload>(
|
||||
MEASUREMENT_REMINDER_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMeasurementReminderCheck,
|
||||
);
|
||||
// v1.18.1 — eventful Vorsorge satisfaction. Resolves a user's reminders
|
||||
// against their just-landed measurement / lab. Read-heavy on the user's
|
||||
// own data; the same small concurrency budget as PR detection.
|
||||
await boss.work<ReminderSatisfyPayload>(
|
||||
REMINDER_SATISFY_QUEUE,
|
||||
{ localConcurrency: REMINDER_SATISFY_CONCURRENCY },
|
||||
handleReminderSatisfy,
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
/**
|
||||
* Rollup / consolidation queue registrar.
|
||||
*
|
||||
* Owns the pre-aggregation + consolidation tier: measurement-rollup recompute
|
||||
* + boot-backfill, mood-rollup recompute + boot-backfill, medication-compliance
|
||||
* boot-backfill, legacy step consolidation, daily-mean consolidation, dense
|
||||
* intra-day retention, and the nightly per-sample cumulative drain (which also
|
||||
* folds the daily-mean + dense-retention passes onto the same tick). Includes
|
||||
* the boot-time discovery enqueues for the self-converging backfills.
|
||||
*
|
||||
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
|
||||
* cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`,
|
||||
* and a `boss.work(QUEUE, …, handler)` binding drains it. The
|
||||
* `drain-cumulative-queue`, `mean-consolidation-queue`, and
|
||||
* `stress-strain-retention-queue` (dense-retention facet) guards read THIS
|
||||
* module.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import {
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
ROLLUP_FULL_BACKFILL_CONCURRENCY,
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
ROLLUP_RECOMPUTE_CONCURRENCY,
|
||||
enqueueBootTimeRollupBackfill,
|
||||
recomputeUserRollups,
|
||||
type RollupFullBackfillPayload,
|
||||
type RollupRecomputePayload,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import {
|
||||
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY,
|
||||
MOOD_ROLLUP_RECOMPUTE_QUEUE,
|
||||
MOOD_ROLLUP_RECOMPUTE_CONCURRENCY,
|
||||
enqueueBootTimeMoodRollupBackfill,
|
||||
recomputeUserMoodRollups,
|
||||
type MoodRollupFullBackfillPayload,
|
||||
type MoodRollupRecomputePayload,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY,
|
||||
recomputeUserMedicationCompliance,
|
||||
enqueueBootTimeMedicationComplianceBackfill,
|
||||
type MedicationComplianceBackfillPayload,
|
||||
} from "@/lib/rollups/medication-compliance-rollups";
|
||||
import {
|
||||
drainPerSampleCumulative,
|
||||
DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
import {
|
||||
consolidateDailyMean,
|
||||
MEAN_CONSOLIDATION_CUTOFF_HOURS,
|
||||
} from "@/lib/measurements/consolidate-daily-mean";
|
||||
import {
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
STEP_CONSOLIDATION_CONCURRENCY,
|
||||
runStepConsolidationForUser,
|
||||
enqueueBootTimeStepConsolidation,
|
||||
type StepConsolidationPayload,
|
||||
} from "@/lib/jobs/step-consolidation";
|
||||
import {
|
||||
MEAN_CONSOLIDATION_QUEUE,
|
||||
MEAN_CONSOLIDATION_CONCURRENCY,
|
||||
runMeanConsolidationForUser,
|
||||
enqueueBootTimeMeanConsolidation,
|
||||
type MeanConsolidationPayload,
|
||||
} from "@/lib/jobs/mean-consolidation";
|
||||
import {
|
||||
DENSE_INTRADAY_RETENTION_QUEUE,
|
||||
DENSE_INTRADAY_RETENTION_CONCURRENCY,
|
||||
runDenseIntradayRetentionForUser,
|
||||
enqueueBootTimeDenseIntradayRetention,
|
||||
DENSE_INTRADAY_RETENTION_ENABLED,
|
||||
type DenseIntradayRetentionPayload,
|
||||
} from "@/lib/jobs/dense-intraday-retention";
|
||||
import { runDenseIntradayRetention } from "@/lib/measurements/dense-intraday-retention";
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
import { createAndSchedule, type ScheduleEntry } from "./registrar-shared";
|
||||
// v1.4.37 W7c — nightly drain of per-sample APPLE_HEALTH cumulative rows.
|
||||
// Collapses each user × cumulative-type × calendar-day bucket into one
|
||||
// `stats:…` row so the list view stops painting hundreds of step chunks per
|
||||
// day. 03:45 Europe/Berlin slots in between the 03:15 audit-log cleanup and
|
||||
// the 04:00 feedback aggregator. The 36-hour grace window keeps today + the
|
||||
// trailing watch-sync window intact for real-time visibility; only
|
||||
// completed-and-stable days fall to the drain.
|
||||
|
||||
const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative";
|
||||
|
||||
const DRAIN_CUMULATIVE_CRON = "45 3 * * *";
|
||||
|
||||
interface DrainCumulativePayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
const allQueues = [
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
// v1.4.39 W-MOOD — per-bucket WEEK/MONTH/YEAR fold queue for the
|
||||
// mood rollup tier. The DAY pass runs inline in the write hook;
|
||||
// these enqueue paths cover the cross-granularity buckets the
|
||||
// worker materialises off the request path.
|
||||
MOOD_ROLLUP_RECOMPUTE_QUEUE,
|
||||
// v1.4.39 W-MOOD — boot-time fold queue for the mood rollup tier.
|
||||
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
// v1.4.39 W-MED — boot-time fold for the medication-compliance
|
||||
// rollup tier. Discovery enqueues one job per user with intake
|
||||
// events but no rollup coverage; idempotent across reboots.
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
// v1.5.6 — boot-time legacy step consolidation. Discovery enqueues
|
||||
// one job per user still holding live pre-v1.5.0 granular step rows.
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
// v1.7.0 — daily-mean consolidation for high-frequency spot HealthKit
|
||||
// metrics (walking speed/step length, respiratory rate, audio exposure).
|
||||
MEAN_CONSOLIDATION_QUEUE,
|
||||
// v1.10.0 — computed scores (WX-E). Dense intra-day retention drain of
|
||||
// daytime HRV / HR samples (per-user backfill queue, boot-discovery
|
||||
// driven like mean-consolidation; the steady-state nightly walk folds
|
||||
// onto the drain-cumulative tick).
|
||||
DENSE_INTRADAY_RETENTION_QUEUE,
|
||||
// v1.4.37 W7c — explicit createQueue is required before the nightly
|
||||
// schedule below registers (pg-boss v12 contract). Without this entry the
|
||||
// drain schedule silently no-ops and the per-sample APPLE_HEALTH rows
|
||||
// never collapse.
|
||||
DRAIN_CUMULATIVE_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
// v1.4.37 W7c — nightly fold of per-sample APPLE_HEALTH cumulative
|
||||
// rows into one row per day per type. Slots between the
|
||||
// audit-log cleanup (03:15) and the feedback aggregator (04:00).
|
||||
[DRAIN_CUMULATIVE_QUEUE, DRAIN_CUMULATIVE_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register every rollup / consolidation queue. Returns the queue names created
|
||||
* (for the boot-level aggregate assertion).
|
||||
*/
|
||||
export async function registerRollupQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules);
|
||||
|
||||
// v1.5.0 — persistent measurement rollup worker. Folds the
|
||||
// WEEK / MONTH / YEAR buckets that the write-path hooks enqueue;
|
||||
// the DAY bucket is already recomputed synchronously by the hook
|
||||
// itself. Concurrency-2 keeps two recomputes in flight without
|
||||
// crowding the dashboard request pool.
|
||||
await boss.work<RollupRecomputePayload>(
|
||||
ROLLUP_RECOMPUTE_QUEUE,
|
||||
{ localConcurrency: ROLLUP_RECOMPUTE_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const payload = job.data;
|
||||
await recomputeUserRollups(payload.userId, {
|
||||
types: [payload.type],
|
||||
granularities: [payload.granularity],
|
||||
from: new Date(payload.from),
|
||||
to: new Date(payload.to),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 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.5.6 — legacy step consolidation worker. The boot enqueue helper
|
||||
// below sends one job per user still holding live pre-v1.5.0 granular
|
||||
// step rows; this handler collapses them into one daily-total row per
|
||||
// calendar day and soft-deletes the originals. Serial concurrency so
|
||||
// the populator never crowds the dashboard request pool.
|
||||
await boss.work<StepConsolidationPayload>(
|
||||
STEP_CONSOLIDATION_QUEUE,
|
||||
{ localConcurrency: STEP_CONSOLIDATION_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { daysConsolidated, legacyRowsSoftDeleted } =
|
||||
await runStepConsolidationForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] user=${userId} days=${daysConsolidated} legacyRowsSoftDeleted=${legacyRowsSoftDeleted}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.7.0 — daily-mean consolidation worker. The boot enqueue helper
|
||||
// below sends one job per user holding live per-sample high-frequency
|
||||
// mean-type rows; this handler collapses each completed day to its
|
||||
// mean and soft-deletes the originals. Serial concurrency so the
|
||||
// populator never crowds the dashboard request pool.
|
||||
await boss.work<MeanConsolidationPayload>(
|
||||
MEAN_CONSOLIDATION_QUEUE,
|
||||
{ localConcurrency: MEAN_CONSOLIDATION_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
const { daysConsolidated, perSampleRowsSoftDeleted } =
|
||||
await runMeanConsolidationForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[mean-consolidation] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.10.0 WX-E — dense intra-day retention per-user backfill worker. The
|
||||
// boot enqueue helper below sends one job per user holding live per-sample
|
||||
// dense-tier (HRV / HR) rows older than the retention window; this handler
|
||||
// folds those out-of-window samples to a daily mean and soft-deletes the
|
||||
// originals, keeping the in-window intra-day shape intact for the Stress
|
||||
// engine. Serial concurrency so the backfill never crowds the request pool.
|
||||
await boss.work<DenseIntradayRetentionPayload>(
|
||||
DENSE_INTRADAY_RETENTION_QUEUE,
|
||||
{ localConcurrency: DENSE_INTRADAY_RETENTION_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
try {
|
||||
const { daysConsolidated, perSampleRowsSoftDeleted } =
|
||||
await runDenseIntradayRetentionForUser(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[dense-intraday-retention] user=${userId} days=${daysConsolidated} perSampleRowsSoftDeleted=${perSampleRowsSoftDeleted}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[dense-intraday-retention] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.39 W-MOOD — mood-rollup per-bucket worker. Folds the
|
||||
// WEEK / MONTH / YEAR buckets that the mood-entry write hooks
|
||||
// enqueue; the DAY bucket runs synchronously in the hook itself.
|
||||
// No current read path consumes these buckets — they exist so a
|
||||
// future cross-granularity reader can ship without a backfill
|
||||
// step. Concurrency-2 mirrors the measurement-rollup worker.
|
||||
await boss.work<MoodRollupRecomputePayload>(
|
||||
MOOD_ROLLUP_RECOMPUTE_QUEUE,
|
||||
{ localConcurrency: MOOD_ROLLUP_RECOMPUTE_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const payload = job.data;
|
||||
await recomputeUserMoodRollups(payload.userId, {
|
||||
granularities: [payload.granularity],
|
||||
from: new Date(payload.from),
|
||||
to: new Date(payload.to),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.39 W-MOOD — mood-rollup boot-time fold worker. The boot
|
||||
// discovery helper below sends one job per user with mood entries
|
||||
// but zero rollup rows; this handler folds the full 5-year window
|
||||
// across every granularity. Concurrency-1 so the populator never
|
||||
// crowds the request pool.
|
||||
await boss.work<MoodRollupFullBackfillPayload>(
|
||||
MOOD_ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
{ localConcurrency: MOOD_ROLLUP_FULL_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
try {
|
||||
const { rowsUpserted, durationMs } =
|
||||
await recomputeUserMoodRollups(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[mood-rollup-full-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[mood-rollup-full-backfill] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.39 W-MED — medication-compliance boot-backfill worker. The
|
||||
// discovery helper below sends one job per user with intake events
|
||||
// but zero rollup coverage; this handler folds the trailing 90-day
|
||||
// window per account. Concurrency-1 so the populator never crowds
|
||||
// the request pool.
|
||||
await boss.work<MedicationComplianceBackfillPayload>(
|
||||
MEDICATION_COMPLIANCE_BACKFILL_QUEUE,
|
||||
{ localConcurrency: MEDICATION_COMPLIANCE_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
try {
|
||||
const { rowsUpserted, durationMs } =
|
||||
await recomputeUserMedicationCompliance(userId);
|
||||
workerLog(
|
||||
"info",
|
||||
`[medication-compliance-backfill] user=${userId} rows=${rowsUpserted} duration=${durationMs}ms`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog(
|
||||
"error",
|
||||
`[medication-compliance-backfill] user=${userId} failed`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.4.37 W7c — nightly drain worker. Walks every user × cumulative
|
||||
// type and folds per-sample APPLE_HEALTH rows older than the cutoff
|
||||
// into one `stats:…` row per calendar day. Idempotent — a second run
|
||||
// collapses zero buckets once every day is in the `stats:` shape.
|
||||
// Concurrency-1 so the drain never crowds the dashboard request pool
|
||||
// and a long backfill on the maintainer's account (300 k+ measurement rows)
|
||||
// stays a single sequential walk.
|
||||
await boss.work<DrainCumulativePayload>(
|
||||
DRAIN_CUMULATIVE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const summary = await drainPerSampleCumulative(getWorkerPrisma(), {
|
||||
dryRun: false,
|
||||
cutoffHours: DRAIN_CUMULATIVE_CUTOFF_HOURS,
|
||||
log: (line) => workerLog("info", line),
|
||||
});
|
||||
workerLog(
|
||||
"info",
|
||||
`[drain-cumulative] triggeredAt=${job.data.triggeredAt} usersScanned=${summary.totals.usersScanned} bucketsCollapsed=${summary.totals.bucketsCollapsed} perSampleRowsDeleted=${summary.totals.perSampleRowsDeleted} dailyRowsUpserted=${summary.totals.dailyRowsUpserted}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError("drain-cumulative", err);
|
||||
}
|
||||
|
||||
// v1.8.5 — fold the daily-MEAN drain onto the same nightly tick as
|
||||
// the cumulative (SUM) drain. The mean drain was previously
|
||||
// boot-discovery only, so between worker reboots new high-frequency
|
||||
// spot samples (walking speed, respiratory rate, gait/mobility,
|
||||
// audio exposure) accumulated raw. Running both passes on one
|
||||
// concurrency-1 cron keeps the maintenance window a single
|
||||
// sequential walk and never crowds the request pool. The global
|
||||
// (no `userId`) signature drains every user; the 36-hour grace
|
||||
// cutoff keeps today's in-flight watch syncs raw. Boot discovery
|
||||
// stays as the back-fill for accounts that accumulated raw rows
|
||||
// before this cron shipped.
|
||||
try {
|
||||
const meanSummary = await consolidateDailyMean(getWorkerPrisma(), {
|
||||
dryRun: false,
|
||||
cutoffHours: MEAN_CONSOLIDATION_CUTOFF_HOURS,
|
||||
log: (line) => workerLog("info", line),
|
||||
});
|
||||
workerLog(
|
||||
"info",
|
||||
`[mean-consolidation] triggeredAt=${job.data.triggeredAt} usersScanned=${meanSummary.totals.usersScanned} daysConsolidated=${meanSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${meanSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${meanSummary.totals.dailyRowsUpserted} daysFailed=${meanSummary.totals.daysFailed}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError("mean-consolidation", err, {
|
||||
tick: "nightly",
|
||||
});
|
||||
}
|
||||
|
||||
// v1.10.0 WX-E — fold the dense intra-day retention drain onto the
|
||||
// same nightly tick. Unlike the daily-mean drain, this scopes to the
|
||||
// dense-tier types (HEART_RATE_VARIABILITY, PULSE) and keeps the last
|
||||
// DENSE_INTRADAY_RETENTION_DAYS of raw per-sample rows so the Stress
|
||||
// engine still sees the intra-day SDNN shape; only out-of-window
|
||||
// samples fold to a daily mean. These two types are NEVER in the
|
||||
// destructive HIGH_FREQUENCY_MEAN_TYPES allowlist — the drain
|
||||
// exemption the intra-day shape depends on. The global (no `userId`)
|
||||
// signature drains every user. Boot discovery (below) back-fills
|
||||
// accounts that accumulated out-of-window raw rows before this
|
||||
// shipped.
|
||||
try {
|
||||
if (!DENSE_INTRADAY_RETENTION_ENABLED) {
|
||||
workerLog(
|
||||
"info",
|
||||
"[dense-intraday-retention] disabled via DENSE_INTRADAY_RETENTION_ENABLED — skipping the nightly walk (operator kill-switch)",
|
||||
);
|
||||
} else {
|
||||
const denseSummary = await runDenseIntradayRetention(
|
||||
getWorkerPrisma(),
|
||||
{
|
||||
dryRun: false,
|
||||
log: (line) => workerLog("info", line),
|
||||
},
|
||||
);
|
||||
workerLog(
|
||||
"info",
|
||||
`[dense-intraday-retention] triggeredAt=${job.data.triggeredAt} usersScanned=${denseSummary.totals.usersScanned} daysConsolidated=${denseSummary.totals.daysConsolidated} perSampleRowsSoftDeleted=${denseSummary.totals.perSampleRowsSoftDeleted} dailyRowsUpserted=${denseSummary.totals.dailyRowsUpserted}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError("dense-intraday-retention", err, {
|
||||
tick: "nightly",
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget boot discovery for the self-converging rollup / consolidation
|
||||
* backfills. Each pass is idempotent across reboots and never fails worker boot
|
||||
* on a miss (errors come back through the helper's result value).
|
||||
*/
|
||||
export async function enqueueRollupBootDiscovery(): Promise<void> {
|
||||
// v1.4.35.1 — measurement-rollup backfill. Finds every user with
|
||||
// measurements but no rollup coverage and enqueues a full-fold per account.
|
||||
// The discovery query only matches accounts with zero rollup rows, so once
|
||||
// a fold completes the user drops off the list.
|
||||
try {
|
||||
const { enqueued, skipped, error } = await enqueueBootTimeRollupBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[rollup-full-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
// v1.4.38.7 — log the discovery result on every boot, including
|
||||
// the silent `enqueued=0 skipped=0` case. Without this, an
|
||||
// operator chasing "analytics is slow" cannot tell whether the
|
||||
// discovery query ran successfully (and found nothing to fold)
|
||||
// vs. silently no-op'd. The line is one row per worker boot, so
|
||||
// the log cost is negligible.
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.5.6 — legacy step consolidation. Finds every user still holding live
|
||||
// pre-v1.5.0 granular step rows and enqueues one consolidation job per
|
||||
// account. Consolidated legacy rows are soft-deleted, so the
|
||||
// `deleted_at IS NULL` discovery predicate drops them.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeStepConsolidation();
|
||||
if (error) {
|
||||
workerLog("error", `[step-consolidation] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[step-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[step-consolidation] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.7.0 — daily-mean consolidation. Finds every user holding live
|
||||
// per-sample high-frequency mean-type rows and enqueues one job per account.
|
||||
// Consolidated rows are soft-deleted, so the discovery predicate drops them.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeMeanConsolidation();
|
||||
if (error) {
|
||||
workerLog("error", `[mean-consolidation] boot discovery failed: ${error}`);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[mean-consolidation] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[mean-consolidation] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.10.0 WX-E — dense intra-day retention drain. Finds every user holding
|
||||
// live per-sample dense-tier (HRV / HR) rows OLDER than the retention window
|
||||
// and enqueues one job per account. Folded rows are soft-deleted, so the
|
||||
// discovery predicate drops them.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeDenseIntradayRetention();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[dense-intraday-retention] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[dense-intraday-retention] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[dense-intraday-retention] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MOOD — mood rollup tier. Mirrors the v1.4.35.1
|
||||
// measurement-rollup pattern: one job per user with mood entries but no
|
||||
// rollup coverage. Idempotent across reboots and singleton-keyed inside
|
||||
// pg-boss so a fast restart while a backfill is queued doesn't double up.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeMoodRollupBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[mood-rollup-full-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[mood-rollup-full-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[mood-rollup-full-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.4.39 W-MED — medication compliance rollup tier. Mirrors the v1.4.35.1
|
||||
// pattern: one job per user with intake events but no rollup coverage.
|
||||
// Idempotent across reboots and singleton-keyed inside pg-boss.
|
||||
try {
|
||||
const { enqueued, skipped, error } =
|
||||
await enqueueBootTimeMedicationComplianceBackfill();
|
||||
if (error) {
|
||||
workerLog(
|
||||
"error",
|
||||
`[medication-compliance-backfill] boot discovery failed: ${error}`,
|
||||
);
|
||||
} else {
|
||||
workerLog(
|
||||
"info",
|
||||
`[medication-compliance-backfill] boot discovery: enqueued=${enqueued} skipped=${skipped}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"[medication-compliance-backfill] boot discovery threw an unexpected error",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Status / insight / computed-score queue registrar.
|
||||
*
|
||||
* Owns the nightly per-metric insight-status ladder (general / BP / weight /
|
||||
* pulse / BMI / mood / medication-compliance), the comprehensive-insight
|
||||
* pre-generation, the on-demand per-metric status generation, the nightly
|
||||
* computed scores (recovery / stress / strain), the proactive Coach nudge, the
|
||||
* Coach memory refresh, the period-narrative warm, and the medication
|
||||
* low-stock pass.
|
||||
*
|
||||
* v1.4.37 dead-queue contract: every queue name appears in `allQueues`, its
|
||||
* cron (where it has one) appears as a `[QUEUE, CRON]` tuple in `schedules`,
|
||||
* and a `boss.work(QUEUE, …, handler)` binding drains it. The status-wiring
|
||||
* guards (`status-cron-candidates`, `insight-pregenerate`,
|
||||
* `insight-status-generate`, `recovery-score-queue`,
|
||||
* `stress-strain-retention-queue`, `period-narrative-warm`,
|
||||
* `coach-memory-refresh-queue`) read THIS module.
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
import { reportWorkerError } from "@/lib/jobs/report-worker-error";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import {
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
INSIGHT_PREGENERATE_CRON,
|
||||
type InsightPregeneratePayload,
|
||||
} from "@/lib/jobs/insight-pregenerate";
|
||||
import {
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
RECOVERY_SCORE_CRON,
|
||||
runRecoveryScore,
|
||||
} from "@/lib/jobs/recovery-score";
|
||||
import {
|
||||
COACH_NUDGE_QUEUE,
|
||||
COACH_NUDGE_CRON,
|
||||
runCoachNudgeTick,
|
||||
} from "@/lib/jobs/coach-nudge";
|
||||
import {
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
MEDICATION_LOW_STOCK_CRON,
|
||||
runMedicationLowStockTick,
|
||||
} from "@/lib/jobs/medication-low-stock";
|
||||
import {
|
||||
STRESS_SCORE_QUEUE,
|
||||
STRESS_SCORE_CRON,
|
||||
runStressScore,
|
||||
} from "@/lib/jobs/stress-score";
|
||||
import {
|
||||
STRAIN_SCORE_QUEUE,
|
||||
STRAIN_SCORE_CRON,
|
||||
runStrainScore,
|
||||
} from "@/lib/jobs/strain-score";
|
||||
import {
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
PERIOD_NARRATIVE_CRON,
|
||||
runPeriodNarrativeWarm,
|
||||
warmOneNarrative,
|
||||
type PeriodNarrativePayload,
|
||||
} from "@/lib/jobs/period-narrative-warm";
|
||||
import {
|
||||
COACH_MEMORY_REFRESH_QUEUE,
|
||||
type CoachMemoryRefreshPayload,
|
||||
} from "@/lib/ai/coach/coach-memory-shared";
|
||||
import { runCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-refresh-worker";
|
||||
import {
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
INSIGHT_STATUS_GENERATE_CONCURRENCY,
|
||||
type InsightStatusGeneratePayload,
|
||||
} from "@/lib/jobs/insight-status-generate";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
import {
|
||||
createAndSchedule,
|
||||
insightRetryOptions,
|
||||
type ScheduleEntry,
|
||||
} from "./registrar-shared";
|
||||
import {
|
||||
GeneralStatusPayload,
|
||||
BloodPressureStatusPayload,
|
||||
WeightStatusPayload,
|
||||
PulseStatusPayload,
|
||||
BmiStatusPayload,
|
||||
MoodStatusPayload,
|
||||
MedicationComplianceStatusPayload,
|
||||
handleGeneralStatusGenerate,
|
||||
handleBloodPressureStatusGenerate,
|
||||
handleWeightStatusGenerate,
|
||||
handlePulseStatusGenerate,
|
||||
handleBmiStatusGenerate,
|
||||
handleMoodStatusGenerate,
|
||||
handleMedicationComplianceStatusGenerate,
|
||||
handleInsightPregenerateJob,
|
||||
handleInsightStatusGenerate,
|
||||
} from "./insights-handlers";
|
||||
|
||||
const GENERAL_STATUS_QUEUE = "insights-general-status";
|
||||
|
||||
const GENERAL_STATUS_CRON = "0 2 * * *"; // daily at 02:00
|
||||
|
||||
const BLOOD_PRESSURE_STATUS_QUEUE = "insights-blood-pressure-status";
|
||||
|
||||
const BLOOD_PRESSURE_STATUS_CRON = "5 2 * * *"; // daily at 02:05
|
||||
|
||||
const WEIGHT_STATUS_QUEUE = "insights-weight-status";
|
||||
|
||||
const WEIGHT_STATUS_CRON = "10 2 * * *"; // daily at 02:10
|
||||
|
||||
const PULSE_STATUS_QUEUE = "insights-pulse-status";
|
||||
|
||||
const PULSE_STATUS_CRON = "15 2 * * *"; // daily at 02:15
|
||||
|
||||
const BMI_STATUS_QUEUE = "insights-bmi-status";
|
||||
|
||||
const BMI_STATUS_CRON = "20 2 * * *"; // daily at 02:20
|
||||
// v1.15.20 — mood joins the nightly per-metric status ladder. Same gate +
|
||||
// discovery as the six older crons (see status-cron-candidates.ts); 02:30
|
||||
// continues the 5-minute stagger after BMI (02:20) and compliance (02:25).
|
||||
|
||||
const MOOD_STATUS_QUEUE = "insights-mood-status";
|
||||
|
||||
const MOOD_STATUS_CRON = "30 2 * * *"; // daily at 02:30
|
||||
|
||||
const MEDICATION_COMPLIANCE_STATUS_QUEUE =
|
||||
"insights-medication-compliance-status";
|
||||
|
||||
const MEDICATION_COMPLIANCE_STATUS_CRON = "25 2 * * *"; // daily at 02:25
|
||||
|
||||
const allQueues = [
|
||||
GENERAL_STATUS_QUEUE,
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
PULSE_STATUS_QUEUE,
|
||||
BMI_STATUS_QUEUE,
|
||||
// v1.15.20 — mood joins the nightly status ladder. The queue MUST be
|
||||
// registered here or pg-boss never provisions it and the 02:30 schedule
|
||||
// silently no-ops (the v1.4.37 dead-queue class).
|
||||
MOOD_STATUS_QUEUE,
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation so the daily
|
||||
// briefing is warm before the user opens /insights or the dashboard
|
||||
// snapshot. Without this entry the 04:30 schedule silently no-ops.
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
// v1.8.3 — on-demand per-metric status generation. The read-only status
|
||||
// route enqueues here on a cold card; without this entry pg-boss never
|
||||
// provisions the queue and the enqueue silently drops. No cron schedule —
|
||||
// a send-only queue driven by navigation.
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
// v1.10.0 — computed scores (WX-C / WX-E). Nightly Recovery / Stress /
|
||||
// Strain compute + store. The queue MUST be registered here or pg-boss
|
||||
// never provisions it and the nightly schedule silently never fires.
|
||||
RECOVERY_SCORE_QUEUE,
|
||||
STRESS_SCORE_QUEUE,
|
||||
STRAIN_SCORE_QUEUE,
|
||||
// v1.11.0 — nightly period-narrative warm + single-user warm enqueued by
|
||||
// the read-only narrative GET.
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
// v1.11.1 — combined Coach memory-refresh (rolling conversation summary +
|
||||
// durable fact extraction), enqueued fire-and-forget from a long chat turn.
|
||||
COACH_MEMORY_REFRESH_QUEUE,
|
||||
// v1.15.20 — proactive Coach nudge. Without this entry the daily 05:15
|
||||
// schedule silently no-ops and no nudge ever fires.
|
||||
COACH_NUDGE_QUEUE,
|
||||
// v1.16.11 — daily medication low-stock pass. Without this entry the 09:00
|
||||
// schedule silently no-ops and no low-stock alert ever fires.
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
];
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, insightRetryOptions],
|
||||
[BLOOD_PRESSURE_STATUS_QUEUE, BLOOD_PRESSURE_STATUS_CRON, insightRetryOptions],
|
||||
[WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, insightRetryOptions],
|
||||
[PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, insightRetryOptions],
|
||||
[BMI_STATUS_QUEUE, BMI_STATUS_CRON, insightRetryOptions],
|
||||
// v1.15.20 — mood status nightly, continuing the 02:xx ladder.
|
||||
[MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, insightRetryOptions],
|
||||
[
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
MEDICATION_COMPLIANCE_STATUS_CRON,
|
||||
insightRetryOptions,
|
||||
],
|
||||
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
|
||||
// pre-generation. Budget-gated per user inside the handler.
|
||||
[INSIGHT_PREGENERATE_QUEUE, INSIGHT_PREGENERATE_CRON, insightRetryOptions],
|
||||
// v1.10.0 — computed scores (WX-C). Nightly 04:45 Europe/Berlin
|
||||
// Recovery-score compute + store, after the rollup-feeding consolidation
|
||||
// + drain so the signals it reads are already folded.
|
||||
[RECOVERY_SCORE_QUEUE, RECOVERY_SCORE_CRON],
|
||||
// v1.10.0 — computed scores (WX-E). Nightly 04:50 Europe/Berlin
|
||||
// Stress-score compute + store, after the dense intra-day retention
|
||||
// drain so the HRV inputs it reads are settled.
|
||||
[STRESS_SCORE_QUEUE, STRESS_SCORE_CRON],
|
||||
// v1.10.0 — computed scores (WX-E). Nightly 04:55 Europe/Berlin
|
||||
// Strain-score compute + store, after the recovery + stress passes so
|
||||
// the nightly score writes stay ordered.
|
||||
[STRAIN_SCORE_QUEUE, STRAIN_SCORE_CRON],
|
||||
// v1.11.0 — nightly 05:05 Europe/Berlin period-narrative warm. The
|
||||
// handler only fans out on a week (Mon) / month (1st) boundary; every
|
||||
// other night is a cheap no-op. Budget-gated per user inside the runner.
|
||||
[PERIOD_NARRATIVE_QUEUE, PERIOD_NARRATIVE_CRON, insightRetryOptions],
|
||||
// v1.15.20 — daily 05:15 Europe/Berlin proactive Coach nudge, after
|
||||
// the 04:45–04:55 score crons so the recovery-score trigger reads
|
||||
// settled rows. Deterministic triggers only — no AI call on this path.
|
||||
[COACH_NUDGE_QUEUE, COACH_NUDGE_CRON],
|
||||
// v1.16.11 — medication low-stock pass at 09:00 Europe/Berlin: a
|
||||
// supply alert is an errand prompt, so it fires at a time the user
|
||||
// can act on it. Once daily; the per-medication stamp keeps it at
|
||||
// one push per threshold crossing.
|
||||
[MEDICATION_LOW_STOCK_QUEUE, MEDICATION_LOW_STOCK_CRON],
|
||||
];
|
||||
|
||||
/**
|
||||
* Register every status / insight / computed-score queue. Returns the queue
|
||||
* names created (for the boot-level aggregate assertion).
|
||||
*/
|
||||
export async function registerStatusQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules);
|
||||
|
||||
await boss.work<GeneralStatusPayload>(
|
||||
GENERAL_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleGeneralStatusGenerate,
|
||||
);
|
||||
await boss.work<BloodPressureStatusPayload>(
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleBloodPressureStatusGenerate,
|
||||
);
|
||||
await boss.work<WeightStatusPayload>(
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWeightStatusGenerate,
|
||||
);
|
||||
await boss.work<PulseStatusPayload>(
|
||||
PULSE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handlePulseStatusGenerate,
|
||||
);
|
||||
await boss.work<BmiStatusPayload>(
|
||||
BMI_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleBmiStatusGenerate,
|
||||
);
|
||||
await boss.work<MoodStatusPayload>(
|
||||
MOOD_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodStatusGenerate,
|
||||
);
|
||||
await boss.work<MedicationComplianceStatusPayload>(
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMedicationComplianceStatusGenerate,
|
||||
);
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation.
|
||||
//
|
||||
// v1.16.8 — localConcurrency 2 (was 1). The queue carries BOTH the
|
||||
// scheduled 04:30 cohort walk AND the visit-triggered per-user force
|
||||
// warms; with a single slot a force warm enqueued during the nightly
|
||||
// batch sat behind the entire cohort and the visiting user stared at
|
||||
// a cold dashboard for the duration. Two slots let one force warm run
|
||||
// alongside the batch while still bounding provider-level concurrency
|
||||
// (the cohort walk is itself sequential per user, and the content-hash
|
||||
// gate + per-user budget gate inside the handler cover the rare
|
||||
// double-tick overlap the old single slot serialised away).
|
||||
await boss.work<InsightPregeneratePayload>(
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
{ localConcurrency: 2 },
|
||||
handleInsightPregenerateJob,
|
||||
);
|
||||
// v1.8.3 — on-demand per-metric status generation enqueued by the
|
||||
// read-only status route on a cold card. Low concurrency so a first
|
||||
// visit that cold-misses several cards can't saturate the Prisma pool
|
||||
// or fan out an unbounded number of concurrent provider calls.
|
||||
await boss.work<InsightStatusGeneratePayload>(
|
||||
INSIGHT_STATUS_GENERATE_QUEUE,
|
||||
{ localConcurrency: INSIGHT_STATUS_GENERATE_CONCURRENCY },
|
||||
handleInsightStatusGenerate,
|
||||
);
|
||||
// v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute +
|
||||
// store. The cron tick carries an empty payload; the runner iterates every
|
||||
// eligible user and upserts one `COMPUTED RECOVERY_SCORE` row per scored
|
||||
// day (idempotent — a re-fire overwrites in place). Single-flight so two
|
||||
// ticks never double-walk the cohort.
|
||||
await boss.work(RECOVERY_SCORE_QUEUE, { localConcurrency: 1 }, async () => {
|
||||
try {
|
||||
const summary = await runRecoveryScore(getWorkerPrisma());
|
||||
workerLog(
|
||||
"info",
|
||||
`[recovery-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[recovery-score] pass failed", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// v1.15.20 — proactive Coach nudge. Single-flight; the push-attempts
|
||||
// ledger caps a user at one nudge per rolling week, so an overlapping
|
||||
// tick would only waste reads. Deterministic triggers, no AI call.
|
||||
await boss.work(COACH_NUDGE_QUEUE, { localConcurrency: 1 }, async () => {
|
||||
await withBackgroundEvent("job.coach_nudge", async (evt) => {
|
||||
try {
|
||||
const summary = await runCoachNudgeTick(getWorkerPrisma(), new Date());
|
||||
evt.setBackground({
|
||||
task_name: "job.coach_nudge",
|
||||
result: {
|
||||
candidates_scanned: summary.candidatesScanned,
|
||||
dispatched: summary.dispatched,
|
||||
skipped_opted_out: summary.skippedOptedOut,
|
||||
skipped_no_provider: summary.skippedNoProvider,
|
||||
skipped_recent_nudge: summary.skippedRecentNudge,
|
||||
skipped_no_trigger: summary.skippedNoTrigger,
|
||||
skipped_no_channel: summary.skippedNoChannel,
|
||||
failed: summary.failed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
});
|
||||
// v1.16.11 — medication low-stock pass. Single-flight; the
|
||||
// per-medication stamp makes a re-fire idempotent (already-notified
|
||||
// crossings skip), so an overlapping tick would only waste reads.
|
||||
await boss.work(
|
||||
MEDICATION_LOW_STOCK_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async () => {
|
||||
await withBackgroundEvent("job.medication_low_stock", async (evt) => {
|
||||
try {
|
||||
const summary = await runMedicationLowStockTick(
|
||||
getWorkerPrisma(),
|
||||
new Date(),
|
||||
);
|
||||
evt.setBackground({
|
||||
task_name: "job.medication_low_stock",
|
||||
result: {
|
||||
users_scanned: summary.usersScanned,
|
||||
skipped_threshold_off: summary.skippedThresholdOff,
|
||||
medications_evaluated: summary.medicationsEvaluated,
|
||||
notified: summary.notified,
|
||||
rearmed: summary.rearmed,
|
||||
skipped_already_notified: summary.skippedAlreadyNotified,
|
||||
skipped_above_threshold: summary.skippedAboveThreshold,
|
||||
skipped_no_runway: summary.skippedNoRunway,
|
||||
skipped_no_channel: summary.skippedNoChannel,
|
||||
failed: summary.failed,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
// v1.10.0 — computed scores (WX-E). Nightly Stress-score (HRV-derived
|
||||
// proxy) compute + store. Single-flight so two ticks never double-walk
|
||||
// the cohort. The runner iterates every eligible user and upserts one
|
||||
// `COMPUTED STRESS_SCORE` row per scored day (idempotent — a re-fire
|
||||
// overwrites in place).
|
||||
await boss.work(STRESS_SCORE_QUEUE, { localConcurrency: 1 }, async () => {
|
||||
try {
|
||||
const summary = await runStressScore(getWorkerPrisma());
|
||||
workerLog(
|
||||
"info",
|
||||
`[stress-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[stress-score] pass failed", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// v1.10.0 — computed scores (WX-E). Nightly Strain-score (Banister TRIMP
|
||||
// cardio-load) compute + store. Single-flight; upserts one `COMPUTED
|
||||
// STRAIN_SCORE` row per scored day (idempotent).
|
||||
await boss.work(STRAIN_SCORE_QUEUE, { localConcurrency: 1 }, async () => {
|
||||
try {
|
||||
const summary = await runStrainScore(getWorkerPrisma());
|
||||
workerLog(
|
||||
"info",
|
||||
`[strain-score] considered=${summary.considered} stored=${summary.stored} insufficient=${summary.insufficient} errored=${summary.errored}`,
|
||||
);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[strain-score] pass failed", err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
// v1.11.0 — period-narrative warm. A scheduled tick (no `userId`) runs the
|
||||
// boundary-gated nightly fan-out; a `userId` payload runs a single-user warm
|
||||
// enqueued by the read-only GET on a cold/stale read. Single-flight so two
|
||||
// ticks never double-walk the cohort; the per-user budget gate covers the
|
||||
// fan-out and the enqueue `singletonKey` covers the single-user path.
|
||||
await boss.work<PeriodNarrativePayload>(
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.data?.userId) {
|
||||
await warmOneNarrative(job.data);
|
||||
} else {
|
||||
const summary = await runPeriodNarrativeWarm(getWorkerPrisma());
|
||||
workerLog(
|
||||
"info",
|
||||
`[period-narrative] periods=${summary.periods.join(",") || "none"} total=${summary.total} generated=${summary.generated} cached=${summary.cached} skipped=${summary.skipped} insufficient=${summary.insufficient} failed=${summary.failed} budget=${summary.budgetBlocked}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, {
|
||||
mode: job.data?.userId ? "single-user" : "scheduled",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
// v1.11.1 — combined Coach memory refresh: rolling conversation summary +
|
||||
// durable fact extraction for one long conversation. localConcurrency 1 so a
|
||||
// burst of long-conversation turns can't fan out concurrent provider calls;
|
||||
// each step is budget-gated inside runStatusCompletion and fault-isolated.
|
||||
await boss.work<CoachMemoryRefreshPayload>(
|
||||
COACH_MEMORY_REFRESH_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
async (jobs) => {
|
||||
for (const job of jobs) {
|
||||
if (!job.data?.conversationId || !job.data?.userId) continue;
|
||||
try {
|
||||
await runCoachMemoryRefresh(job.data);
|
||||
} catch (err) {
|
||||
recordError();
|
||||
workerLog("error", "[coach-memory-refresh] failed", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Shared plumbing for the domain-grouped queue registrars.
|
||||
*
|
||||
* v1.18.1 — `startReminderWorker()` was a 2143-LOC monolith that declared
|
||||
* every pg-boss queue name, listed them in one `allQueues` array, scheduled
|
||||
* every cron, and bound every `boss.work` handler inline. It is now decomposed
|
||||
* into domain registrars (integration-sync, status, rollup, reminders,
|
||||
* maintenance) under `src/lib/jobs/reminder/`. Each registrar owns the four
|
||||
* facts the v1.4.37 dead-queue guards pin — the queue-name constant, its
|
||||
* `allQueues` membership, its `[QUEUE, CRON]` schedule tuple, and its
|
||||
* `boss.work(QUEUE, …, handler)` binding — so a queue can never be declared
|
||||
* without being provisioned, scheduled, and drained. The boot file composes
|
||||
* the registrars; the guards follow the wiring into each registrar module.
|
||||
*
|
||||
* Every registrar returns the queue names it created so the boot file can
|
||||
* assert a single aggregate `allQueues` (defence in depth: the per-registrar
|
||||
* arrays AND the boot-level union both have to agree).
|
||||
*/
|
||||
import { PgBoss } from "pg-boss";
|
||||
|
||||
/**
|
||||
* Cron schedule tuple: `[queueName, cronExpression, sendOptions?]`. The
|
||||
* optional third element carries per-queue send options (e.g. the LLM-bound
|
||||
* insight retry policy) merged into the `boss.schedule` call.
|
||||
*/
|
||||
export type ScheduleEntry = [string, string, Record<string, unknown>?];
|
||||
|
||||
/**
|
||||
* Shared retry policy for the LLM-bound insight queues. A transient failure
|
||||
* (provider hiccup, pool exhaustion) used to fail the nightly tick silently
|
||||
* until the NEXT night; three backed-off retries match the backfill queues'
|
||||
* established shape (see e.g. whoop-backfill.ts).
|
||||
*/
|
||||
export const insightRetryOptions = {
|
||||
retryLimit: 3,
|
||||
retryDelay: 60,
|
||||
retryBackoff: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Create every queue in `queues`, then schedule every cron in `schedules`.
|
||||
* Centralised so each registrar provisions before it schedules in the exact
|
||||
* order the monolith did, and the `Europe/Berlin` tz default stays in one
|
||||
* place.
|
||||
*/
|
||||
export async function createAndSchedule(
|
||||
boss: PgBoss,
|
||||
queues: readonly string[],
|
||||
schedules: readonly ScheduleEntry[],
|
||||
): Promise<void> {
|
||||
for (const q of queues) {
|
||||
await boss.createQueue(q);
|
||||
}
|
||||
for (const [name, cron, sendOptions] of schedules) {
|
||||
await boss.schedule(
|
||||
name,
|
||||
cron,
|
||||
{},
|
||||
{
|
||||
tz: "Europe/Berlin",
|
||||
...(sendOptions ?? {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user