Files
HealthLog/src/lib/jobs/queue-runtime.ts
T
Marc-André Bombeck bce9ad1c25 feat(oura): backfill the full history in resumable slices
Every Oura collection, workouts and heart rate included, is now read
back to the start of the account's history by a backfill that runs
through the shared integration lane in five-minute slices. Each
collection walks from the day the backfill began toward the past one
window at a time (ninety days, thirty for sleep, seven for heart rate)
and the progress note on the user row records after every window where
it got to, so a slice cut short by its budget, the lane or a rate limit
resumes there. A 429 ends the slice and the next one waits for the
Retry-After Oura named. Activity and sleep are walked first to learn
where history starts; the rest stop a week before it.

Heart rate streams a page at a time and keeps only the days it touched,
so memory stays flat however long the history is. A collection the
grant does not cover is a scope note, not a failure; the first slice of
the next chain asks again, and a connect clears the completion stamp so
a newly granted scope is read without walking anything twice.

The two columns arrive empty, so every existing connection is offered
the backfill at the next boot without reconnecting. Before anything
else the backfill retires the WALKING_RUNNING_DISTANCE rows earlier
syncs wrote from the equivalent walking distance and refolds their days;
an account that kept such rows after disconnecting is offered too.
2026-10-21 19:53:57 +02:00

507 lines
22 KiB
TypeScript

/**
* How long one job of each queue can run, and what keeps a long one safe.
*
* pg-boss gives every job an expiry, fifteen minutes unless the send says
* otherwise. Past it pg-boss fails the job, fires its abort signal and retries
* it, and it does not stop the handler, so a job that legitimately needs longer
* ends up running twice beside itself. No queue policy prevents that: the
* policies constrain job rows, and an expired row is no longer active. Every
* queue bound with `createAndWork` is therefore declared here as one of:
*
* - `short`: a job cannot come near fifteen minutes (one account's bounded
* window, one document, a forwarder, a bounded delete). It relies on the
* default expiry, and the reason says why that is enough.
* - `budgeted`: a pass over a long history that stops itself at three
* quarters of the default expiry (`jobBudget`) and hands the rest to its
* next run, so it ends before fifteen minutes by construction.
* - `long`: a job that may run for hours. It carries an explicit
* `expireInSeconds` on every send and schedule, stops itself before that
* expiry (`jobBudget` / `jobDeadline`), and holds something per identity
* that keeps a second run from working beside it: the lock `lockedPass`
* takes, a lock of its own, or a row it claims. The one exception is a
* one-shot job with `retryLimit: 0`, which pg-boss never retries and which
* cannot stop half-way without losing its input.
*
* `src/__tests__/job-queue-runtime-guard.test.ts` holds every binding, send
* and schedule in the tree to this table.
*/
import { INTEGRATION_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/integration-backfill-admission";
import { NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS } from "@/lib/jobs/reminder/registrar-shared";
import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared";
import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared";
import { PR_DETECTION_EXPIRE_SECONDS } from "@/lib/jobs/pr-detection";
import { ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS } from "@/lib/jobs/encryption-key-rotate";
import { CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS } from "@/lib/jobs/document-content-index-backfill";
import { DOCUMENT_AI_RUN_EXPIRE_SECONDS } from "@/lib/jobs/document-ai-run";
import { DATA_BACKUP_SEND_OPTIONS } from "@/lib/jobs/data-backup-policy";
import { OFFHOST_BACKUP_EXPIRE_SECONDS } from "@/lib/jobs/offhost-backup";
import { BACKUP_RESTORE_EXPIRE_SECONDS } from "@/lib/jobs/backup-restore";
import { RESTORE_DRILL_EXPIRE_SECONDS } from "@/lib/jobs/restore-drill";
import { APPLE_HEALTH_IMPORT_SEND_OPTIONS } from "@/lib/jobs/apple-health-import-worker";
import { MEDICATION_INTAKE_IMPORT_SEND_OPTIONS } from "@/lib/jobs/medication-intake-import";
import { HEALTH_CONNECT_IMPORT_SEND_OPTIONS } from "@/lib/jobs/health-connect-import-worker";
import { MEASUREMENT_MAINTENANCE_SEND_OPTIONS } from "@/lib/jobs/measurement-maintenance";
import { PULSE_DAY_MEAN_SEND_OPTIONS } from "@/lib/jobs/pulse-day-mean";
import { ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS } from "@/lib/rollups/measurement-rollups";
/** pg-boss's default job expiry, in seconds. */
export const DEFAULT_JOB_EXPIRE_SECONDS = 15 * 60;
/** Where a check lives: `binding` is the `createAndWork` call itself. */
export type SourceSite = "binding" | { file: string; fn: string };
export type QueueRuntime =
| { runtime: "short"; why: string }
| { runtime: "budgeted"; budget: SourceSite; why: string }
| {
runtime: "long";
expireInSeconds: number;
/**
* Identifiers that carry the expiry into a send or a schedule. A site
* that names none of them must set `expireInSeconds` itself.
*/
expiryVia: readonly string[];
/** Where the handler stops on the job's budget, or `oneShot`. */
stop: SourceSite | { oneShot: string };
/**
* What keeps a second run from working beside this one: `lockedPass`
* at the binding, or a marker in the named function.
*/
exclusive: "lockedPass" | { marker: string; at: SourceSite };
why: string;
};
const short = (why: string): QueueRuntime => ({ runtime: "short", why });
const cohortSync = (provider: string): QueueRuntime =>
short(
`${provider} sync: a recent window per connection (incremental since the last sync); a webhook job is one connection.`,
);
const oauthStateCleanup = short("One DELETE of expired OAuth state rows.");
const forwarder = short(
"Forwards each job to integration-backfill-admission; the import itself runs there.",
);
export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
// ── Integration sync ───────────────────────────────────────────────
"integration-backfill-admission": {
runtime: "long",
expireInSeconds: INTEGRATION_BACKFILL_EXPIRE_SECONDS,
expiryVia: ["integrationBackfillAdmissionSendOptions"],
stop: {
file: "lib/jobs/integration-backfill-drain.ts",
fn: "drainIntegrationBackfillAdmission",
},
exclusive: {
marker: "withIntegrationBackfillLock",
at: {
file: "lib/jobs/integration-backfill-drain.ts",
fn: "drainIntegrationBackfillAdmission",
},
},
why: "Full-history provider imports: thousands of pages on a dense account.",
},
"withings-fallback-sync": cohortSync("Withings"),
"withings-activity-sync": cohortSync("Withings activity"),
"withings-sleep-sync": cohortSync("Withings sleep"),
"withings-ecg-sync": cohortSync("Withings ECG"),
"withings-oauth-state-cleanup": oauthStateCleanup,
"whoop-recovery-sync": cohortSync("WHOOP recovery"),
"whoop-sleep-sync": cohortSync("WHOOP sleep"),
"whoop-workout-sync": cohortSync("WHOOP workout"),
"whoop-cycle-sync": cohortSync("WHOOP cycle"),
"whoop-backfill": forwarder,
"whoop-oauth-state-cleanup": oauthStateCleanup,
"oidc-native-handoff-cleanup": short("One DELETE of expired handoff rows."),
"fitbit-sync": cohortSync("Fitbit"),
"fitbit-backfill": forwarder,
"fitbit-sleep-repair": forwarder,
"fitbit-oauth-state-cleanup": oauthStateCleanup,
"google-health-sync": cohortSync("Google Health"),
"google-health-backfill": forwarder,
"google-health-sleep-repair": forwarder,
"google-health-oauth-state-cleanup": oauthStateCleanup,
"sleep-timeline-backfill": forwarder,
"lab-biomarker-backfill": forwarder,
"nightscout-sync": cohortSync("Nightscout"),
"polar-sync": cohortSync("Polar"),
"oura-sync": cohortSync("Oura"),
"strava-sync": cohortSync("Strava"),
"strava-backfill": forwarder,
"oura-backfill": forwarder,
// ── Status and insights ────────────────────────────────────────────
"insights-general-status": {
runtime: "long",
expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS,
expiryVia: ["nightlyInsightPassOptions"],
stop: {
file: "lib/jobs/reminder/insights-handlers.ts",
fn: "handleGeneralStatusGenerate",
},
exclusive: "lockedPass",
why: "Nightly status batch: one provider call per candidate account, serially, over the whole instance.",
},
"insight-pregenerate": {
runtime: "long",
expireInSeconds: INSIGHT_PREGENERATE_EXPIRE_SECONDS,
expiryVia: ["INSIGHT_PREGENERATE_EXPIRE_SECONDS"],
stop: {
file: "lib/jobs/reminder/insights-handlers.ts",
fn: "handleInsightPregenerateJob",
},
exclusive: "lockedPass",
why: "Up to 200 accounts, a comprehensive generation each (a provider call bounded by the account's timeout, up to ten minutes) plus the status warm.",
},
"morning-digest-refresh": short(
"One account, one comprehensive generation, bounded by the provider timeout.",
),
"insight-status-generate": short(
"One account, one metric, one provider call.",
),
"data-arrival": short("One arrival's reactions."),
"reaction-line-generate": short("One provider call for one reaction line."),
"workout-insight-generate": short("One workout's insight."),
"recovery-score-compute": short(
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
),
"stress-score-compute": short(
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
),
"strain-score-compute": short(
"Arithmetic over a recent window, capped at 500 accounts; no provider call.",
),
"period-narrative-warm": {
runtime: "long",
expireInSeconds: PERIOD_NARRATIVE_EXPIRE_SECONDS,
expiryVia: ["PERIOD_NARRATIVE_EXPIRE_SECONDS"],
stop: "binding",
exclusive: "lockedPass",
why: "On a boundary night up to 200 accounts with a provider call per period each; a single-account warm is short.",
},
"coach-memory-refresh": short("One conversation's summary and facts."),
"coach-nudge": short(
"Light deterministic checks per account; the provider part is capped at 25 calls and 90 seconds a tick.",
),
"coach-reminder-sweep": short("Batches of 200 to 500 rows, no provider."),
"coach-plan-review": short("A batch of 100 plans, folded in SQL."),
"medication-low-stock": short("A light inventory check per account."),
"daily-briefing": short("A light cohort scan every fifteen minutes."),
// ── Rollups ────────────────────────────────────────────────────────
"rollup-recompute": short(
"One type at one granularity over one bucket or a run of adjacent buckets, inside the five-year fold window.",
),
"rollup-full-backfill": {
runtime: "long",
expireInSeconds: ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS,
expiryVia: ["ROLLUP_FULL_BACKFILL_EXPIRE_SECONDS"],
stop: "binding",
exclusive: "lockedPass",
why: "One account's five-year fold: four aggregates under the statement timeout and four persist transactions of up to 120 s each, about thirteen minutes at worst under the defaults and unbounded when the operator lifts the statement timeout.",
},
"mood-rollup-recompute": short("One bucket."),
"mood-rollup-full-backfill": short(
"One account's mood entries, a few a day.",
),
"step-consolidation": short(
"One account's legacy step rows, one transaction per day.",
),
"step-consolidation-repair": short(
"One account's tombstoned step rows inside the retention window.",
),
"supplement-nutrient-recompute": short(
"One account's taken doses of medications with a composition: a few thousand rows for years of a daily supplement.",
),
"cumulative-pr-rederive": short(
"One account's record detection; measured at 20 seconds for 1.25 million rows.",
),
"mean-consolidation": {
runtime: "budgeted",
budget: "binding",
why: "One account's whole history, a day at a time; stops at three quarters of the default expiry and resumes.",
},
"dense-intraday-retention": {
runtime: "budgeted",
budget: "binding",
why: "A dense account's history, a day at a time; stops at three quarters of the default expiry and resumes.",
},
"dense-intraday-hourly-rebuild": {
runtime: "budgeted",
budget: "binding",
why: "A dense account's hourly rows, a day at a time; stops at three quarters of the default expiry and resumes.",
},
"drain-per-sample-cumulative": {
runtime: "budgeted",
budget: "binding",
why: "Every account's per-sample drains; stops at three quarters of the default expiry and hands the rest to a continuation job.",
},
// ── Reminders ──────────────────────────────────────────────────────
"medication-reminder-check": short("The due slots of the current minute."),
"mood-reminder-check": short("Accounts whose local reminder hour is now."),
"cycle-reminder-check": short("Accounts whose local reminder hour is now."),
"measurement-reminder-check": short(
"Accounts whose local reminder hour is now.",
),
"reminder-satisfy": short("One account's open reminders."),
// ── Maintenance ────────────────────────────────────────────────────
"data-backup": {
runtime: "long",
expireInSeconds: DATA_BACKUP_SEND_OPTIONS.expireInSeconds,
expiryVia: ["DATA_BACKUP_SEND_OPTIONS"],
stop: {
file: "lib/jobs/reminder/backup-handlers.ts",
fn: "handleDataBackup",
},
exclusive: "lockedPass",
why: "Serialises every account's whole record.",
},
"data-backup-offhost": {
runtime: "long",
expireInSeconds: OFFHOST_BACKUP_EXPIRE_SECONDS,
expiryVia: ["OFFHOST_BACKUP_SEND_OPTIONS"],
stop: {
file: "lib/jobs/reminder/backup-handlers.ts",
fn: "handleOffhostBackup",
},
exclusive: "lockedPass",
why: "Uploads every account's record off-host.",
},
"data-restore-drill": {
runtime: "long",
expireInSeconds: RESTORE_DRILL_EXPIRE_SECONDS,
expiryVia: ["RESTORE_DRILL_SEND_OPTIONS"],
stop: { file: "lib/jobs/restore-drill.ts", fn: "handleRestoreDrill" },
exclusive: "lockedPass",
why: "Fetches and stream-parses every account's newest off-host object.",
},
"offhost-backup-purge": short("At most 200 delete requests per run."),
"backup-restore": {
runtime: "long",
expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS,
expiryVia: ["BACKUP_RESTORE_SEND_OPTIONS", "BACKUP_RESTORE_EXPIRE_SECONDS"],
stop: { file: "lib/jobs/backup-restore.ts", fn: "handleBackupRestore" },
exclusive: {
marker: "restore_claimed",
at: { file: "lib/jobs/backup-restore.ts", fn: "runBackupRestoreJob" },
},
why: "Restores a whole account in one transaction.",
},
"host-metric-sample": short("One host sample."),
"feedback-aggregator": short("One aggregate over the feedback table."),
"geo-backfill": short("At most 500 audit rows per pass."),
"geolite2-fetch": short("One database download."),
"tls-pin-monitor": short("One TLS handshake."),
"integration-watchdog-hourly": short(
"A few indexed reads per account with a connection and no provider call; a 14-day history count only for a silent source.",
),
"integration-watchdog-daily": short(
"The hourly pass plus one 14-day history count per standing silence flag.",
),
"mood-reminder-cleanup": short("One retention DELETE."),
"push-attempt-cleanup": short("One retention DELETE."),
"arrival-reaction-cleanup": short("One retention DELETE."),
"rate-limit-cleanup": short("One retention DELETE."),
"idempotency-cleanup": short("One retention DELETE."),
"step-up-elevation-cleanup": short("One retention DELETE."),
"coach-message-cleanup": short("One retention DELETE."),
"workout-insight-claim-cleanup": short("One retention DELETE."),
"mcp-token-cleanup": short("One retention DELETE."),
"audit-log-cleanup": short("At most 40 batches of 5 000 rows per leg."),
"measurement-tombstone-cleanup": short(
"At most 40 batches of 5 000 rows per leg.",
),
"document-tombstone-purge": short("One deleteMany."),
"soft-delete-purge": short(
"At most 40 batches of 5 000 rows per table, over a few small tables.",
),
"auth-ledger-cleanup": short("At most 40 batches of 5 000 rows per table."),
"document-summary-reaper": short("One updateMany."),
"cycle-prediction-refresh": short(
"Arithmetic per eligible account, no provider call.",
),
"mood-prognosis-refresh": short(
"Arithmetic per eligible account, no provider call.",
),
"achievement-unlock-sweep": short(
"A bounded achievement read per account, no provider call.",
),
"pr-detection": {
runtime: "long",
expireInSeconds: PR_DETECTION_EXPIRE_SECONDS,
expiryVia: ["PR_DETECTION_EXPIRE_SECONDS"],
stop: {
file: "lib/jobs/reminder/ops-handlers.ts",
fn: "handlePrDetection",
},
exclusive: "lockedPass",
why: "The fallback cron scans every account's all-time history every thirty minutes; an ingest job is one account.",
},
"medication-inventory-expire": short("One updateMany."),
"intake-auto-skip": short("One updateMany."),
"intake-auto-take": short(
"At most seven days of slots per opted-in medication, a few rows each.",
),
"apple-health-import-v2": {
runtime: "long",
expireInSeconds: APPLE_HEALTH_IMPORT_SEND_OPTIONS.expireInSeconds,
expiryVia: ["APPLE_HEALTH_IMPORT_SEND_OPTIONS"],
stop: {
oneShot:
"The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.",
},
exclusive: {
marker: "retryLimit: 0",
at: {
file: "lib/jobs/apple-health-import-worker.ts",
fn: "APPLE_HEALTH_IMPORT_SEND_OPTIONS",
},
},
why: "Parses an export that can be several gigabytes.",
},
"apple-health-import": short(
"The legacy queue: moves each job onto apple-health-import-v2.",
),
"apple-health-import-reconcile": short("One updateMany."),
"health-connect-import": {
runtime: "long",
expireInSeconds: HEALTH_CONNECT_IMPORT_SEND_OPTIONS.expireInSeconds,
expiryVia: ["HEALTH_CONNECT_IMPORT_SEND_OPTIONS"],
stop: {
oneShot:
"The run consumes and unlinks its staged upload, so it can neither stop half-way nor be retried; the send sets retryLimit 0.",
},
exclusive: {
marker: "retryLimit: 0",
at: {
file: "lib/jobs/health-connect-import-worker.ts",
fn: "HEALTH_CONNECT_IMPORT_SEND_OPTIONS",
},
},
why: "Reads a Health Connect export database that can hold years of minute-level samples.",
},
"compaction-tombstone-purge": short(
"At most 40 batches of 5 000 rows per run; the predicate is idempotent, so the next run resumes.",
),
"measurement-fold-repair": {
runtime: "budgeted",
budget: {
file: "lib/jobs/measurement-fold-repair.ts",
fn: "handleMeasurementFoldRepair",
},
why: "One account's folded means, a day at a time; stops at three quarters of the default expiry and sends a follow-up that resumes at the next day.",
},
"measurement-maintenance": {
runtime: "long",
expireInSeconds: MEASUREMENT_MAINTENANCE_SEND_OPTIONS.expireInSeconds,
expiryVia: ["MEASUREMENT_MAINTENANCE_SEND_OPTIONS"],
stop: {
oneShot:
"VACUUM and REINDEX CONCURRENTLY cannot be resumed mid-statement; the send and the nightly tick set retryLimit 0, and the next night or the operator runs it again.",
},
exclusive: {
marker: "retryLimit: 0",
at: {
file: "lib/jobs/measurement-maintenance.ts",
fn: "MEASUREMENT_MAINTENANCE_SEND_OPTIONS",
},
},
why: "The operator's run rebuilds every index of the largest table, one at a time; the nightly check is one VACUUM at most.",
},
"pulse-day-mean": {
runtime: "long",
expireInSeconds: PULSE_DAY_MEAN_SEND_OPTIONS.expireInSeconds,
expiryVia: ["PULSE_DAY_MEAN_SEND_OPTIONS"],
stop: {
oneShot:
"A dry run or rebuild that stops part way is started again by the operator; the send sets retryLimit 0.",
},
exclusive: {
marker: "retryLimit: 0",
at: {
file: "lib/jobs/pulse-day-mean.ts",
fn: "PULSE_DAY_MEAN_SEND_OPTIONS",
},
},
why: "Walks every account's pulse history a window at a time, twice over for a dry run.",
},
"medication-intake-import": {
runtime: "long",
expireInSeconds: MEDICATION_INTAKE_IMPORT_SEND_OPTIONS.expireInSeconds,
expiryVia: ["MEDICATION_INTAKE_IMPORT_SEND_OPTIONS"],
stop: {
file: "lib/jobs/medication-intake-import.ts",
fn: "handleMedicationIntakeImport",
},
exclusive: {
marker: "FOR UPDATE",
at: {
file: "lib/jobs/medication-intake-import.ts",
fn: "processNextChunk",
},
},
why: "Imports a dose-history file chunk by chunk; its length follows the file.",
},
"intake-slot-dedup": short("One account's duplicate dose slots."),
"note-encryption-backfill": short(
"Pages of 200 rows of one account's plaintext notes, light per row.",
),
"med-notes-encryption-backfill": short(
"Pages of 200 rows of one account's plaintext notes, light per row.",
),
"free-text-encryption-backfill": short(
"Pages of 200 rows of one account's plaintext fields, light per row.",
),
"encryption-key-rotate": {
runtime: "long",
expireInSeconds: ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS,
expiryVia: ["ENCRYPTION_KEY_ROTATE_EXPIRE_SECONDS"],
stop: {
file: "lib/jobs/encryption-key-rotate.ts",
fn: "handleEncryptionKeyRotate",
},
exclusive: "lockedPass",
why: "Re-encrypts every encrypted column on the instance.",
},
"document-content-index-backfill": {
runtime: "long",
expireInSeconds: CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS,
expiryVia: ["CONTENT_INDEX_BACKFILL_EXPIRE_SECONDS"],
stop: "binding",
exclusive: "lockedPass",
why: "Up to 200 documents of one account, a provider transcription each.",
},
"document-index": short("One document."),
"document-ai-run": {
runtime: "long",
expireInSeconds: DOCUMENT_AI_RUN_EXPIRE_SECONDS,
expiryVia: ["DOCUMENT_AI_RUN_EXPIRE_SECONDS"],
stop: {
file: "lib/jobs/document-ai-run.ts",
fn: "handleDocumentAiRunJobs",
},
exclusive: "lockedPass",
why: "One person's document or lab scan, up to three model calls at their AI response time (up to ten minutes each).",
},
"document-ai-run-reaper": short(
"Up to 500 conditional updates and one deleteMany.",
),
"document-thumbnail": short("One document."),
"document-summary": short("One document."),
"document-summary-catchup": short("Enqueues at most 200 documents."),
"document-thumbnail-backfill": short("Enqueues at most 1 000 documents."),
"environment-fetch": short(
"A fan-out of sends, or one account's bounded archive range.",
),
"dawarich-sync": short(
"A fan-out of sends, or one account's range: one timeline request per 30 days (at most 13), one visits request and at most two residency requests.",
),
"environment-aq-history": short(
"A fan-out of sends, or one account's history run: at most eight ranges of up to 90 days, two requests each, then a follow-up.",
),
};