diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ca49212..69e0146c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [1.10.0.2] — 2026-06-03 — Disable the intra-day retention drain + +### Fixed + +- **Stops the post-deploy restart loop for good.** The intra-day retention drain (a storage optimisation behind the new stress reading) folded older samples into a daily summary, but on real data that summary collided with an existing daily row and the drain failed on every run, which kept the app cycling after a deploy. The drain is now switched off until it is reworked and tested against real data; the nightly pass is skipped, nothing is queued at start-up, and any work already queued completes harmlessly. No user-facing feature is lost — the stress reading simply falls back to its sparse-data behaviour. + ## [1.10.0.1] — 2026-06-03 — Defer the intra-day retention drain past startup ### Fixed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 243d4be12..e5c3c41c7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.10.0.1 + version: 1.10.0.2 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 44da40e57..419ce0580 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.10.0.1", + "version": "1.10.0.2", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "AGPL-3.0-only", "homepage": "https://healthlog.dev", diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index f39d7a2ca..34a6b58a5 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -61,6 +61,22 @@ export const DENSE_INTRADAY_RETENTION_CRON = "50 3 * * *"; */ export const DENSE_INTRADAY_RETENTION_BOOT_DELAY_SECONDS = 600; +/** + * Kill-switch, default OFF. The drain folds out-of-window per-sample rows + * into one daily-mean `stats:` row, but its canonical-timestamp upsert + * collides with an already-present daily row on the + * `(user_id, type, measured_at, source, sleep_stage)` unique constraint + * (`P2002`) on real data — a fold-coexistence bug the mocked unit tests + * could not surface. Until the fold is reworked + integration-tested + * against real rows, the drain is disabled: the boot-discovery enqueues + * nothing, the nightly walk is skipped, and the per-user handler no-ops so + * any already-queued backlog completes harmlessly instead of erroring. + * Re-enable with `DENSE_INTRADAY_RETENTION_ENABLED=true` once fixed. The + * Stress proxy degrades gracefully on sparse intra-day data meanwhile. + */ +export const DENSE_INTRADAY_RETENTION_ENABLED = + process.env.DENSE_INTRADAY_RETENTION_ENABLED === "true"; + export interface DenseIntradayRetentionPayload { userId: string; enqueuedAt: string; @@ -73,6 +89,11 @@ export interface DenseIntradayRetentionPayload { export async function runDenseIntradayRetentionForUser( userId: string, ): Promise<{ daysConsolidated: number; perSampleRowsSoftDeleted: number }> { + // Kill-switch: no-op so any already-queued backlog completes cleanly + // (drains the queue) instead of erroring on the P2002 fold collision. + if (!DENSE_INTRADAY_RETENTION_ENABLED) { + return { daysConsolidated: 0, perSampleRowsSoftDeleted: 0 }; + } const summary = await runDenseIntradayRetention(prisma, { userId, log: () => { @@ -114,6 +135,10 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ skipped: number; error: string | null; }> { + // Kill-switch: enqueue nothing while the drain is disabled. + if (!DENSE_INTRADAY_RETENTION_ENABLED) { + return { enqueued: 0, skipped: 0, error: null }; + } const boss = getGlobalBoss(); if (!boss) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index 86bf7ebb1..45a379751 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -88,6 +88,7 @@ import { 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"; @@ -2820,17 +2821,24 @@ export async function startReminderWorker() { // accounts that accumulated out-of-window raw rows before this // shipped. try { - 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}`, - ); + if (!DENSE_INTRADAY_RETENTION_ENABLED) { + workerLog( + "info", + "[dense-intraday-retention] disabled — skipping the nightly walk (P2002 fold collision; re-enable via DENSE_INTRADAY_RETENTION_ENABLED once reworked)", + ); + } 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(); workerLog(