mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'fix/v1437-jobs' into release/v1.43.7
This commit is contained in:
commit
ea933b91cc
59 files changed
+2340
-855
No files matched your search
@@ -76535,6 +76535,17 @@ components:
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
lastEnteredHereAt:
|
||||
description: "v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood
|
||||
glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the
|
||||
type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the
|
||||
phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both
|
||||
this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`."
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
required:
|
||||
- type
|
||||
- lastSeenAt
|
||||
|
||||
@@ -137,7 +137,8 @@ it.
|
||||
## Reading the result
|
||||
|
||||
The run's wide event `job.measurement_maintenance` carries
|
||||
`maintenance_outcome` (`completed`, `refused_purge_running`, `failed`), the
|
||||
`maintenance_outcome` (`completed`, `nothing_due`, `refused_purge_running`,
|
||||
`failed`), the live and dead rows the run started from, the
|
||||
table and index sizes before and after, and `maintenance_steps`, one entry
|
||||
per step with its target, size before and after, and duration. The same
|
||||
sizes from `psql`:
|
||||
@@ -147,6 +148,38 @@ SELECT pg_size_pretty(pg_total_relation_size('measurements')) AS total,
|
||||
pg_size_pretty(pg_indexes_size('measurements')) AS indexes;
|
||||
```
|
||||
|
||||
## The nightly check
|
||||
|
||||
Since v1.43.7 the same queue also runs by itself at 03:40 (Europe/Berlin).
|
||||
That run names no steps: it reads the table's live and dead rows and runs a
|
||||
plain `VACUUM (ANALYZE)` only when there are at least 10,000 dead rows and
|
||||
more than one for every ten live ones. It never rebuilds an index, and a
|
||||
plain vacuum stops neither reads nor writes. It shares the singleton key with
|
||||
the run you start, so the two never overlap. The admin card keeps showing
|
||||
only the runs you started.
|
||||
|
||||
The check also measures how far the table file has grown past what its live
|
||||
rows need (`maintenance_bloat_factor` on the wide event, `bloat_factor` in the
|
||||
job result). Past 3 it adds a warning to the event: a plain vacuum makes the
|
||||
space reusable but does not shrink the file, and only `VACUUM FULL` does (see
|
||||
below).
|
||||
|
||||
Why the table grew in the first place: until v1.43.7 an update that changed
|
||||
nothing still moved `updated_at`, which sits in an index, so no update could
|
||||
stay on its page. Migration 0412 keeps `updated_at` and `sync_version` on such
|
||||
an update and gives the table `fillfactor = 85` and a 5 % autovacuum
|
||||
threshold. To check that updates stay on their page:
|
||||
|
||||
```sql
|
||||
SELECT n_tup_upd, n_tup_hot_upd, n_live_tup, n_dead_tup,
|
||||
pg_size_pretty(pg_relation_size('measurements')) AS heap
|
||||
FROM pg_stat_user_tables WHERE relname = 'measurements';
|
||||
```
|
||||
|
||||
`n_tup_hot_upd` should now grow along with `n_tup_upd`. The fill factor
|
||||
applies to pages written after the migration; a `VACUUM FULL` after the
|
||||
upgrade applies it to the whole table.
|
||||
|
||||
## "could not resize shared memory segment"
|
||||
|
||||
Up to v1.42.0 the run could stop at once with `could not resize shared memory
|
||||
@@ -160,6 +193,6 @@ runs single-process and needs no shared segment, and the bundled
|
||||
## Not part of this
|
||||
|
||||
`VACUUM FULL` would shrink the table file as well, but it locks the table for
|
||||
reads and writes while it runs. After the purge the remaining table is small
|
||||
and the space inside it is reused by new rows, so it is not needed. If you
|
||||
want it anyway, run it yourself in a maintenance window with the app stopped.
|
||||
reads and writes while it runs, so no job starts it. When the nightly check
|
||||
warns about the bloat factor, run it yourself in a maintenance window with the
|
||||
app stopped.
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Letzter neuer Wert {relative}",
|
||||
"lastBackground": "Zuletzt im Hintergrund {relative}",
|
||||
"never": "Noch nicht empfangen",
|
||||
"lastBackfill": "Verlauf nachgeladen {relative}"
|
||||
"lastBackfill": "Verlauf nachgeladen {relative}",
|
||||
"lastEnteredHere": "Zuletzt hier eingetragen {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Last new value {relative}",
|
||||
"lastBackground": "Last in background {relative}",
|
||||
"never": "Not received yet",
|
||||
"lastBackfill": "History caught up {relative}"
|
||||
"lastBackfill": "History caught up {relative}",
|
||||
"lastEnteredHere": "Last entered here {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Último valor nuevo {relative}",
|
||||
"lastBackground": "Última vez en segundo plano {relative}",
|
||||
"never": "Aún no recibido",
|
||||
"lastBackfill": "Historial recuperado {relative}"
|
||||
"lastBackfill": "Historial recuperado {relative}",
|
||||
"lastEnteredHere": "Último registro aquí {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Dernière nouvelle valeur {relative}",
|
||||
"lastBackground": "Dernière fois en arrière-plan {relative}",
|
||||
"never": "Pas encore reçu",
|
||||
"lastBackfill": "Historique rattrapé {relative}"
|
||||
"lastBackfill": "Historique rattrapé {relative}",
|
||||
"lastEnteredHere": "Dernière saisie ici {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Ultimo nuovo valore {relative}",
|
||||
"lastBackground": "Ultima volta in background {relative}",
|
||||
"never": "Non ancora ricevuto",
|
||||
"lastBackfill": "Storico recuperato {relative}"
|
||||
"lastBackfill": "Storico recuperato {relative}",
|
||||
"lastEnteredHere": "Ultimo inserimento qui {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "마지막 새 값 {relative}",
|
||||
"lastBackground": "마지막 백그라운드 수신 {relative}",
|
||||
"never": "아직 수신되지 않음",
|
||||
"lastBackfill": "기록 보충 {relative}"
|
||||
"lastBackfill": "기록 보충 {relative}",
|
||||
"lastEnteredHere": "여기에서 마지막 입력 {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
+2
-1
@@ -7275,7 +7275,8 @@
|
||||
"lastNewValue": "Ostatnia nowa wartość {relative}",
|
||||
"lastBackground": "Ostatnio w tle {relative}",
|
||||
"never": "Jeszcze nie odebrano",
|
||||
"lastBackfill": "Historia uzupełniona {relative}"
|
||||
"lastBackfill": "Historia uzupełniona {relative}",
|
||||
"lastEnteredHere": "Ostatni wpis tutaj {relative}"
|
||||
}
|
||||
},
|
||||
"garminInfo": {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Let an update of `measurements` that changes nothing stay a HOT update.
|
||||
--
|
||||
-- Every write path that re-reads a provider window (Withings, Fitbit, Oura,
|
||||
-- the Apple Health `stats:` overwrites, the reconcile paths) updates rows
|
||||
-- whose values did not change, and each such update moved `updated_at`
|
||||
-- (Prisma's `@updatedAt`) and `sync_version`. `updated_at` sits in the
|
||||
-- sync-feed index `measurements_user_id_updated_at_id_idx`, so not one of
|
||||
-- those updates could be heap-only: every one wrote a new row version on
|
||||
-- another page and an entry in every index, the table grew several times
|
||||
-- its live size, and every unchanged row went back down the `/api/sync/changes`
|
||||
-- feed to the phone.
|
||||
--
|
||||
-- 1. A row trigger keeps `updated_at` and `sync_version` as they were when
|
||||
-- the update changes no other column. Such an update then touches no
|
||||
-- indexed column, so Postgres can keep it on the page (HOT), and the
|
||||
-- sync feed stays silent because nothing changed. An update that does
|
||||
-- change something still moves both stamps, exactly as before.
|
||||
-- 2. `fillfactor = 85` leaves room on each page for those in-page versions;
|
||||
-- it applies to pages written from now on (a `VACUUM FULL` rewrites the
|
||||
-- rest).
|
||||
-- 3. Autovacuum starts at 5 % dead rows instead of 20 %, so a large table
|
||||
-- does not wait for a fifth of itself to be dead before it is cleaned.
|
||||
--
|
||||
-- `to_jsonb(row)` compares every column, so a column added later is covered
|
||||
-- without touching this function.
|
||||
|
||||
CREATE OR REPLACE FUNCTION "measurements_keep_stamps_on_noop"() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (to_jsonb(NEW) - 'updated_at' - 'sync_version')
|
||||
= (to_jsonb(OLD) - 'updated_at' - 'sync_version') THEN
|
||||
NEW."updated_at" := OLD."updated_at";
|
||||
NEW."sync_version" := OLD."sync_version";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER "measurements_noop_update_keeps_stamps"
|
||||
BEFORE UPDATE ON "measurements"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION "measurements_keep_stamps_on_noop"();
|
||||
|
||||
ALTER TABLE "measurements" SET (
|
||||
fillfactor = 85,
|
||||
autovacuum_vacuum_scale_factor = 0.05,
|
||||
autovacuum_analyze_scale_factor = 0.05
|
||||
);
|
||||
@@ -2094,6 +2094,11 @@ model Measurement {
|
||||
// limit+1`. Without this index Postgres filters by `userId` then sorts
|
||||
// the user's entire measurement set per page; the delta drain allows
|
||||
// 120 pulls/min/user. See migration 0096.
|
||||
// Because `updatedAt` is indexed here, an update that moves it can never
|
||||
// be heap-only. The trigger `measurements_noop_update_keeps_stamps`
|
||||
// (migration 0412) keeps `updatedAt` and `syncVersion` on an update that
|
||||
// changes nothing else, so those stay HOT; the table also carries
|
||||
// `fillfactor = 85` and a 5 % autovacuum threshold from the same migration.
|
||||
@@index([userId, updatedAt, id])
|
||||
// `measurements_legacy_step_consolidation_idx` is a partial index
|
||||
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
type QueueRuntime,
|
||||
type SourceSite,
|
||||
} from "@/lib/jobs/queue-runtime";
|
||||
import { SCHEDULED_AND_SENT_QUEUES } from "@/lib/jobs/reminder/registrar-shared";
|
||||
import { HEAVY_QUEUES } from "@/lib/jobs/heavy-lane";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
const REGISTRARS = [
|
||||
@@ -369,3 +371,91 @@ describe("every queue declares its runtime", () => {
|
||||
).toEqual(["planted-long: expiry 900s out of range"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Queues a file sends to through a variable (`boss.send(queueName, …)`): every
|
||||
* known queue name the file spells out, as a constant or a map value, counts
|
||||
* as sent, because the matcher above cannot tell which one the variable holds.
|
||||
*/
|
||||
function dynamicallySent(texts: ReadonlyMap<string, string>): Set<string> {
|
||||
const out = new Set<string>();
|
||||
for (const [, raw] of texts) {
|
||||
const text = code(raw);
|
||||
if (
|
||||
!/\b(?:boss|getGlobalBoss\(\)\??)\s*\.\s*send\s*(?:<[^(]*?>)?\(\s*[a-z]/.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for (const m of text.matchAll(/"([a-z0-9-]+)"/g)) {
|
||||
if (m[1] in QUEUE_RUNTIME) out.add(m[1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("a cron-fed queue polls slowly, a sent one does not", () => {
|
||||
const all = scan(source);
|
||||
const scheduled = new Set(all.schedules.map((s) => s.queue));
|
||||
const sent = new Set([
|
||||
...all.sends.map((s) => s.queue),
|
||||
...dynamicallySent(source),
|
||||
]);
|
||||
|
||||
it("lists exactly the scheduled queues that also take sends", () => {
|
||||
const expected = [...scheduled].filter((q) => sent.has(q)).sort();
|
||||
expect([...SCHEDULED_AND_SENT_QUEUES].sort()).toEqual(expected);
|
||||
// Most scheduled queues are fed by their cron alone.
|
||||
expect(scheduled.size - expected.length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("counts a send through a variable as a send to every queue the file names", () => {
|
||||
const dynamic = dynamicallySent(source);
|
||||
expect(dynamic.has("withings-activity-sync")).toBe(true);
|
||||
expect(dynamic.has("whoop-recovery-sync")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/** The text of the first `const NAME = …` in the tree, up to its end. */
|
||||
function constBody(texts: ReadonlyMap<string, string>, name: string): string {
|
||||
for (const [file, raw] of texts) {
|
||||
if (new RegExp(`\\bconst\\s+${name}\\b`).test(raw)) {
|
||||
return bodyOf(texts, { file, fn: name }).split(/\n\n/)[0];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
describe("heavy jobs share the lane", () => {
|
||||
const all = scan(source);
|
||||
const laned = all.bindings.filter((b) => /\bheavyLane\s*\(/.test(b.text));
|
||||
|
||||
it("binds every heavy queue in the lane, and nothing else", () => {
|
||||
expect(laned.map((b) => b.queue).sort()).toEqual(
|
||||
Object.keys(HEAVY_QUEUES).sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds a lockedPass outside the lane, so an expiry retry does not queue twice", () => {
|
||||
const inverted = laned
|
||||
.filter((b) => /\blockedPass\s*\(/.test(b.text))
|
||||
.filter((b) => b.text.indexOf("lockedPass") > b.text.indexOf("heavyLane"))
|
||||
.map((b) => b.queue);
|
||||
expect(inverted).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a waiter past its expiry only where nothing retries the job", () => {
|
||||
const problems = Object.entries(HEAVY_QUEUES)
|
||||
.filter(([, heavy]) => heavy.oneShot)
|
||||
.flatMap(([queue]) => {
|
||||
const entry = QUEUE_RUNTIME[queue];
|
||||
if (entry?.runtime !== "long") return [`${queue}: not a long queue`];
|
||||
const noRetry = entry.expiryVia.some((v) =>
|
||||
/\bretryLimit:\s*0\b/.test(constBody(source, v)),
|
||||
);
|
||||
return noRetry ? [] : [`${queue}: its send options allow a retry`];
|
||||
});
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Structural guard: a provider client walks a paged collection through
|
||||
* `walkPages` (`src/lib/integrations/page-walk.ts`), never a loop of its own.
|
||||
*
|
||||
* Each hand-rolled loop ended at its page ceiling by returning what it had,
|
||||
* silently, and the backfill around it stamped the history complete. A
|
||||
* `while (pages < MAX)`, a `pageCount > N` break or a `do … while (next &&
|
||||
* pageCount < max)` in a provider directory is that class coming back.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const LIB = join(process.cwd(), "src", "lib");
|
||||
const PROVIDER_DIRS = [
|
||||
"dawarich",
|
||||
"fitbit",
|
||||
"google-health",
|
||||
"nightscout",
|
||||
"oura",
|
||||
"polar",
|
||||
"strava",
|
||||
"whoop",
|
||||
"withings",
|
||||
];
|
||||
/**
|
||||
* Loops still counting pages on their own, with the reason. Each is bounded
|
||||
* and reports nothing at its bound; each moves to `walkPages` with the next
|
||||
* change to its file.
|
||||
*/
|
||||
const PENDING: Readonly<Record<string, string>> = {
|
||||
"google-health/client.ts":
|
||||
"progress-bounded walk (backfill-bounds.ts); moves with the next Google Health change",
|
||||
"strava/sync.ts":
|
||||
"activity pages per sync; moves with the next Strava change",
|
||||
};
|
||||
|
||||
/** A page counter compared against a bound, in any of the shapes used. */
|
||||
const PAGE_LOOP =
|
||||
/\b(?:pages?|pageCount|pagesRead|page)\s*(?:<|<=|>|>=)\s*(?:[A-Z_][A-Z0-9_]*|maxPages|\d+)\b/;
|
||||
|
||||
function strip(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("provider clients walk pages through walkPages", () => {
|
||||
const files = walkSourceFiles(LIB, { floor: 1000 }).filter(
|
||||
(f) =>
|
||||
PROVIDER_DIRS.some((d) => f.startsWith(`${d}/`)) &&
|
||||
!f.includes("__tests__") &&
|
||||
!f.endsWith(".test.ts"),
|
||||
);
|
||||
const offending = (f: string) =>
|
||||
PAGE_LOOP.test(strip(readFileSync(join(LIB, f), "utf8")));
|
||||
|
||||
it("walks the provider directories", () => {
|
||||
expect(files.length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("finds no page loop of their own outside the pending list", () => {
|
||||
expect(files.filter((f) => !(f in PENDING) && offending(f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the pending list honest", () => {
|
||||
expect(Object.keys(PENDING).filter((f) => !offending(f))).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches the shapes the loops used", () => {
|
||||
for (const planted of [
|
||||
"while (pages < WORKOUT_MAX_PAGES) {",
|
||||
"if (pageCount > 1000) break;",
|
||||
"} while (nextToken && pageCount < maxPages);",
|
||||
]) {
|
||||
expect(PAGE_LOOP.test(planted), planted).toBe(true);
|
||||
}
|
||||
expect(PAGE_LOOP.test("const walk = await walkPages(label, 12, f);")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Structural guard: a provider client rounds an imported value through
|
||||
* `importValue` (`src/lib/integrations/import-value.ts`) and nothing else.
|
||||
*
|
||||
* Each client used to keep its own `parseFloat(n.toFixed(2))`. Five copies
|
||||
* of one rule drift apart, and `toFixed` mis-rounds halves on the double
|
||||
* below the decimal. A `toFixed` or a private `round2` in a provider
|
||||
* directory is the class coming back.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const LIB = join(process.cwd(), "src", "lib");
|
||||
const PROVIDER_DIRS = [
|
||||
"apple-health",
|
||||
"dawarich",
|
||||
"fitbit",
|
||||
"google-health",
|
||||
"nightscout",
|
||||
"oura",
|
||||
"polar",
|
||||
"strava",
|
||||
"whoop",
|
||||
"withings",
|
||||
];
|
||||
/**
|
||||
* Files that still round on their own, with the reason. Google Health's
|
||||
* mapper is the one left: it moves to `importValue` with the next change to
|
||||
* that directory.
|
||||
*/
|
||||
const PENDING: Readonly<Record<string, string>> = {
|
||||
"google-health/mappers.ts":
|
||||
"moves to importValue with the next Google Health change",
|
||||
};
|
||||
|
||||
const ROUNDING = /\.toFixed\s*\(|\bfunction\s+round2\b/;
|
||||
|
||||
function strip(text: string): string {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("provider clients round through importValue", () => {
|
||||
const files = walkSourceFiles(LIB, { floor: 1000 }).filter(
|
||||
(f) =>
|
||||
PROVIDER_DIRS.some((d) => f.startsWith(`${d}/`)) &&
|
||||
!f.includes("__tests__") &&
|
||||
!f.endsWith(".test.ts"),
|
||||
);
|
||||
|
||||
it("walks the provider directories", () => {
|
||||
expect(files.length).toBeGreaterThanOrEqual(60);
|
||||
});
|
||||
|
||||
it("finds no rounding of their own outside the pending list", () => {
|
||||
const offenders = files.filter(
|
||||
(f) =>
|
||||
!(f in PENDING) &&
|
||||
ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the pending list honest", () => {
|
||||
const stale = Object.keys(PENDING).filter(
|
||||
(f) => !ROUNDING.test(strip(readFileSync(join(LIB, f), "utf8"))),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a planted copy", () => {
|
||||
expect(ROUNDING.test("const v = parseFloat(n.toFixed(2));")).toBe(true);
|
||||
expect(ROUNDING.test("function round2(n: number) {}")).toBe(true);
|
||||
expect(ROUNDING.test("value: importValue(n)")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -358,6 +358,86 @@ describe("GET /api/integrations/healthkit — sync health", () => {
|
||||
expect(byType.PULSE).toBe(false);
|
||||
});
|
||||
|
||||
// #1247 — weight entered here goes to Apple Health and never arrives back,
|
||||
// so a person who keeps their weight here watched the type go quiet.
|
||||
describe("a type the app writes back to the phone", () => {
|
||||
function mockWriteBack(enteredHereAt: Date | null): void {
|
||||
vi.mocked(listLiveMeasurementTypes).mockResolvedValue([
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
]);
|
||||
vi.mocked(prisma.$queryRaw).mockImplementation((async (
|
||||
strings: TemplateStringsArray,
|
||||
) => {
|
||||
const sql = strings.join("?");
|
||||
if (sql.includes("medication_intake_events")) {
|
||||
return [{ last_seen: null }];
|
||||
}
|
||||
if (sql.includes("<> 'APPLE_HEALTH'")) {
|
||||
return [{ type: "WEIGHT", last_seen: enteredHereAt }];
|
||||
}
|
||||
return [
|
||||
{ type: "WEIGHT", last_seen: ago(30 * DAY) },
|
||||
{ type: "PULSE", last_seen: ago(30 * DAY) },
|
||||
];
|
||||
}) as never);
|
||||
}
|
||||
|
||||
function byType(body: Body) {
|
||||
return Object.fromEntries(
|
||||
body.metricFreshness.map((entry) => [entry.type, entry]),
|
||||
) as Record<
|
||||
string,
|
||||
Body["metricFreshness"][number] & {
|
||||
lastEnteredHereAt?: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
it("is not quiet while readings are entered here", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
const enteredHereAt = ago(2 * DAY);
|
||||
mockWriteBack(enteredHereAt);
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(false);
|
||||
expect(entries.WEIGHT.lastEnteredHereAt).toBe(
|
||||
enteredHereAt.toISOString(),
|
||||
);
|
||||
// A read-only type keeps its own verdict and carries no such field.
|
||||
expect(entries.PULSE.stale).toBe(true);
|
||||
expect(entries.PULSE).not.toHaveProperty("lastEnteredHereAt");
|
||||
});
|
||||
|
||||
it("is quiet when nothing arrived and nothing was entered here", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: null,
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
mockWriteBack(null);
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(true);
|
||||
expect(entries.WEIGHT.lastEnteredHereAt).toBeNull();
|
||||
});
|
||||
|
||||
it("counts only what the entry still writes back", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
healthKitConfigJson: {
|
||||
entries: [
|
||||
{ id: "bodyMass", kind: "bodyMass", direction: "readOnly" },
|
||||
],
|
||||
},
|
||||
healthKitLastSyncedAt: ago(60 * 60 * 1000),
|
||||
} as never);
|
||||
mockWriteBack(ago(2 * DAY));
|
||||
const entries = byType(await read());
|
||||
expect(entries.WEIGHT.stale).toBe(true);
|
||||
expect(entries.WEIGHT).not.toHaveProperty("lastEnteredHereAt");
|
||||
});
|
||||
});
|
||||
|
||||
// #586 — the reporter's question is whether the phone delivers on its own or
|
||||
// only while the app is open. `lastSyncedAt` reads identically either way, so
|
||||
// it cannot answer it; these two fields are what can.
|
||||
|
||||
@@ -23,11 +23,13 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
|
||||
import {
|
||||
getHealthKitTypeArrivals,
|
||||
healthKitWriteBackTypes,
|
||||
newestAppleHealthDataAt,
|
||||
withHealthKitArrivals,
|
||||
} from "@/lib/integrations/healthkit-type-sync";
|
||||
import {
|
||||
getAppleHealthDoseFreshness,
|
||||
getEnteredHereAt,
|
||||
getSourceFreshness,
|
||||
} from "@/lib/integrations/metric-freshness";
|
||||
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
|
||||
@@ -183,14 +185,27 @@ export const GET = apiHandler(async () => {
|
||||
// v1.42.4 — Apple Health doses join the list as `MEDICATION_DOSE`, so the
|
||||
// phone can read when doses last arrived from the same entries as every
|
||||
// other type. Fail-soft on its own: a failed dose read drops that one entry.
|
||||
const [measurementSamples, doseSample, arrivals, syncProgress, health] =
|
||||
await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getAppleHealthDoseFreshness(user.id).catch(() => null),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
]);
|
||||
// #1247 — the types the app writes back to the phone: what is entered here
|
||||
// goes to Apple Health and never arrives back, so each of them also carries
|
||||
// the newest reading entered here, and is quiet only when both are. Fail-
|
||||
// soft to undefined, which leaves the list as it was before.
|
||||
const [
|
||||
measurementSamples,
|
||||
doseSample,
|
||||
arrivals,
|
||||
syncProgress,
|
||||
health,
|
||||
enteredHere,
|
||||
] = await Promise.all([
|
||||
getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []),
|
||||
getAppleHealthDoseFreshness(user.id).catch(() => null),
|
||||
getHealthKitTypeArrivals(user.id).catch(() => null),
|
||||
getAppleHealthSyncProgress(user.id).catch(() => null),
|
||||
loadOneConnectionHealth(user.id, "apple-health").catch(() => null),
|
||||
getEnteredHereAt(user.id, healthKitWriteBackTypes(entries)).catch(
|
||||
() => undefined,
|
||||
),
|
||||
]);
|
||||
const samples = doseSample
|
||||
? [...measurementSamples, doseSample].sort((a, b) =>
|
||||
a.type.localeCompare(b.type),
|
||||
@@ -216,7 +231,12 @@ export const GET = apiHandler(async () => {
|
||||
lastBackgroundSyncAt,
|
||||
syncHealth,
|
||||
metricFreshness: withHealthKitArrivals(
|
||||
classifyMetricFreshness(samples, syncHealth.verdict),
|
||||
classifyMetricFreshness(
|
||||
samples,
|
||||
syncHealth.verdict,
|
||||
Date.now(),
|
||||
enteredHere,
|
||||
),
|
||||
arrivals,
|
||||
),
|
||||
syncProgress,
|
||||
|
||||
@@ -210,6 +210,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.TELEGRAM_WEBHOOK_SECRET;
|
||||
else process.env.TELEGRAM_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
@@ -396,7 +397,11 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
expect(ackText).toMatch(/1 hour/i);
|
||||
});
|
||||
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through end-of-day", async () => {
|
||||
it("'skip:<medId>' creates an intake event with skipped=true and snoozes through the person's own end of day", async () => {
|
||||
// 23:30 UTC is already 01:30 the next day in Berlin: the snooze ends at
|
||||
// Berlin's next midnight, not at the end of the UTC day half an hour on.
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-06-10T23:30:00.000Z"));
|
||||
const res = await POST(tgRequest(callbackUpdate("skip:med-1")));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -417,8 +422,7 @@ describe("Telegram webhook — callback dispatch", () => {
|
||||
const updateArgs = vi.mocked(prisma.medication.update).mock.calls[0][0];
|
||||
const snoozedUntil = (updateArgs.data as { snoozedUntil: Date })
|
||||
.snoozedUntil;
|
||||
expect(snoozedUntil.getHours()).toBe(23);
|
||||
expect(snoozedUntil.getMinutes()).toBe(59);
|
||||
expect(snoozedUntil.toISOString()).toBe("2026-06-11T21:59:59.999Z");
|
||||
// Skip row + rest-of-day snooze commit atomically.
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
// v1.16.10 — an explicit skip refunds a previously-consumed stamp
|
||||
|
||||
@@ -531,6 +531,33 @@ describe("<AppleHealthCard> — per-type arrivals (#1173)", () => {
|
||||
expect(out).toContain("Not received yet");
|
||||
});
|
||||
|
||||
// #1247 — weight entered here goes to the phone and never arrives back.
|
||||
it("names when a written-back type was last entered here", () => {
|
||||
const received = html({
|
||||
...base,
|
||||
type: "WEIGHT",
|
||||
lastReceivedAt: hoursAgo(400),
|
||||
lastTrigger: "background",
|
||||
lastNewSampleAt: hoursAgo(400),
|
||||
lastEnteredHereAt: hoursAgo(48),
|
||||
});
|
||||
expect(received).toContain("Last entered here 2 d ago");
|
||||
const never = html({
|
||||
...base,
|
||||
type: "WEIGHT",
|
||||
lastReceivedAt: null,
|
||||
lastEnteredHereAt: hoursAgo(48),
|
||||
});
|
||||
expect(never).toContain("Not received yet");
|
||||
expect(never).toContain("Last entered here 2 d ago");
|
||||
const noLedger = html({
|
||||
...base,
|
||||
type: "WEIGHT",
|
||||
lastEnteredHereAt: hoursAgo(48),
|
||||
});
|
||||
expect(noLedger).toContain('data-state="entered-here"');
|
||||
});
|
||||
|
||||
it("renders nothing when the server sent no arrival facts", () => {
|
||||
expect(html(base)).toBe("");
|
||||
});
|
||||
|
||||
@@ -136,7 +136,9 @@ function DeliveryDiagnostic({
|
||||
* arrives on "Sync all" from one the phone delivers by itself (#1173), plus
|
||||
* the last background arrival when a later one came another way. Nothing
|
||||
* renders when the server sent no arrival facts (the workouts row, or a failed
|
||||
* ledger read), so absence is never painted as "not received".
|
||||
* ledger read), so absence is never painted as "not received". A type the app
|
||||
* writes back to the phone also names when a reading was last entered here
|
||||
* (#1247), since those never arrive back.
|
||||
*/
|
||||
export function ArrivalDetail({
|
||||
entry,
|
||||
@@ -147,10 +149,21 @@ export function ArrivalDetail({
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
now?: Date;
|
||||
}) {
|
||||
if (entry.lastReceivedAt === undefined) return null;
|
||||
const reference = (now ?? new Date()).getTime();
|
||||
const relative = (iso: string) =>
|
||||
formatRelative(Math.max(0, reference - new Date(iso).getTime()), t);
|
||||
const enteredHere = entry.lastEnteredHereAt
|
||||
? t("settings.appleHealth.freshness.lastEnteredHere", {
|
||||
relative: relative(entry.lastEnteredHereAt),
|
||||
})
|
||||
: null;
|
||||
if (entry.lastReceivedAt === undefined) {
|
||||
return enteredHere ? (
|
||||
<span data-testid="apple-health-arrival" data-state="entered-here">
|
||||
{enteredHere}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
// v1.43 (ios #77) — a history catch-up is named apart from the live
|
||||
// arrivals, and never counts as one.
|
||||
const backfill = entry.lastBackfillAt
|
||||
@@ -161,7 +174,7 @@ export function ArrivalDetail({
|
||||
if (entry.lastReceivedAt === null) {
|
||||
return (
|
||||
<span data-testid="apple-health-arrival" data-state="never">
|
||||
{[t("settings.appleHealth.freshness.never"), backfill]
|
||||
{[t("settings.appleHealth.freshness.never"), backfill, enteredHere]
|
||||
.filter((part): part is string => part !== null)
|
||||
.join(", ")}
|
||||
</span>
|
||||
@@ -199,6 +212,7 @@ export function ArrivalDetail({
|
||||
);
|
||||
}
|
||||
if (backfill) parts.push(backfill);
|
||||
if (enteredHere) parts.push(enteredHere);
|
||||
return (
|
||||
<span
|
||||
data-testid="apple-health-arrival"
|
||||
|
||||
@@ -43,6 +43,7 @@ import { canonicalDailyTimestamp } from "@/lib/measurements/consolidation-tz";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
import { FitbitApiError, classifyFitbitResponse } from "./response-classifier";
|
||||
import { envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
|
||||
/** Classic Fitbit Web API base. */
|
||||
export const FITBIT_API_BASE = "https://api.fitbit.com";
|
||||
@@ -505,10 +506,6 @@ async function fitbitGet(
|
||||
// `FitbitMappedMeasurement[]` (the source + final externalId are stamped by the
|
||||
// sync layer).
|
||||
|
||||
function round2(n: number): number {
|
||||
return parseFloat(n.toFixed(2));
|
||||
}
|
||||
|
||||
/** Finite + strictly-positive guard. */
|
||||
function positive(n: unknown): n is number {
|
||||
return typeof n === "number" && Number.isFinite(n) && n > 0;
|
||||
@@ -637,7 +634,7 @@ export function mapWeight(
|
||||
if (!positive(e.weight)) continue;
|
||||
out.push({
|
||||
type: "WEIGHT",
|
||||
value: round2(e.weight),
|
||||
value: importValue(e.weight),
|
||||
unit: "kg",
|
||||
measuredAt: bodyLogInstant(e, tz),
|
||||
fieldTag: `${bodyLogAnchor(e)}:weight`,
|
||||
@@ -657,7 +654,7 @@ export function mapBodyFat(
|
||||
if (!positive(e.fat)) continue;
|
||||
out.push({
|
||||
type: "BODY_FAT",
|
||||
value: round2(e.fat),
|
||||
value: importValue(e.fat),
|
||||
unit: "%",
|
||||
measuredAt: bodyLogInstant(e, tz),
|
||||
fieldTag: `${bodyLogAnchor(e)}:body_fat`,
|
||||
@@ -703,7 +700,7 @@ function mapDailySummary(
|
||||
const scaled = spec.factor ? value * spec.factor : value;
|
||||
out.push({
|
||||
type: spec.type,
|
||||
value: round2(scaled),
|
||||
value: importValue(scaled),
|
||||
unit: spec.unit,
|
||||
measuredAt: canonicalDailyTimestamp(day, tz),
|
||||
fieldTag: `${day}:${spec.fieldTag}`,
|
||||
@@ -822,7 +819,7 @@ export function mapVo2Max(
|
||||
if (v === null || !positive(v)) continue;
|
||||
out.push({
|
||||
type: "VO2_MAX",
|
||||
value: round2(v),
|
||||
value: importValue(v),
|
||||
unit: "mL/(kg·min)",
|
||||
measuredAt: canonicalDailyTimestamp(day, tz),
|
||||
fieldTag: `vo2_max:${day}`,
|
||||
@@ -880,7 +877,7 @@ function mapActivitySeries(
|
||||
const scaled = spec.factor ? n * spec.factor : n;
|
||||
out.push({
|
||||
type: spec.type,
|
||||
value: round2(scaled),
|
||||
value: importValue(scaled),
|
||||
unit: spec.unit,
|
||||
measuredAt: canonicalDailyTimestamp(day, tz),
|
||||
fieldTag: `${spec.fieldTag}:${day}`,
|
||||
@@ -1221,7 +1218,7 @@ export function mapSleepSessionDetailed(
|
||||
if (!windowEnd || end > windowEnd) windowEnd = end;
|
||||
rows.push({
|
||||
type: "SLEEP_DURATION",
|
||||
value: round2(minutes),
|
||||
value: importValue(minutes),
|
||||
unit: "minutes",
|
||||
measuredAt: end,
|
||||
fieldTag: `${anchor}:sleep:${start.toISOString()}`,
|
||||
@@ -1359,7 +1356,7 @@ export function mapWorkout(
|
||||
// distance is km under the metric locale → metres.
|
||||
const distanceM =
|
||||
typeof entry.distance === "number" && entry.distance >= 0
|
||||
? round2(entry.distance * 1000)
|
||||
? importValue(entry.distance * 1000)
|
||||
: null;
|
||||
const avgHr = positive(entry.averageHeartRate)
|
||||
? Math.round(entry.averageHeartRate)
|
||||
|
||||
@@ -13,8 +13,10 @@
|
||||
* collapse for a server-owned source pair.
|
||||
*
|
||||
* The page-walk is bounded (the 150 req/h budget is tight): a small page cap
|
||||
* covers the incremental window comfortably; a deep backfill is bounded by the
|
||||
* backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource.
|
||||
* covers the incremental window comfortably, and a backfill, bounded by the
|
||||
* horizon `afterDate`, may read up to ten thousand sessions. A walk that
|
||||
* reaches its cap is reported truncated (`walkPages`), so the backfill is not
|
||||
* stamped complete over it. A per-endpoint 403 soft-skips the resource.
|
||||
*/
|
||||
import { fetchActivityList, mapWorkout, readActivityList } from "./client";
|
||||
import type { FitbitMappedWorkout } from "./client";
|
||||
@@ -24,11 +26,14 @@ import { prisma, toJson } from "@/lib/db";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
|
||||
/** Activities per page on the `activities/list` walk. */
|
||||
const WORKOUT_PAGE_SIZE = 100;
|
||||
/** Hard ceiling on pages walked per cycle (rate-budget guard). */
|
||||
/** Ceiling on pages walked per incremental cycle (rate-budget guard). */
|
||||
const WORKOUT_MAX_PAGES = 12;
|
||||
/** Ceiling on pages walked by a backfill. */
|
||||
const WORKOUT_BACKFILL_MAX_PAGES = 100;
|
||||
|
||||
function metadataRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
@@ -36,6 +41,32 @@ function metadataRecord(value: unknown): Record<string, unknown> {
|
||||
: {};
|
||||
}
|
||||
|
||||
function walkActivityList(
|
||||
accessToken: string,
|
||||
afterDate: Date,
|
||||
maxPages: number,
|
||||
) {
|
||||
return walkPages<number, ReturnType<typeof readActivityList>[number]>(
|
||||
"fitbit.exercise",
|
||||
maxPages,
|
||||
async (cursor) => {
|
||||
const offset = cursor ?? 0;
|
||||
const page = readActivityList(
|
||||
await fetchActivityList(accessToken, afterDate, {
|
||||
limit: WORKOUT_PAGE_SIZE,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
items: page,
|
||||
next:
|
||||
page.length < WORKOUT_PAGE_SIZE ? null : offset + WORKOUT_PAGE_SIZE,
|
||||
};
|
||||
},
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export async function syncUserWorkout(
|
||||
userId: string,
|
||||
opts: FitbitResourceSyncOptions = {},
|
||||
@@ -50,28 +81,19 @@ export async function syncUserWorkout(
|
||||
const afterDate =
|
||||
opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const entries: ReturnType<typeof readActivityList> = [];
|
||||
let offset = 0;
|
||||
let pages = 0;
|
||||
while (pages < WORKOUT_MAX_PAGES) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await fetchActivityList(tokenInfo.accessToken, afterDate, {
|
||||
limit: WORKOUT_PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("fetchExercise", userId, err);
|
||||
}
|
||||
const page = readActivityList(body);
|
||||
entries.push(...page);
|
||||
pages += 1;
|
||||
if (page.length < WORKOUT_PAGE_SIZE) break;
|
||||
offset += WORKOUT_PAGE_SIZE;
|
||||
let walk: Awaited<ReturnType<typeof walkActivityList>>;
|
||||
try {
|
||||
walk = await walkActivityList(
|
||||
tokenInfo.accessToken,
|
||||
afterDate,
|
||||
opts.fullSync ? WORKOUT_BACKFILL_MAX_PAGES : WORKOUT_MAX_PAGES,
|
||||
);
|
||||
} catch (err) {
|
||||
return handleCollectionFetchError("fetchExercise", userId, err);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
for (const entry of entries) {
|
||||
for (const entry of walk.items) {
|
||||
const w: FitbitMappedWorkout | null = mapWorkout(entry, tz);
|
||||
if (!w) continue; // no usable time span — not a workout
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { importValue } from "../import-value";
|
||||
|
||||
describe("importValue", () => {
|
||||
it("drops the binary noise provider arithmetic leaves behind", () => {
|
||||
expect(importValue(72.30000000000001)).toBe(72.3);
|
||||
expect(importValue(0.1 + 0.2)).toBe(0.3);
|
||||
// Withings: value * 10^unit.
|
||||
expect(importValue(78456 * Math.pow(10, -3))).toBe(78.46);
|
||||
expect(String(importValue(1234 * 0.01))).toBe("12.34");
|
||||
});
|
||||
|
||||
it("rounds halves on the decimal the value prints as, away from zero", () => {
|
||||
// `(1.005).toFixed(2)` is "1.00".
|
||||
expect(importValue(1.005)).toBe(1.01);
|
||||
expect(importValue(-0.125)).toBe(-0.13);
|
||||
expect(importValue(2.675)).toBe(2.68);
|
||||
});
|
||||
|
||||
it("keeps whole numbers, zero and non-finite values as they are", () => {
|
||||
expect(importValue(64)).toBe(64);
|
||||
expect(Object.is(importValue(-0.001), 0)).toBe(true);
|
||||
expect(importValue(0)).toBe(0);
|
||||
expect(importValue(Number.NaN)).toBeNaN();
|
||||
expect(importValue(1e-7)).toBe(0);
|
||||
expect(importValue(1e22)).toBe(1e22);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { backfillVerdict, walkPages, withPageWalkLedger } from "../page-walk";
|
||||
|
||||
describe("walkPages", () => {
|
||||
it("reads until a page has no next", async () => {
|
||||
const walk = await walkPages<number, string>(
|
||||
"test.done",
|
||||
10,
|
||||
async (cursor) => ({
|
||||
items: [`p${cursor ?? 0}`],
|
||||
next: (cursor ?? 0) < 2 ? (cursor ?? 0) + 1 : null,
|
||||
}),
|
||||
);
|
||||
expect(walk).toEqual({
|
||||
items: ["p0", "p1", "p2"],
|
||||
pages: 3,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a walk that reaches its ceiling with a page pending", async () => {
|
||||
const walk = await walkPages<number, number>(
|
||||
"test.ceiling",
|
||||
4,
|
||||
async (cursor) => ({ items: [cursor ?? 0], next: (cursor ?? 0) + 1 }),
|
||||
);
|
||||
expect(walk.truncated).toBe(true);
|
||||
expect(walk.pages).toBe(4);
|
||||
expect(walk.items).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("stops on a cursor it already followed, the first one included", async () => {
|
||||
const fetchPage = vi.fn(async () => ({ items: [1], next: 0 }));
|
||||
const walk = await walkPages<number, number>(
|
||||
"test.repeat",
|
||||
50,
|
||||
fetchPage,
|
||||
0,
|
||||
);
|
||||
expect(walk.truncated).toBe(true);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withPageWalkLedger and backfillVerdict", () => {
|
||||
it("collects the truncated walks of a run", async () => {
|
||||
const { result, truncated } = await withPageWalkLedger(async () => {
|
||||
await walkPages("b.walk", 1, async () => ({ items: [], next: "x" }));
|
||||
await walkPages("a.walk", 5, async () => ({ items: [], next: null }));
|
||||
return "done";
|
||||
});
|
||||
expect(result).toBe("done");
|
||||
expect(truncated).toEqual(["b.walk"]);
|
||||
});
|
||||
|
||||
it("retries a failure, leaves a truncated history incomplete, stamps a clean one", () => {
|
||||
expect(backfillVerdict({ failed: true, truncated: ["a"] })).toBe("retry");
|
||||
expect(backfillVerdict({ failed: false, truncated: ["a"] })).toBe(
|
||||
"incomplete",
|
||||
);
|
||||
expect(backfillVerdict({ failed: false, truncated: [] })).toBe("complete");
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,35 @@ export function isBackfillLedgerKey(type: string): boolean {
|
||||
*/
|
||||
export const MEDICATION_DOSE_ARRIVAL_TYPE = MEDICATION_DOSE_FRESHNESS_TYPE;
|
||||
|
||||
/**
|
||||
* The measurement types behind each Apple Health entry kind the app writes
|
||||
* back to the phone (#1247). A reading entered here goes to Apple Health and
|
||||
* is never sent back, so these types are counted from both directions.
|
||||
*/
|
||||
export const HEALTHKIT_WRITE_BACK_TYPES: Readonly<
|
||||
Record<string, readonly MeasurementType[]>
|
||||
> = {
|
||||
bodyMass: ["WEIGHT"],
|
||||
bloodPressure: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"],
|
||||
bloodGlucose: ["BLOOD_GLUCOSE"],
|
||||
};
|
||||
|
||||
/** The types the person's resolved entries currently write back. */
|
||||
export function healthKitWriteBackTypes(
|
||||
entries: ReadonlyArray<{ kind: string; direction: string; enabled: boolean }>,
|
||||
): MeasurementType[] {
|
||||
const types = new Set<MeasurementType>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.enabled) continue;
|
||||
if (entry.direction !== "bidirectional" && entry.direction !== "writeOnly")
|
||||
continue;
|
||||
for (const type of HEALTHKIT_WRITE_BACK_TYPES[entry.kind] ?? []) {
|
||||
types.add(type);
|
||||
}
|
||||
}
|
||||
return [...types].sort();
|
||||
}
|
||||
|
||||
/** A ledger key: a measurement type, or the dose key above. */
|
||||
export type HealthKitArrivalType =
|
||||
MeasurementType | typeof MEDICATION_DOSE_ARRIVAL_TYPE;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* The one rounding a provider value gets on its way in.
|
||||
*
|
||||
* Provider payloads arrive as binary floats that carry noise from the
|
||||
* provider's own arithmetic and from ours (Withings sends `value * 10^unit`,
|
||||
* seconds become minutes, metres become kilometres): `72.30000000000001`
|
||||
* stored as is shows up in a chart tooltip, an export and a delta. Every
|
||||
* provider client used to keep its own `parseFloat(n.toFixed(2))`, which
|
||||
* also mis-rounds on halves (`(1.005).toFixed(2)` is `"1.00"`, because the
|
||||
* double below 1.005 is what gets rounded).
|
||||
*
|
||||
* Two decimals, rounded half away from zero on the decimal the value prints
|
||||
* as, so the result is the double nearest to a two-place decimal and prints
|
||||
* cleanly. Two places is the precision every provider client already stored;
|
||||
* this keeps it. `src/__tests__/provider-rounding-guard.test.ts` keeps
|
||||
* `toFixed` out of the provider directories.
|
||||
*/
|
||||
export function importValue(n: number): number {
|
||||
if (!Number.isFinite(n)) return n;
|
||||
const magnitude = Math.abs(n);
|
||||
const text = String(magnitude);
|
||||
// A value printed in exponent form is below a millionth or beyond 1e21;
|
||||
// shifting its text would not parse, and plain arithmetic is exact enough.
|
||||
const hundredths = text.includes("e")
|
||||
? Math.round(magnitude * 100)
|
||||
: Math.round(Number(`${text}e2`));
|
||||
return (Math.sign(n) * hundredths) / 100 || 0;
|
||||
}
|
||||
@@ -214,6 +214,44 @@ export async function getAppleHealthDoseFreshness(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest reading entered here, not arrived from Apple Health, for each of
|
||||
* `types` (#1247): the readings the app writes back to the phone. Every
|
||||
* requested type has a key, null when there is none, so a write-back type is
|
||||
* recognisable on the wire even before its first entry.
|
||||
*/
|
||||
export async function getEnteredHereAt(
|
||||
userId: string,
|
||||
types: readonly MeasurementType[],
|
||||
): Promise<Map<string, string | null>> {
|
||||
const out = new Map<string, string | null>(types.map((t) => [t, null]));
|
||||
if (types.length === 0) return out;
|
||||
// One `LIMIT 1` per type down the (user, type, measured_at) index; the
|
||||
// enum casts are literal SQL, everything else is bound.
|
||||
const rows = await prisma.$queryRaw<
|
||||
Array<{ type: string; last_seen: Date | null }>
|
||||
>`
|
||||
SELECT wanted."type"::text AS "type",
|
||||
(
|
||||
SELECT m."measured_at"
|
||||
FROM "measurements" m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."type" = wanted."type"
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."source" <> 'APPLE_HEALTH'::"measurement_source"
|
||||
ORDER BY m."measured_at" DESC
|
||||
LIMIT 1
|
||||
) AS "last_seen"
|
||||
FROM unnest(${[...types]}::"measurement_type"[]) AS wanted("type")
|
||||
`;
|
||||
for (const row of rows) {
|
||||
if (row.last_seen && out.has(row.type)) {
|
||||
out.set(row.type, new Date(row.last_seen).toISOString());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-`(integration, type)` last-value timestamps for the sync
|
||||
* integrations, keyed by `IntegrationKey`. Two grouped queries (measurements +
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The one way a provider client walks a paged collection, and the contract a
|
||||
* history backfill reads off it.
|
||||
*
|
||||
* Every client used to keep its own `while (pages < MAX)` loop, and each one
|
||||
* ended the same way at its ceiling: it returned what it had read, said
|
||||
* nothing, and the backfill around it stamped the connection complete. A
|
||||
* history longer than the ceiling, or a cursor that never advanced, was cut
|
||||
* off for good without a trace. `walkPages` keeps the ceiling, because a
|
||||
* runaway cursor must stop somewhere, but a walk that stops with a page still
|
||||
* pending, or on a cursor it has already followed, reports itself
|
||||
* `truncated`: on the run's wide event, and in the ledger a backfill opens
|
||||
* with `withPageWalkLedger`.
|
||||
*
|
||||
* The backfill contract (`backfillVerdict`), the same for every provider:
|
||||
* - a failed collection is transient: the job throws and pg-boss retries;
|
||||
* - a truncated collection is not complete: the marker is not stamped, and
|
||||
* boot discovery offers the account again, without a retry storm;
|
||||
* - a connection parked for re-authorisation is permanent until the person
|
||||
* reconnects: the runner returns without stamping and without a retry.
|
||||
*
|
||||
* Google Health keeps its own progress-based walk (`google-health/
|
||||
* backfill-bounds.ts`), whose `no_progress` end is this module's runaway case
|
||||
* measured in time rather than in cursors; it moves onto this contract with
|
||||
* its next change. `src/__tests__/page-walk-guard.test.ts` keeps hand-rolled
|
||||
* page loops out of the provider directories.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/** One page as the client read it. A null `next` ends the walk. */
|
||||
export interface WalkPage<C, T> {
|
||||
items: readonly T[];
|
||||
next: C | null | undefined;
|
||||
}
|
||||
|
||||
/** What the walk came to. */
|
||||
export interface PageWalk<T> {
|
||||
items: T[];
|
||||
pages: number;
|
||||
/** Stopped at the ceiling or on a repeated cursor with history left. */
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
const ledger = new AsyncLocalStorage<Set<string>>();
|
||||
|
||||
/**
|
||||
* Walk `fetchPage` from `first` (or no cursor) until a page has no `next`,
|
||||
* at most `maxPages` pages. `label` names the collection (`whoop.recovery`)
|
||||
* on the wide event and in the ledger.
|
||||
*/
|
||||
export async function walkPages<C, T>(
|
||||
label: string,
|
||||
maxPages: number,
|
||||
fetchPage: (cursor: C | undefined, page: number) => Promise<WalkPage<C, T>>,
|
||||
first?: C,
|
||||
): Promise<PageWalk<T>> {
|
||||
const items: T[] = [];
|
||||
const seen = new Set<C>(first === undefined ? [] : [first]);
|
||||
let cursor = first;
|
||||
for (let page = 0; page < maxPages; page += 1) {
|
||||
const result = await fetchPage(cursor, page);
|
||||
items.push(...result.items);
|
||||
const next = result.next;
|
||||
if (next === null || next === undefined) {
|
||||
return { items, pages: page + 1, truncated: false };
|
||||
}
|
||||
if (seen.has(next)) return truncated(label, items, page + 1, "repeat");
|
||||
seen.add(next);
|
||||
cursor = next;
|
||||
}
|
||||
return truncated(label, items, maxPages, "ceiling");
|
||||
}
|
||||
|
||||
function truncated<T>(
|
||||
label: string,
|
||||
items: T[],
|
||||
pages: number,
|
||||
reason: "ceiling" | "repeat",
|
||||
): PageWalk<T> {
|
||||
annotate({
|
||||
action: { name: "integration.page_walk.truncated" },
|
||||
meta: { walk: label, pages, reason },
|
||||
});
|
||||
ledger.getStore()?.add(label);
|
||||
return { items, pages, truncated: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `run` with a ledger of the walks inside it that ended truncated. A
|
||||
* backfill wraps its full sync in this and reads the ledger before it
|
||||
* stamps its marker.
|
||||
*/
|
||||
export async function withPageWalkLedger<R>(
|
||||
run: () => Promise<R>,
|
||||
): Promise<{ result: R; truncated: string[] }> {
|
||||
const walks = new Set<string>();
|
||||
const result = await ledger.run(walks, run);
|
||||
return { result, truncated: [...walks].sort() };
|
||||
}
|
||||
|
||||
/** What a backfill does with its full sync's outcome. */
|
||||
export type BackfillVerdict = "complete" | "retry" | "incomplete";
|
||||
|
||||
export function backfillVerdict(outcome: {
|
||||
failed: boolean;
|
||||
truncated: readonly string[];
|
||||
}): BackfillVerdict {
|
||||
if (outcome.failed) return "retry";
|
||||
return outcome.truncated.length > 0 ? "incomplete" : "complete";
|
||||
}
|
||||
@@ -314,6 +314,12 @@ export interface MetricFreshnessEntry extends MetricFreshnessSample {
|
||||
* `classifyMetricFreshness`.
|
||||
*/
|
||||
stale: boolean;
|
||||
/**
|
||||
* Apple Health types the app writes back to the phone only (#1247): the
|
||||
* newest reading entered here rather than arriving from Apple Health, or
|
||||
* null when there is none. Absent on every other entry.
|
||||
*/
|
||||
lastEnteredHereAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -330,15 +336,27 @@ export function classifyMetricFreshness(
|
||||
samples: readonly MetricFreshnessSample[],
|
||||
verdict: SyncVerdict,
|
||||
now: Date | number = Date.now(),
|
||||
enteredHere?: ReadonlyMap<string, string | null>,
|
||||
): MetricFreshnessEntry[] {
|
||||
const nowMs = now instanceof Date ? now.getTime() : now;
|
||||
return samples.map((sample) => {
|
||||
// A type the app writes back to the phone (#1247): what is entered here
|
||||
// goes to Apple Health and never arrives back, so a person who keeps
|
||||
// their readings here would otherwise watch the type go quiet. The newer
|
||||
// of the two is what says whether the type is still in use.
|
||||
const written = enteredHere?.has(sample.type)
|
||||
? { lastEnteredHereAt: enteredHere.get(sample.type) ?? null }
|
||||
: {};
|
||||
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
|
||||
return { ...sample, stale: false };
|
||||
return { ...sample, ...written, stale: false };
|
||||
}
|
||||
const seen = toMillis(sample.lastSeenAt);
|
||||
const seen = newest(
|
||||
toMillis(sample.lastSeenAt),
|
||||
toMillis(written.lastEnteredHereAt),
|
||||
);
|
||||
return {
|
||||
...sample,
|
||||
...written,
|
||||
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -41,10 +41,6 @@ const AI_JOBS: Record<string, JobGate> = {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
"lib/jobs/reminder/insights-handlers.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
},
|
||||
"lib/jobs/insight-status-generate.ts": {
|
||||
gate: "aiCapabilityForJob",
|
||||
keys: ["statusText"],
|
||||
|
||||
@@ -125,3 +125,24 @@ describe("runFitbitBackfillForUser — verdict-gated marker", () => {
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runFitbitBackfillForUser — a walk that reached its ceiling", () => {
|
||||
it("leaves the marker unset and does not throw", async () => {
|
||||
const { walkPages } = await import("@/lib/integrations/page-walk");
|
||||
syncUserFitbit.mockImplementation(async () => {
|
||||
// A history longer than the walk may read: every page has a next.
|
||||
await walkPages<number, number>(
|
||||
"fitbit.exercise",
|
||||
3,
|
||||
async (cursor) => ({ items: [1], next: (cursor ?? 0) + 1 }),
|
||||
0,
|
||||
);
|
||||
return { imported: 3, failed: false };
|
||||
});
|
||||
|
||||
await expect(runFitbitBackfillForUser("u1")).resolves.toEqual({
|
||||
imported: 3,
|
||||
});
|
||||
expect(prismaMock.fitbitConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* The heavy lane runs one heavy job at a time per process, first come first
|
||||
* served; a waiter pg-boss expired leaves the line, a one-shot keeps its place.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { heavyLane } from "../heavy-lane";
|
||||
import { jobDone } from "../job-outcome";
|
||||
|
||||
type TestJob = Job<{ force?: boolean }>;
|
||||
|
||||
function job(data: { force?: boolean } = {}, signal?: AbortSignal): TestJob {
|
||||
return { id: "j", data, signal } as unknown as TestJob;
|
||||
}
|
||||
|
||||
/** A handler that runs until `finish` is called, recording the overlap. */
|
||||
function gate(log: string[], name: string) {
|
||||
let finish!: () => void;
|
||||
const done = new Promise<void>((r) => (finish = r));
|
||||
const handler = vi.fn(async () => {
|
||||
log.push(`start:${name}`);
|
||||
await done;
|
||||
log.push(`end:${name}`);
|
||||
return jobDone({ jobs: 1 });
|
||||
});
|
||||
return { handler, finish };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("heavyLane", () => {
|
||||
it("runs two heavy jobs of different queues one after the other", async () => {
|
||||
const log: string[] = [];
|
||||
const a = gate(log, "backup");
|
||||
const b = gate(log, "import");
|
||||
const first = heavyLane("data-backup", a.handler)([job()]);
|
||||
const second = heavyLane("apple-health-import-v2", b.handler)([job()]);
|
||||
await tick();
|
||||
expect(log).toEqual(["start:backup"]);
|
||||
a.finish();
|
||||
await first;
|
||||
await tick();
|
||||
expect(log).toEqual(["start:backup", "end:backup", "start:import"]);
|
||||
b.finish();
|
||||
await second;
|
||||
});
|
||||
|
||||
it("lets a light job of a heavy queue pass the line", async () => {
|
||||
const log: string[] = [];
|
||||
const a = gate(log, "walk");
|
||||
const b = gate(log, "warm");
|
||||
const notForced = (j: TestJob) => !j.data.force;
|
||||
const walk = heavyLane(
|
||||
"insight-pregenerate",
|
||||
a.handler,
|
||||
notForced,
|
||||
)([job()]);
|
||||
const warm = heavyLane(
|
||||
"insight-pregenerate",
|
||||
b.handler,
|
||||
notForced,
|
||||
)([job({ force: true })]);
|
||||
await tick();
|
||||
b.finish();
|
||||
a.finish();
|
||||
await Promise.all([walk, warm]);
|
||||
// The warm started while the walk held the lane.
|
||||
expect(log.indexOf("start:warm")).toBeLessThan(log.indexOf("end:walk"));
|
||||
});
|
||||
|
||||
it("drops a waiter pg-boss expired, and keeps a one-shot past its expiry", async () => {
|
||||
const log: string[] = [];
|
||||
const holder = gate(log, "holder");
|
||||
const pass = gate(log, "pass");
|
||||
const upload = gate(log, "upload");
|
||||
const held = heavyLane("data-backup-offhost", holder.handler)([job()]);
|
||||
const expiry = new AbortController();
|
||||
const waitingPass = heavyLane(
|
||||
"data-backup",
|
||||
pass.handler,
|
||||
)([job({}, expiry.signal)]);
|
||||
const uploadExpiry = new AbortController();
|
||||
const waitingUpload = heavyLane(
|
||||
"apple-health-import-v2",
|
||||
upload.handler,
|
||||
)([job({}, uploadExpiry.signal)]);
|
||||
await tick();
|
||||
expiry.abort();
|
||||
uploadExpiry.abort();
|
||||
const outcome = await waitingPass;
|
||||
expect(outcome).toMatchObject({ ok: true, did: { lane_expired: 1 } });
|
||||
expect(pass.handler).not.toHaveBeenCalled();
|
||||
holder.finish();
|
||||
await held;
|
||||
await tick();
|
||||
expect(log).toEqual(["start:holder", "end:holder", "start:upload"]);
|
||||
upload.finish();
|
||||
await waitingUpload;
|
||||
});
|
||||
|
||||
it("frees the lane when a handler throws", async () => {
|
||||
const failing = heavyLane("rollup-full-backfill", async () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await expect(failing([job()])).rejects.toThrow("boom");
|
||||
const next = vi.fn(async () => jobDone({ jobs: 1 }));
|
||||
await expect(
|
||||
heavyLane("data-backup", next)([job()]),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("refuses a queue that is not declared heavy", () => {
|
||||
expect(() =>
|
||||
heavyLane("host-metric-sample", async () => jobDone()),
|
||||
).toThrow(/not in HEAVY_QUEUES/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The nightly measurements check: what it decides from the table's
|
||||
* statistics. It vacuums only when dead rows pile up and never rebuilds; the
|
||||
* operator's run keeps the steps it names.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {},
|
||||
sessionOptionsDisabled: () => false,
|
||||
}));
|
||||
|
||||
import {
|
||||
DEAD_ROW_VACUUM_FLOOR,
|
||||
resolveSteps,
|
||||
tableHealth,
|
||||
vacuumDue,
|
||||
type TableHealth,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
const health = (over: Partial<TableHealth> = {}): TableHealth => ({
|
||||
liveRows: 1_000_000,
|
||||
deadRows: 0,
|
||||
heapBytes: 0,
|
||||
bloatFactor: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("vacuumDue", () => {
|
||||
it("vacuums once dead rows pass a tenth of the live ones", () => {
|
||||
expect(vacuumDue(health({ deadRows: 100_001 }))).toBe(true);
|
||||
expect(vacuumDue(health({ deadRows: 99_999 }))).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a small table alone below the floor", () => {
|
||||
expect(
|
||||
vacuumDue(health({ liveRows: 100, deadRows: DEAD_ROW_VACUUM_FLOOR - 1 })),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSteps", () => {
|
||||
it("gives the nightly tick a vacuum when one is due and never a rebuild", () => {
|
||||
expect(resolveSteps({}, health({ deadRows: 500_000 }))).toEqual({
|
||||
vacuum: true,
|
||||
reindex: false,
|
||||
});
|
||||
expect(resolveSteps(undefined, health())).toEqual({
|
||||
vacuum: false,
|
||||
reindex: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the operator's steps whatever the statistics say", () => {
|
||||
expect(resolveSteps({ vacuum: false, reindex: true }, health())).toEqual({
|
||||
vacuum: false,
|
||||
reindex: true,
|
||||
});
|
||||
expect(resolveSteps({ vacuum: true, reindex: false }, health())).toEqual({
|
||||
vacuum: true,
|
||||
reindex: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("tableHealth", () => {
|
||||
it("measures the heap against what the live rows need", async () => {
|
||||
const db = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
// 1,000 rows of 72 bytes plus 28 of overhead at fill factor 0.85
|
||||
// need about 117,647 bytes; the heap holds 1,176,470.
|
||||
rows: [{ live: "1000", dead: "50", heap: "1176470", width: "72" }],
|
||||
}),
|
||||
};
|
||||
expect(await tableHealth(db as never)).toEqual({
|
||||
liveRows: 1000,
|
||||
deadRows: 50,
|
||||
heapBytes: 1176470,
|
||||
bloatFactor: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("has no factor before the table has statistics", async () => {
|
||||
const db = {
|
||||
query: vi.fn().mockResolvedValue({
|
||||
rows: [{ live: "0", dead: "0", heap: "8192", width: null }],
|
||||
}),
|
||||
};
|
||||
expect((await tableHealth(db as never)).bloatFactor).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -175,44 +175,53 @@ 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.
|
||||
describe("the nightly status batch is the one status cron", () => {
|
||||
const workerSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder/register-status.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const RETIRED = [
|
||||
"insights-blood-pressure-status",
|
||||
"insights-weight-status",
|
||||
"insights-pulse-status",
|
||||
"insights-bmi-status",
|
||||
"insights-mood-status",
|
||||
"insights-medication-compliance-status",
|
||||
];
|
||||
|
||||
it("registers the queue in the allQueues createQueue loop", () => {
|
||||
it("registers and schedules the 02:00 batch with the nightly pass options", () => {
|
||||
const match = workerSrc.match(/const allQueues\s*=\s*\[([\s\S]*?)\];/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![1]).toMatch(/\bMOOD_STATUS_QUEUE\b/);
|
||||
});
|
||||
|
||||
it("schedules the 02:30 cron with the nightly pass options (retries and expiry)", () => {
|
||||
expect(match![1]).toMatch(/\bGENERAL_STATUS_QUEUE\b/);
|
||||
expect(workerSrc).toMatch(
|
||||
/\[\s*MOOD_STATUS_QUEUE\s*,\s*MOOD_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/,
|
||||
/\[\s*GENERAL_STATUS_QUEUE\s*,\s*GENERAL_STATUS_CRON\s*,\s*nightlyInsightPassOptions\s*\]/,
|
||||
);
|
||||
expect(workerSrc).toMatch(/MOOD_STATUS_CRON\s*=\s*"30 2 \* \* \*"/);
|
||||
expect(workerSrc).toMatch(/GENERAL_STATUS_CRON\s*=\s*"0 2 \* \* \*"/);
|
||||
});
|
||||
|
||||
it("registers a createAndWork handler for the queue", () => {
|
||||
expect(workerSrc).toMatch(/createAndWork[\s\S]{0,120}MOOD_STATUS_QUEUE/);
|
||||
it("retires the six per-metric crons on an instance that still has them", () => {
|
||||
const retired = workerSrc.match(
|
||||
/const RETIRED_STATUS_QUEUES\s*=\s*\[([\s\S]*?)\]/,
|
||||
);
|
||||
expect(retired).not.toBeNull();
|
||||
for (const name of RETIRED) expect(retired![1]).toContain(`"${name}"`);
|
||||
expect(workerSrc).toMatch(
|
||||
/await retireQueues\(boss,\s*RETIRED_STATUS_QUEUES\)/,
|
||||
);
|
||||
const live = workerSrc.replace(retired![0], "");
|
||||
for (const name of RETIRED) expect(live).not.toContain(`"${name}"`);
|
||||
});
|
||||
|
||||
it("drives all seven status crons through the shared discovery", () => {
|
||||
it("drives the batch through the shared discovery", () => {
|
||||
const statusSrc = fs.readFileSync(
|
||||
path.resolve(__dirname, "../reminder/insights-handlers.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(statusSrc).toMatch(/findStatusCronCandidates\(prisma\)/);
|
||||
// The old iterate-every-user discovery must be gone from the status
|
||||
// handlers (the WHOOP/data-backup cohort reads keep their own scans).
|
||||
const statusBlock = statusSrc.slice(
|
||||
statusSrc.indexOf("async function runStatusCronGenerate"),
|
||||
statusSrc.indexOf("async function runStatusBatchCron"),
|
||||
statusSrc.indexOf("async function handleInsightPregenerateJob"),
|
||||
);
|
||||
expect(statusBlock).toMatch(/findStatusCronCandidates\(prisma\)/);
|
||||
expect(statusBlock).not.toMatch(/prisma\.user\.findMany/);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { isReauthRequired } from "@/lib/integrations/status";
|
||||
import {
|
||||
backfillVerdict,
|
||||
withPageWalkLedger,
|
||||
} from "@/lib/integrations/page-walk";
|
||||
import { syncUserFitbit } from "@/lib/fitbit/sync";
|
||||
|
||||
export const FITBIT_BACKFILL_QUEUE = "fitbit-backfill";
|
||||
@@ -39,14 +43,17 @@ export interface FitbitBackfillPayload {
|
||||
* `runWhoopBackfillForUser` (whose identical unconditional-stamp bug is fixed
|
||||
* separately — this is the template for that port).
|
||||
*
|
||||
* Verdict-gated: a partial hard failure (one collection 400ing, a write
|
||||
* failing, a dead token) THROWS so pg-boss retries the job — stamping the
|
||||
* marker over a partial walk would freeze the history gap in forever and the
|
||||
* configured `retryLimit` would be dead code. A connection parked at
|
||||
* `error_reauth` is NOT clean either, but throwing against a dead grant would
|
||||
* just burn the retry budget on the same no-op: return WITHOUT stamping — the
|
||||
* boot discovery re-enqueues the account on the next boot, and the stamp lands
|
||||
* once the user reconnects and a clean walk completes.
|
||||
* Verdict-gated (`backfillVerdict`, `integrations/page-walk.ts`): a partial
|
||||
* hard failure (one collection 400ing, a write failing, a dead token) THROWS
|
||||
* so pg-boss retries the job — stamping the marker over a partial walk would
|
||||
* freeze the history gap in forever and the configured `retryLimit` would be
|
||||
* dead code. A collection whose walk reached its page ceiling leaves the
|
||||
* marker unset WITHOUT a retry: nothing failed, and boot discovery offers the
|
||||
* account again. A connection parked at `error_reauth` is NOT clean either,
|
||||
* but throwing against a dead grant would just burn the retry budget on the
|
||||
* same no-op: return WITHOUT stamping — the boot discovery re-enqueues the
|
||||
* account on the next boot, and the stamp lands once the user reconnects and
|
||||
* a clean walk completes.
|
||||
*/
|
||||
export async function runFitbitBackfillForUser(
|
||||
userId: string,
|
||||
@@ -61,9 +68,28 @@ export async function runFitbitBackfillForUser(
|
||||
return { imported: 0 };
|
||||
}
|
||||
|
||||
const { imported, failed } = await syncUserFitbit(userId, { fullSync: true });
|
||||
const {
|
||||
result: { imported, failed },
|
||||
truncated,
|
||||
} = await withPageWalkLedger(() =>
|
||||
syncUserFitbit(userId, { fullSync: true }),
|
||||
);
|
||||
const verdict = backfillVerdict({ failed, truncated });
|
||||
|
||||
if (failed) {
|
||||
if (verdict === "incomplete") {
|
||||
// A collection longer than its walk may read in one run: the history is
|
||||
// not complete, but nothing failed, so no retry. Boot discovery offers
|
||||
// the account again while the marker stays unset.
|
||||
annotate({
|
||||
action: {
|
||||
name: "fitbit.backfill.truncated",
|
||||
details: { imported, collections: truncated.join(",") },
|
||||
},
|
||||
});
|
||||
return { imported };
|
||||
}
|
||||
|
||||
if (verdict === "retry") {
|
||||
// Surface through the pg-boss retry path (retryLimit 3, backoff, set by
|
||||
// `integrationBackfillSourceOptions`). If the failure parked the connection
|
||||
// at error_reauth meanwhile, the next attempt takes the reauth return above
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* One heavy job at a time per process.
|
||||
*
|
||||
* The queues in `HEAVY_QUEUES` each hold a large working set in the heap while
|
||||
* they run: an export being parsed, a whole account being restored or
|
||||
* serialised, a provider pass over every account. Web and worker share one
|
||||
* process and one heap limit, and these jobs used to run side by side when
|
||||
* their schedules or a person's upload happened to meet, so the peak was the
|
||||
* sum of whatever overlapped rather than the largest one. Under the lane a
|
||||
* heavy job waits for the one before it, first come first served.
|
||||
*
|
||||
* The lane lives in the process, not the database: the heap it protects is
|
||||
* the process's own, and another process has a heap of its own.
|
||||
*
|
||||
* A job waits until its turn or until pg-boss gives up on it. At the job's
|
||||
* expiry pg-boss aborts its signal and applies the queue's retry policy, so a
|
||||
* waiter that sees the abort leaves the line without running and reports
|
||||
* `lane_expired`; the retry takes its place. A queue bound with `lockedPass`
|
||||
* holds that lock outside the lane, so the retry finds the lock taken while
|
||||
* the original still waits instead of queueing a second time
|
||||
* (`heavy-lane-guard.test.ts`). The exception is a one-shot: its input lives
|
||||
* outside pg-boss (a staged upload, a restore row) and nothing retries it, so
|
||||
* it keeps its place past the expiry and runs when its turn comes, as pg-boss
|
||||
* lets a handler do.
|
||||
*/
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export interface HeavyQueue {
|
||||
/** What makes the job heavy. */
|
||||
why: string;
|
||||
/** A one-shot keeps its place past its expiry; see the module note. */
|
||||
oneShot?: true;
|
||||
}
|
||||
|
||||
/** The queues that take the lane. */
|
||||
export const HEAVY_QUEUES: Readonly<Record<string, HeavyQueue>> = {
|
||||
"apple-health-import-v2": {
|
||||
why: "Parses an export of up to several gigabytes.",
|
||||
oneShot: true,
|
||||
},
|
||||
"health-connect-import": {
|
||||
why: "Reads a Health Connect export database.",
|
||||
oneShot: true,
|
||||
},
|
||||
"backup-restore": {
|
||||
why: "Holds a batch of a whole account's readings per insert.",
|
||||
oneShot: true,
|
||||
},
|
||||
"data-backup": { why: "Serialises every account's whole record." },
|
||||
"data-backup-offhost": {
|
||||
why: "Serialises and uploads every account's record.",
|
||||
},
|
||||
"data-restore-drill": {
|
||||
why: "Stream-parses every account's newest off-host object.",
|
||||
},
|
||||
"rollup-full-backfill": {
|
||||
why: "Folds five years of one account's readings into buckets.",
|
||||
},
|
||||
"insights-general-status": {
|
||||
why: "Builds every status snapshot for every account, a provider call each.",
|
||||
},
|
||||
"insight-pregenerate": {
|
||||
why: "The nightly walk: a comprehensive snapshot and generation per account.",
|
||||
},
|
||||
"period-narrative-warm": {
|
||||
why: "The boundary-night walk: a narrative per account and period.",
|
||||
},
|
||||
};
|
||||
|
||||
type Waiter = () => void;
|
||||
|
||||
const NEVER = new AbortController().signal;
|
||||
|
||||
const waiting: Waiter[] = [];
|
||||
let busy = false;
|
||||
|
||||
/** Resolves true once the lane is ours, false if `signal` aborts first. */
|
||||
function enter(signal: AbortSignal): Promise<boolean> {
|
||||
if (!busy) {
|
||||
busy = true;
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const onAbort = () => {
|
||||
const at = waiting.indexOf(grant);
|
||||
if (at >= 0) waiting.splice(at, 1);
|
||||
resolve(false);
|
||||
};
|
||||
const grant: Waiter = () => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
};
|
||||
if (signal.aborted) return resolve(false);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
waiting.push(grant);
|
||||
});
|
||||
}
|
||||
|
||||
function leave(): void {
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
else busy = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `handler` so the jobs of `queue` run in the lane. `inLane` picks the
|
||||
* jobs that are heavy where a queue carries light ones too (a single
|
||||
* account's warm beside the nightly walk); a batch with none of them runs at
|
||||
* once.
|
||||
*/
|
||||
export function heavyLane<J extends Job<unknown>>(
|
||||
queue: string,
|
||||
handler: (jobs: J[]) => Promise<JobOutcome>,
|
||||
inLane: (job: J) => boolean = () => true,
|
||||
): (jobs: J[]) => Promise<JobOutcome> {
|
||||
const heavy = HEAVY_QUEUES[queue];
|
||||
if (!heavy) throw new Error(`heavyLane: ${queue} is not in HEAVY_QUEUES`);
|
||||
return async (jobs) => {
|
||||
if (!jobs.some(inLane)) return handler(jobs);
|
||||
const queuedAt = Date.now();
|
||||
const entered = await enter(
|
||||
heavy.oneShot ? NEVER : AbortSignal.any(signalsOf(jobs)),
|
||||
);
|
||||
const waitedMs = Date.now() - queuedAt;
|
||||
annotate({
|
||||
action: {
|
||||
name: entered ? "job.heavy_lane.entered" : "job.heavy_lane.expired",
|
||||
},
|
||||
meta: { queue, waited_ms: waitedMs },
|
||||
});
|
||||
if (!entered) return jobDone({ lane_expired: jobs.length });
|
||||
try {
|
||||
return await handler(jobs);
|
||||
} finally {
|
||||
leave();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function signalsOf(jobs: readonly Job<unknown>[]): AbortSignal[] {
|
||||
return jobs.flatMap((job) => (job.signal ? [job.signal] : []));
|
||||
}
|
||||
@@ -749,7 +749,7 @@ export async function runInsightPregenerate(
|
||||
// (`insights:${userId}`), so this never starves a user's manual
|
||||
// regenerate quota. The gate bounds the comprehensive cost only: a
|
||||
// blocked user still gets the refill-only status warm below, because
|
||||
// the 02:xx status crons already skipped every pregenerate candidate
|
||||
// the 02:00 status batch already skipped every pregenerate candidate
|
||||
// on the assumption that THIS pass warms their cards — exiting early
|
||||
// here left those cards cold until the first on-visit generation.
|
||||
const budget = briefing.available
|
||||
@@ -906,7 +906,7 @@ export async function runInsightPregenerate(
|
||||
// generator's content-hash gate turns a same-data regeneration into a
|
||||
// timestamp refresh with no provider call. The warm runs on every
|
||||
// comprehensive outcome (budget-blocked, failed, timed-out,
|
||||
// consent-skipped included) because the 02:xx status crons skip every
|
||||
// consent-skipped included) because the 02:00 status batch skips every
|
||||
// pregenerate candidate on the assumption that THIS pass covers them.
|
||||
//
|
||||
// Nothing to warm when the status notes themselves are unavailable.
|
||||
|
||||
@@ -57,6 +57,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"assessments_warmed",
|
||||
"auto_resolved",
|
||||
"backed",
|
||||
// The nightly measurements check: heap size over what the live rows need.
|
||||
"bloat_factor",
|
||||
"buckets_collapsed",
|
||||
"buckets_recomputed",
|
||||
"budget_blocked",
|
||||
@@ -78,6 +80,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"days_skipped",
|
||||
"days_skipped_no_tombstones",
|
||||
"days_stored",
|
||||
"dead_rows",
|
||||
// Tombstone purge: accounts left for the next run because a restore of
|
||||
// the account was running.
|
||||
"deferred_accounts",
|
||||
@@ -140,6 +143,8 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = new Set([
|
||||
"inventory_expired_count",
|
||||
"inventory_items_migrated",
|
||||
"jobs",
|
||||
// A heavy job pg-boss expired while it waited for the heavy lane.
|
||||
"lane_expired",
|
||||
"legacy_rows_soft_deleted",
|
||||
"linked",
|
||||
"malformed",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* What the admin card shows about the measurement table maintenance: the
|
||||
* newest run and where it stands, whether the compaction-tombstone purge is
|
||||
* still at work (the run refuses to start while it is), and the table's size
|
||||
* right now.
|
||||
* newest run the operator started and where it stands, whether the
|
||||
* compaction-tombstone purge is still at work (the run refuses to start while
|
||||
* it is), and the table's size right now.
|
||||
*
|
||||
* Read straight from `pgboss.job`, the same source `job-failures.ts` reads,
|
||||
* and fail-soft the same way: a web-only deployment has no `pgboss` schema,
|
||||
@@ -90,6 +90,9 @@ export async function readMeasurementMaintenanceStatus(): Promise<MeasurementMai
|
||||
output->'did'->>'outcome' AS outcome
|
||||
FROM pgboss.job
|
||||
WHERE name = ${MEASUREMENT_MAINTENANCE_QUEUE}
|
||||
-- The operator's runs name their steps; the nightly check's tick
|
||||
-- names none and reports through its wide event instead.
|
||||
AND (data ? 'vacuum' OR data ? 'reindex')
|
||||
ORDER BY created_on DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
/**
|
||||
* Operator-triggered maintenance of the `measurements` table (v1.42).
|
||||
* Maintenance of the `measurements` table (v1.42).
|
||||
*
|
||||
* `VACUUM (ANALYZE)` and a `REINDEX INDEX CONCURRENTLY` per index, largest
|
||||
* first, after the compaction-tombstone purge has shrunk the table. Neither
|
||||
* statement can run inside a transaction, so this is a job rather than a
|
||||
* Prisma migration. Started only from `POST /api/admin/maintenance/measurements`
|
||||
* (cookie-only admin), never on a schedule: the operator picks a quiet window.
|
||||
* Runbook: `docs/ops/measurement-maintenance.md`.
|
||||
* Prisma migration. Runbook: `docs/ops/measurement-maintenance.md`.
|
||||
*
|
||||
* Two ways in:
|
||||
* - The operator, from `POST /api/admin/maintenance/measurements`
|
||||
* (cookie-only admin), with the steps named; the rebuild only ever runs
|
||||
* this way, in a window the operator picks.
|
||||
* - The nightly check (`MEASUREMENT_MAINTENANCE_CRON`, a tick without
|
||||
* steps). It reads the table's dead and live rows and vacuums only when
|
||||
* the dead ones pass `vacuumDue`. A plain VACUUM takes no lock that stops
|
||||
* reads or writes. It also measures how far the heap has grown past its
|
||||
* live rows (`TableHealth.bloatFactor`); only a rewrite (`VACUUM FULL`,
|
||||
* which locks the table) gives that space back, so past
|
||||
* `TABLE_BLOAT_WARN_FACTOR` the run warns instead of acting.
|
||||
*
|
||||
* Why the indexes: after the purge the heap is small, but a B-tree does not
|
||||
* give back the pages its deleted entries held. VACUUM marks the heap space
|
||||
@@ -59,12 +69,51 @@ export const MEASUREMENT_MAINTENANCE_SEND_OPTIONS = {
|
||||
singletonKey: MEASUREMENT_MAINTENANCE_SINGLETON_KEY,
|
||||
} as const;
|
||||
|
||||
/** Which steps the operator asked for. */
|
||||
/** Which steps the operator asked for. The nightly tick names none. */
|
||||
export interface MeasurementMaintenancePayload {
|
||||
vacuum?: boolean;
|
||||
reindex?: boolean;
|
||||
}
|
||||
|
||||
/** The steps a run takes, once resolved. */
|
||||
export interface MaintenanceSteps {
|
||||
vacuum: boolean;
|
||||
reindex: boolean;
|
||||
}
|
||||
|
||||
/** 03:40 Europe/Berlin, inside the nightly maintenance band. */
|
||||
export const MEASUREMENT_MAINTENANCE_CRON = "40 3 * * *";
|
||||
|
||||
/** Dead rows, as a share of live ones, past which the nightly check vacuums. */
|
||||
export const DEAD_ROW_VACUUM_SHARE = 0.1;
|
||||
|
||||
/** Fewer dead rows than this are never worth a vacuum of their own. */
|
||||
export const DEAD_ROW_VACUUM_FLOOR = 10_000;
|
||||
|
||||
/**
|
||||
* Heap size against what the live rows need, past which the run warns that
|
||||
* only a rewrite gives the space back.
|
||||
*/
|
||||
export const TABLE_BLOAT_WARN_FACTOR = 3;
|
||||
|
||||
/** Bytes a heap row costs beyond its data: header, item pointer, alignment. */
|
||||
const ROW_OVERHEAD_BYTES = 28;
|
||||
|
||||
/** The fill factor migration 0412 gives the table. */
|
||||
const FILL_FACTOR = 0.85;
|
||||
|
||||
/** What the statistics say about the table's heap. */
|
||||
export interface TableHealth {
|
||||
liveRows: number;
|
||||
deadRows: number;
|
||||
heapBytes: number;
|
||||
/**
|
||||
* Heap bytes over what the live rows need at the planner's average row
|
||||
* width and the table's fill factor; null before the table has statistics.
|
||||
*/
|
||||
bloatFactor: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session settings of the maintenance connection.
|
||||
*
|
||||
@@ -113,7 +162,12 @@ export interface MaintenanceStep {
|
||||
}
|
||||
|
||||
export interface MeasurementMaintenanceSummary {
|
||||
outcome: "completed" | "refused_purge_running" | "already_running" | "failed";
|
||||
outcome:
|
||||
| "completed"
|
||||
| "nothing_due"
|
||||
| "refused_purge_running"
|
||||
| "already_running"
|
||||
| "failed";
|
||||
steps: MaintenanceStep[];
|
||||
tableBytesBefore: number;
|
||||
tableBytesAfter: number;
|
||||
@@ -143,6 +197,62 @@ async function relationSizes(
|
||||
};
|
||||
}
|
||||
|
||||
/** The table's heap as the statistics collector and the planner see it. */
|
||||
export async function tableHealth(
|
||||
db: MaintenanceConnection,
|
||||
): Promise<TableHealth> {
|
||||
const { rows } = await db.query<{
|
||||
live: string;
|
||||
dead: string;
|
||||
heap: string;
|
||||
width: string | null;
|
||||
}>(
|
||||
`SELECT s.n_live_tup::text AS live,
|
||||
s.n_dead_tup::text AS dead,
|
||||
pg_relation_size('measurements'::regclass)::text AS heap,
|
||||
(SELECT sum(avg_width)::text FROM pg_stats
|
||||
WHERE schemaname = s.schemaname AND tablename = 'measurements') AS width
|
||||
FROM pg_stat_user_tables s
|
||||
WHERE s.relid = 'measurements'::regclass`,
|
||||
);
|
||||
const liveRows = Number(rows[0]?.live ?? 0);
|
||||
const heapBytes = Number(rows[0]?.heap ?? 0);
|
||||
const width = Number(rows[0]?.width ?? 0);
|
||||
const needed = (liveRows * (width + ROW_OVERHEAD_BYTES)) / FILL_FACTOR;
|
||||
return {
|
||||
liveRows,
|
||||
deadRows: Number(rows[0]?.dead ?? 0),
|
||||
heapBytes,
|
||||
bloatFactor:
|
||||
liveRows > 0 && width > 0
|
||||
? Math.round((heapBytes / needed) * 10) / 10
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the dead rows are worth a vacuum of their own tonight. */
|
||||
export function vacuumDue(health: TableHealth): boolean {
|
||||
return (
|
||||
health.deadRows >= DEAD_ROW_VACUUM_FLOOR &&
|
||||
health.deadRows > health.liveRows * DEAD_ROW_VACUUM_SHARE
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The steps a delivery takes: the operator's as asked, the nightly tick's a
|
||||
* vacuum when one is due and never a rebuild.
|
||||
*/
|
||||
export function resolveSteps(
|
||||
payload: MeasurementMaintenancePayload | undefined,
|
||||
health: TableHealth,
|
||||
): MaintenanceSteps {
|
||||
const named =
|
||||
typeof payload?.vacuum === "boolean" ||
|
||||
typeof payload?.reindex === "boolean";
|
||||
if (!named) return { vacuum: vacuumDue(health), reindex: false };
|
||||
return { vacuum: payload?.vacuum ?? true, reindex: payload?.reindex ?? true };
|
||||
}
|
||||
|
||||
async function measurementIndexes(
|
||||
db: MaintenanceConnection,
|
||||
): Promise<IndexRow[]> {
|
||||
@@ -223,7 +333,7 @@ async function dropInvalidCopies(
|
||||
*/
|
||||
export async function runMeasurementMaintenance(
|
||||
db: MaintenanceConnection,
|
||||
payload: MeasurementMaintenancePayload,
|
||||
payload: MaintenanceSteps,
|
||||
options: { shouldStop?: () => boolean } = {},
|
||||
): Promise<MeasurementMaintenanceSummary> {
|
||||
const before = await relationSizes(db);
|
||||
@@ -240,6 +350,8 @@ export async function runMeasurementMaintenance(
|
||||
indexBytesAfter: after.indexes,
|
||||
});
|
||||
|
||||
if (!payload.vacuum && !payload.reindex) return summary("nothing_due");
|
||||
|
||||
// The queue table exists wherever a worker has started; a database without
|
||||
// one has no purge to wait for.
|
||||
const { rows: queueTable } = await db.query<{ present: boolean }>(
|
||||
@@ -327,10 +439,6 @@ export async function handleMeasurementMaintenance(
|
||||
jobs: Job<MeasurementMaintenancePayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
const job = jobs[0];
|
||||
const payload: MeasurementMaintenancePayload = {
|
||||
vacuum: job?.data?.vacuum ?? true,
|
||||
reindex: job?.data?.reindex ?? true,
|
||||
};
|
||||
return withBackgroundEvent("job.measurement_maintenance", async (evt) => {
|
||||
const startedAt = Date.now();
|
||||
const guarded = await withJobLock(
|
||||
@@ -350,7 +458,10 @@ export async function handleMeasurementMaintenance(
|
||||
await client.query(statement);
|
||||
}
|
||||
}
|
||||
return await runMeasurementMaintenance(client, payload);
|
||||
const health = await tableHealth(client);
|
||||
const steps = resolveSteps(job?.data, health);
|
||||
const summary = await runMeasurementMaintenance(client, steps);
|
||||
return { summary, health, steps };
|
||||
} finally {
|
||||
await client
|
||||
.end()
|
||||
@@ -363,20 +474,26 @@ export async function handleMeasurementMaintenance(
|
||||
}
|
||||
},
|
||||
);
|
||||
const result: MeasurementMaintenanceSummary = guarded.ran
|
||||
? guarded.result
|
||||
: {
|
||||
outcome: "already_running",
|
||||
steps: [],
|
||||
tableBytesBefore: 0,
|
||||
tableBytesAfter: 0,
|
||||
indexBytesBefore: 0,
|
||||
indexBytesAfter: 0,
|
||||
};
|
||||
if (!guarded.ran) {
|
||||
evt.addMeta("maintenance_outcome", "already_running");
|
||||
return jobDone({ outcome: "already_running" });
|
||||
}
|
||||
const { summary: result, health, steps } = guarded.result;
|
||||
|
||||
evt.addMeta("maintenance_outcome", result.outcome);
|
||||
evt.addMeta("maintenance_vacuum", payload.vacuum);
|
||||
evt.addMeta("maintenance_reindex", payload.reindex);
|
||||
evt.addMeta("maintenance_vacuum", steps.vacuum);
|
||||
evt.addMeta("maintenance_reindex", steps.reindex);
|
||||
evt.addMeta("maintenance_live_rows", health.liveRows);
|
||||
evt.addMeta("maintenance_dead_rows", health.deadRows);
|
||||
evt.addMeta("maintenance_heap_bytes", health.heapBytes);
|
||||
if (health.bloatFactor !== null) {
|
||||
evt.addMeta("maintenance_bloat_factor", health.bloatFactor);
|
||||
if (health.bloatFactor > TABLE_BLOAT_WARN_FACTOR) {
|
||||
evt.addWarning(
|
||||
`measurements heap is ${health.bloatFactor}x what its live rows need; only VACUUM FULL in a maintenance window gives the space back`,
|
||||
);
|
||||
}
|
||||
}
|
||||
evt.addMeta("maintenance_table_bytes_before", result.tableBytesBefore);
|
||||
evt.addMeta("maintenance_table_bytes_after", result.tableBytesAfter);
|
||||
evt.addMeta("maintenance_index_bytes_before", result.indexBytesBefore);
|
||||
@@ -412,6 +529,9 @@ export async function handleMeasurementMaintenance(
|
||||
processed: result.steps.filter((s) => s.ok).length,
|
||||
failed: result.steps.filter((s) => !s.ok).length,
|
||||
duration_ms: Date.now() - startedAt,
|
||||
dead_rows: health.deadRows,
|
||||
// Zero before the table has statistics to measure against.
|
||||
bloat_factor: health.bloatFactor ?? 0,
|
||||
};
|
||||
if (result.outcome === "failed") {
|
||||
const firstError = result.steps.find((s) => !s.ok);
|
||||
|
||||
@@ -72,15 +72,6 @@ export type QueueRuntime =
|
||||
|
||||
const short = (why: string): QueueRuntime => ({ runtime: "short", why });
|
||||
|
||||
const nightlyStatusPass = (fn: string): QueueRuntime => ({
|
||||
runtime: "long",
|
||||
expireInSeconds: NIGHTLY_INSIGHT_PASS_EXPIRE_SECONDS,
|
||||
expiryVia: ["nightlyInsightPassOptions"],
|
||||
stop: { file: "lib/jobs/reminder/insights-handlers.ts", fn },
|
||||
exclusive: "lockedPass",
|
||||
why: "Nightly status pass: one provider call per candidate account, serially, over the whole instance.",
|
||||
});
|
||||
|
||||
const cohortSync = (provider: string): QueueRuntime =>
|
||||
short(
|
||||
`${provider} sync: a recent window per connection (incremental since the last sync); a webhook job is one connection.`,
|
||||
@@ -140,17 +131,17 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
"strava-backfill": forwarder,
|
||||
|
||||
// ── Status and insights ────────────────────────────────────────────
|
||||
"insights-general-status": nightlyStatusPass("handleGeneralStatusGenerate"),
|
||||
"insights-blood-pressure-status": nightlyStatusPass(
|
||||
"handleBloodPressureStatusGenerate",
|
||||
),
|
||||
"insights-weight-status": nightlyStatusPass("handleWeightStatusGenerate"),
|
||||
"insights-pulse-status": nightlyStatusPass("handlePulseStatusGenerate"),
|
||||
"insights-bmi-status": nightlyStatusPass("handleBmiStatusGenerate"),
|
||||
"insights-mood-status": nightlyStatusPass("handleMoodStatusGenerate"),
|
||||
"insights-medication-compliance-status": nightlyStatusPass(
|
||||
"handleMedicationComplianceStatusGenerate",
|
||||
),
|
||||
"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,
|
||||
@@ -400,7 +391,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
expiryVia: ["MEASUREMENT_MAINTENANCE_SEND_OPTIONS"],
|
||||
stop: {
|
||||
oneShot:
|
||||
"VACUUM and REINDEX CONCURRENTLY cannot be resumed mid-statement; the send sets retryLimit 0 and the operator re-triggers.",
|
||||
"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",
|
||||
@@ -409,7 +400,7 @@ export const QUEUE_RUNTIME: Readonly<Record<string, QueueRuntime>> = {
|
||||
fn: "MEASUREMENT_MAINTENANCE_SEND_OPTIONS",
|
||||
},
|
||||
},
|
||||
why: "Rebuilds every index of the largest table, one at a time.",
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The registrar's two boot-time decisions that reach pg-boss on every
|
||||
* instance: how often an idle worker polls, and which retired queues are
|
||||
* removed.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { $executeRaw: vi.fn().mockResolvedValue(0) },
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
import { jobDone } from "@/lib/jobs/job-outcome";
|
||||
import {
|
||||
CRON_FED_POLL_SECONDS,
|
||||
createAndSchedule,
|
||||
createAndWork,
|
||||
retireQueues,
|
||||
} from "../registrar-shared";
|
||||
|
||||
function fakeBoss() {
|
||||
return {
|
||||
createQueue: vi.fn().mockResolvedValue(undefined),
|
||||
updateQueue: vi.fn().mockResolvedValue(undefined),
|
||||
schedule: vi.fn().mockResolvedValue(undefined),
|
||||
work: vi.fn().mockResolvedValue("worker-id"),
|
||||
unschedule: vi.fn().mockResolvedValue(undefined),
|
||||
deleteQueue: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const handler = async () => jobDone({ jobs: 1 });
|
||||
|
||||
function pollOf(boss: ReturnType<typeof fakeBoss>, queue: string) {
|
||||
const call = boss.work.mock.calls.find((c) => c[0] === queue);
|
||||
return (call?.[1] as { pollingIntervalSeconds?: number })
|
||||
.pollingIntervalSeconds;
|
||||
}
|
||||
|
||||
describe("idle polling", () => {
|
||||
it("polls slowly on a queue only its cron feeds, at pg-boss's default otherwise", async () => {
|
||||
const boss = fakeBoss();
|
||||
await createAndSchedule(
|
||||
boss as never,
|
||||
["test-cron-only", "pr-detection", "test-send-only"],
|
||||
[
|
||||
["test-cron-only", "0 3 * * *"],
|
||||
// Scheduled, and sent per account on every ingest.
|
||||
["pr-detection", "*/30 * * * *"],
|
||||
],
|
||||
);
|
||||
for (const queue of ["test-cron-only", "pr-detection", "test-send-only"]) {
|
||||
await createAndWork(
|
||||
boss as never,
|
||||
queue,
|
||||
{ localConcurrency: 1 },
|
||||
handler,
|
||||
);
|
||||
}
|
||||
expect(pollOf(boss, "test-cron-only")).toBe(CRON_FED_POLL_SECONDS);
|
||||
expect(pollOf(boss, "pr-detection")).toBeUndefined();
|
||||
expect(pollOf(boss, "test-send-only")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a poll the binding names", async () => {
|
||||
const boss = fakeBoss();
|
||||
await createAndSchedule(
|
||||
boss as never,
|
||||
["test-named-poll"],
|
||||
[["test-named-poll", "0 4 * * *"]],
|
||||
);
|
||||
await createAndWork(
|
||||
boss as never,
|
||||
"test-named-poll",
|
||||
{ localConcurrency: 1, pollingIntervalSeconds: 5 },
|
||||
handler,
|
||||
);
|
||||
expect(pollOf(boss, "test-named-poll")).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retireQueues", () => {
|
||||
it("removes the schedule before the queue, and one failure does not stop the rest", async () => {
|
||||
const boss = fakeBoss();
|
||||
const order: string[] = [];
|
||||
boss.unschedule.mockImplementation(async (name: string) => {
|
||||
order.push(`unschedule:${name}`);
|
||||
});
|
||||
boss.deleteQueue.mockImplementation(async (name: string) => {
|
||||
order.push(`delete:${name}`);
|
||||
if (name === "old-a") throw new Error("locked");
|
||||
});
|
||||
await expect(
|
||||
retireQueues(boss as never, ["old-a", "old-b"]),
|
||||
).resolves.toBeUndefined();
|
||||
expect(order).toEqual([
|
||||
"unschedule:old-a",
|
||||
"delete:old-a",
|
||||
"unschedule:old-b",
|
||||
"delete:old-b",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Nightly insight status crons (general / blood pressure / weight / pulse / BMI / mood / medication compliance) plus the insight pregenerate and per-user status-generate handlers.
|
||||
* The nightly status batch plus the insight pregenerate and per-user
|
||||
* status-generate handlers.
|
||||
*
|
||||
* Extracted from reminder-worker.ts, which owns the queue names, cron
|
||||
* schedules, and boss.work registrations.
|
||||
@@ -23,155 +24,32 @@ import {
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { generateBloodPressureStatusForUser } from "@/lib/insights/blood-pressure-status";
|
||||
import { generateWeightStatusForUser } from "@/lib/insights/weight-status";
|
||||
import { generatePulseStatusForUser } from "@/lib/insights/pulse-status";
|
||||
import { generateBmiStatusForUser } from "@/lib/insights/bmi-status";
|
||||
import { generateMoodStatusForUser } from "@/lib/insights/mood-status";
|
||||
import { generateMedicationComplianceStatusForUser } from "@/lib/insights/medication-compliance-status";
|
||||
import { generateStatusBatchForUser } from "@/lib/insights/status-batch";
|
||||
import { findStatusCronCandidates } from "@/lib/jobs/status-cron-candidates";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate";
|
||||
import { getWorkerPrisma } from "./shared";
|
||||
|
||||
export interface GeneralStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface BloodPressureStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface WeightStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface PulseStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface BmiStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
export interface MoodStatusPayload {
|
||||
triggeredAt?: string;
|
||||
}
|
||||
|
||||
export interface MedicationComplianceStatusPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared driver for the nightly 02:xx per-metric status crons. User
|
||||
* discovery is centralised in `findStatusCronCandidates`, which applies
|
||||
* the operator's `insightStatus` switch, the person's AI analysis switch,
|
||||
* and the pregenerate-candidate skip (users with a stale comprehensive
|
||||
* cache belong to the 04:30 pre-generate pass, which re-warms every
|
||||
* per-status note anyway — see `status-cron-candidates.ts` for the full
|
||||
* division of nightly labour). Each user's `statusText` capability is then
|
||||
* resolved before a generator runs.
|
||||
* The generators normalise `locale` themselves (de stays de, everything
|
||||
* else gets English prose).
|
||||
* The nightly status batch. The 02:00 cron runs `generateStatusBatchForUser`
|
||||
* for every candidate (`findStatusCronCandidates`): it builds every
|
||||
* per-metric snapshot once, issues ONE provider call for the metrics still
|
||||
* needing the model, and fans the answer into the per-metric cache rows. A
|
||||
* metric the batch omits, or every metric when the call fails, falls back to
|
||||
* that card's own path inside the batch, so the pass covers every card on its
|
||||
* own. Each user's `statusText` capability is resolved inside the batch before
|
||||
* a snapshot is built.
|
||||
*
|
||||
* The pass is what succeeds or fails. One user whose generation threw is that
|
||||
* user's cold card, reported as the `failed` count; failing the job would
|
||||
* re-run the whole cohort over it.
|
||||
*
|
||||
* The pass walks every candidate with a provider call each, so on a large
|
||||
* instance it can outlast its job. `shouldStop` is the job's time budget: the
|
||||
* pass stops between users once it is spent and reports `stopped_early`. The
|
||||
* users it did not reach keep yesterday's note until the next night or their
|
||||
* next visit; nothing is retried beside a pass that is still running.
|
||||
* The pass is what succeeds or fails. One user whose batch threw is that
|
||||
* user's cold cards, reported as the `failed` count; failing the job would
|
||||
* re-run the whole cohort over it. `shouldStop` is the job's time budget: the
|
||||
* pass stops between users once it is spent and reports `stopped_early`, and
|
||||
* the users it did not reach are warmed by their next visit or night.
|
||||
*/
|
||||
export async function runStatusCronGenerate(
|
||||
taskName: string,
|
||||
generate: (
|
||||
userId: string,
|
||||
options: { locale: string | null; force: boolean },
|
||||
) => Promise<unknown>,
|
||||
shouldStop: () => boolean = () => false,
|
||||
): Promise<JobOutcome> {
|
||||
return withBackgroundEvent(taskName, async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const users = await findStatusCronCandidates(prisma);
|
||||
|
||||
if (users.length === 0) {
|
||||
return jobDone({ total: 0, generated: 0, failed: 0 });
|
||||
}
|
||||
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
|
||||
for (const user of users) {
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
}
|
||||
// The capability before the generator builds a snapshot. The batch
|
||||
// entry below checks for itself.
|
||||
const capability = await aiCapabilityForJob(user.id, "statusText");
|
||||
if (!capability.available) {
|
||||
annotate({
|
||||
action: { name: "insights.status.cron.skipped" },
|
||||
meta: { task: taskName, reason: capability.reason },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await generate(user.id, {
|
||||
locale: await resolveJobLocale(user.locale),
|
||||
force: false,
|
||||
});
|
||||
generated++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
evt.addWarning(
|
||||
`${taskName} generation failed for user ${user.id}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
evt.setBackground({
|
||||
task_name: taskName,
|
||||
result: { generated, failed, total: users.length, stoppedEarly },
|
||||
});
|
||||
return jobDone({
|
||||
total: users.length,
|
||||
generated,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
await reportWorkerError(taskName, err);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.11 (P2) — nightly status batch. The 02:00 anchor cron runs
|
||||
* `generateStatusBatchForUser` for every candidate: it builds all seven
|
||||
* per-metric snapshots once and issues ONE provider call for the metrics
|
||||
* still needing the LLM (seed-pinned + grounded inside `runStatusCompletion`,
|
||||
* unchanged), fanning the response into the SAME per-metric cache rows the
|
||||
* standalone generators wrote.
|
||||
*
|
||||
* The six later per-metric crons (02:05–02:30) stay registered and keep their
|
||||
* own drivers: a card the batch wrote today resolves inside their `prepare`
|
||||
* step as a calendar-day cache hit (`served`, no provider call, no snapshot
|
||||
* rebuild), so they cost a cache read and cover the edge cases the batch
|
||||
* can't — a user discovered after 02:00, a metric the batch omitted, or a
|
||||
* batch-call failure (which falls each metric back to its single-card path
|
||||
* inside the batch itself). Net effect: the nightly ladder pays one call per
|
||||
* user instead of seven, with no queue/registry churn.
|
||||
*/
|
||||
async function runStatusBatchCron(
|
||||
export async function runStatusBatchCron(
|
||||
taskName: string,
|
||||
shouldStop: () => boolean,
|
||||
): Promise<JobOutcome> {
|
||||
@@ -189,8 +67,8 @@ async function runStatusBatchCron(
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
for (const user of users) {
|
||||
// Same budget as the per-metric passes: stop between users, and
|
||||
// leave the rest to the later crons and the next night.
|
||||
// Stop between users; the rest are warmed by a visit or the next
|
||||
// night.
|
||||
if (shouldStop()) {
|
||||
stoppedEarly = true;
|
||||
break;
|
||||
@@ -233,8 +111,7 @@ async function runStatusBatchCron(
|
||||
},
|
||||
});
|
||||
// A user whose batch call threw already fell back to the single-card
|
||||
// path inside the batch, and the six later per-metric crons cover what
|
||||
// is still cold. The batch pass ran, so it reports the count.
|
||||
// path inside the batch. The pass ran, so it reports the count.
|
||||
return jobDone({
|
||||
total: users.length,
|
||||
generated,
|
||||
@@ -257,66 +134,6 @@ export function handleGeneralStatusGenerate(
|
||||
return runStatusBatchCron("job.insights.batch", jobBudget(jobs));
|
||||
}
|
||||
|
||||
export function handleBloodPressureStatusGenerate(
|
||||
jobs: Job<BloodPressureStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.blood_pressure",
|
||||
generateBloodPressureStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleWeightStatusGenerate(
|
||||
jobs: Job<WeightStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.weight",
|
||||
generateWeightStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handlePulseStatusGenerate(
|
||||
jobs: Job<PulseStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.pulse",
|
||||
generatePulseStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleBmiStatusGenerate(
|
||||
jobs: Job<BmiStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.bmi",
|
||||
generateBmiStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleMoodStatusGenerate(
|
||||
jobs: Job<MoodStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.mood",
|
||||
generateMoodStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export function handleMedicationComplianceStatusGenerate(
|
||||
jobs: Job<MedicationComplianceStatusPayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
return runStatusCronGenerate(
|
||||
"job.insights.medication_compliance",
|
||||
generateMedicationComplianceStatusForUser,
|
||||
jobBudget(jobs),
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleInsightPregenerateJob(
|
||||
jobs: Job<InsightPregeneratePayload>[],
|
||||
): Promise<JobOutcome> {
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from "@/lib/jobs/pr-detection";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass";
|
||||
import { heavyLane } from "@/lib/jobs/heavy-lane";
|
||||
import {
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
OFFHOST_BACKUP_SEND_OPTIONS,
|
||||
@@ -79,7 +80,9 @@ import {
|
||||
type MeasurementFoldRepairPayload,
|
||||
} from "@/lib/jobs/measurement-fold-repair";
|
||||
import {
|
||||
MEASUREMENT_MAINTENANCE_CRON,
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
MEASUREMENT_MAINTENANCE_SEND_OPTIONS,
|
||||
handleMeasurementMaintenance,
|
||||
type MeasurementMaintenancePayload,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
@@ -506,9 +509,9 @@ const allQueues = [
|
||||
// their window; the purge above waits for it. Without this entry the boot
|
||||
// enqueue silently no-ops and the purge never runs.
|
||||
MEASUREMENT_FOLD_REPAIR_QUEUE,
|
||||
// v1.42 — operator-triggered VACUUM / REINDEX of `measurements`. No
|
||||
// cron: the admin route enqueues it. Without this entry the trigger
|
||||
// silently no-ops.
|
||||
// v1.42 — VACUUM / REINDEX of `measurements`: the admin route enqueues the
|
||||
// operator's run, and a nightly check vacuums when dead rows pile up.
|
||||
// Without this entry both silently no-op.
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
// v1.43 — the pulse day-mean dry run and rebuild, admin-triggered only.
|
||||
PULSE_DAY_MEAN_QUEUE,
|
||||
@@ -633,6 +636,14 @@ const allQueues = [
|
||||
// `retryLimit: 0` in its own options for the other reason: it pages each
|
||||
// failed account itself, and a retry would page the same accounts again.
|
||||
const schedules: ScheduleEntry[] = [
|
||||
// v1.43.7 — nightly 03:40 check of `measurements`: a plain VACUUM when the
|
||||
// dead rows pass the threshold, never a rebuild. Same singleton key as the
|
||||
// operator's run, so the two collapse.
|
||||
[
|
||||
MEASUREMENT_MAINTENANCE_QUEUE,
|
||||
MEASUREMENT_MAINTENANCE_CRON,
|
||||
{ ...MEASUREMENT_MAINTENANCE_SEND_OPTIONS },
|
||||
],
|
||||
// v1.25 (W-ENV) — daily 02:10 Europe/Berlin discovery tick (empty payload)
|
||||
// that fans out one per-user environment fetch per opted-in account.
|
||||
[ENVIRONMENT_FETCH_QUEUE, ENVIRONMENT_FETCH_CRON],
|
||||
@@ -848,11 +859,11 @@ const queuePolicies: QueuePolicyTable = {
|
||||
reason:
|
||||
"Fixed singleton key, admin-triggered. Two concurrent corpus rotations must never overlap; the route already reports a suppressed send back as alreadyQueued.",
|
||||
},
|
||||
// v1.42 — operator-triggered VACUUM / REINDEX of `measurements`.
|
||||
// v1.42 — VACUUM / REINDEX of `measurements`, operator or nightly.
|
||||
[MEASUREMENT_MAINTENANCE_QUEUE]: {
|
||||
policy: "exclusive",
|
||||
reason:
|
||||
"Fixed singleton key, admin-triggered. Two maintenance passes would rebuild the same indexes side by side; the route reports a suppressed send back as enqueued: false.",
|
||||
"Fixed singleton key on the admin send and the nightly tick alike. Two maintenance passes would vacuum or rebuild side by side; the route reports a suppressed send back as enqueued: false.",
|
||||
},
|
||||
// v1.43 — the pulse day-mean dry run and rebuild.
|
||||
[PULSE_DAY_MEAN_QUEUE]: {
|
||||
@@ -931,7 +942,11 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
DATA_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(DATA_BACKUP_QUEUE, () => WHOLE_PASS, handleDataBackup),
|
||||
lockedPass(
|
||||
DATA_BACKUP_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
heavyLane(DATA_BACKUP_QUEUE, handleDataBackup),
|
||||
),
|
||||
);
|
||||
await createAndWork<RateLimitCleanupPayload>(
|
||||
boss,
|
||||
@@ -962,7 +977,11 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1, includeMetadata: true },
|
||||
lockedPass(OFFHOST_BACKUP_QUEUE, () => WHOLE_PASS, handleOffhostBackup),
|
||||
lockedPass(
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
heavyLane(OFFHOST_BACKUP_QUEUE, handleOffhostBackup),
|
||||
),
|
||||
);
|
||||
// Locked: a drill that outlives a restart or a manual run never reads the
|
||||
// bucket beside another one.
|
||||
@@ -970,7 +989,11 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
RESTORE_DRILL_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(RESTORE_DRILL_QUEUE, () => WHOLE_PASS, handleRestoreDrill),
|
||||
lockedPass(
|
||||
RESTORE_DRILL_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
heavyLane(RESTORE_DRILL_QUEUE, handleRestoreDrill),
|
||||
),
|
||||
);
|
||||
await createAndWork(
|
||||
boss,
|
||||
@@ -985,7 +1008,7 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
BACKUP_RESTORE_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleBackupRestore,
|
||||
heavyLane(BACKUP_RESTORE_QUEUE, handleBackupRestore),
|
||||
);
|
||||
// A restore the previous process was running is picked up again once its
|
||||
// heartbeat has gone stale; see `sweepInterruptedRestores`.
|
||||
@@ -1192,7 +1215,7 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
APPLE_HEALTH_IMPORT_V2_QUEUE,
|
||||
{ localConcurrency: APPLE_HEALTH_IMPORT_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
heavyLane(APPLE_HEALTH_IMPORT_V2_QUEUE, 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.
|
||||
@@ -1205,7 +1228,7 @@ export async function registerMaintenanceQueues(
|
||||
if (!outcome.ok && failure === null) failure = outcome;
|
||||
}
|
||||
return failure ?? jobDone({ jobs: jobs.length });
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Drain pre-revision-2 backlog by moving each claimed legacy job onto the
|
||||
// isolated v2 queue. This preserves the ImportJob status id without letting
|
||||
@@ -1228,7 +1251,7 @@ export async function registerMaintenanceQueues(
|
||||
boss,
|
||||
HEALTH_CONNECT_IMPORT_QUEUE,
|
||||
{ localConcurrency: HEALTH_CONNECT_IMPORT_CONCURRENCY },
|
||||
handleHealthConnectImport,
|
||||
heavyLane(HEALTH_CONNECT_IMPORT_QUEUE, handleHealthConnectImport),
|
||||
);
|
||||
// v1.42 — compaction-tombstone backlog purge. Serial: each batch holds
|
||||
// an account against a concurrent restore.
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
workerLog,
|
||||
BOOT_BACKFILL_STAGGER_SECONDS,
|
||||
} from "./shared";
|
||||
import { heavyLane } from "@/lib/jobs/heavy-lane";
|
||||
import {
|
||||
createAndSchedule,
|
||||
createAndWork,
|
||||
@@ -331,7 +332,7 @@ export async function registerRollupQueues(
|
||||
boss,
|
||||
ROLLUP_FULL_BACKFILL_QUEUE,
|
||||
{ localConcurrency: ROLLUP_FULL_BACKFILL_CONCURRENCY },
|
||||
async (jobs) => {
|
||||
heavyLane(ROLLUP_FULL_BACKFILL_QUEUE, async (jobs) => {
|
||||
let rowsUpsertedTotal = 0;
|
||||
for (const job of jobs) {
|
||||
const { userId } = job.data;
|
||||
@@ -346,7 +347,7 @@ export async function registerRollupQueues(
|
||||
users: jobs.length,
|
||||
rows_upserted: rowsUpsertedTotal,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// v1.5.6 — legacy step consolidation worker. The boot enqueue helper
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 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
|
||||
* Owns the nightly status batch (every per-metric status card, one provider
|
||||
* call per account), 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
|
||||
@@ -104,6 +104,7 @@ import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { jobBudget } from "@/lib/jobs/job-budget";
|
||||
import { lockedPass, WHOLE_PASS } from "@/lib/jobs/long-pass";
|
||||
import { heavyLane } from "@/lib/jobs/heavy-lane";
|
||||
import { INSIGHT_PREGENERATE_EXPIRE_SECONDS } from "@/lib/jobs/insight-pregenerate-shared";
|
||||
import { PERIOD_NARRATIVE_EXPIRE_SECONDS } from "@/lib/jobs/period-narrative-shared";
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
@@ -112,24 +113,13 @@ import {
|
||||
createAndWork,
|
||||
insightRetryOptions,
|
||||
nightlyInsightPassOptions,
|
||||
retireQueues,
|
||||
type QueuePolicyTable,
|
||||
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";
|
||||
@@ -138,45 +128,25 @@ 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
|
||||
/**
|
||||
* The six per-metric status crons that followed the 02:00 batch (02:05 to
|
||||
* 02:30). The batch builds every card and falls each metric it could not
|
||||
* cover back to that card's own path, so they only re-read cards the batch
|
||||
* had just written; a user who joins later is warmed by the visit or the next
|
||||
* night. Their schedules and queues are removed on an instance that still has
|
||||
* them.
|
||||
*/
|
||||
const RETIRED_STATUS_QUEUES = [
|
||||
"insights-blood-pressure-status",
|
||||
"insights-weight-status",
|
||||
"insights-pulse-status",
|
||||
"insights-bmi-status",
|
||||
"insights-mood-status",
|
||||
"insights-medication-compliance-status",
|
||||
] as const;
|
||||
|
||||
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.
|
||||
@@ -238,21 +208,6 @@ const allQueues = [
|
||||
|
||||
const schedules: ScheduleEntry[] = [
|
||||
[GENERAL_STATUS_QUEUE, GENERAL_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
BLOOD_PRESSURE_STATUS_CRON,
|
||||
nightlyInsightPassOptions,
|
||||
],
|
||||
[WEIGHT_STATUS_QUEUE, WEIGHT_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[PULSE_STATUS_QUEUE, PULSE_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[BMI_STATUS_QUEUE, BMI_STATUS_CRON, nightlyInsightPassOptions],
|
||||
// v1.15.20 — mood status nightly, continuing the 02:xx ladder.
|
||||
[MOOD_STATUS_QUEUE, MOOD_STATUS_CRON, nightlyInsightPassOptions],
|
||||
[
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
MEDICATION_COMPLIANCE_STATUS_CRON,
|
||||
nightlyInsightPassOptions,
|
||||
],
|
||||
// v1.7.0 — nightly 04:30 Europe/Berlin comprehensive-insight
|
||||
// pre-generation. Budget-gated per user inside the handler.
|
||||
[
|
||||
@@ -383,6 +338,7 @@ export async function registerStatusQueues(
|
||||
boss: PgBoss,
|
||||
): Promise<readonly string[]> {
|
||||
await createAndSchedule(boss, allQueues, schedules, queuePolicies);
|
||||
await retireQueues(boss, RETIRED_STATUS_QUEUES);
|
||||
|
||||
await createAndWork<GeneralStatusPayload>(
|
||||
boss,
|
||||
@@ -391,55 +347,7 @@ export async function registerStatusQueues(
|
||||
lockedPass(
|
||||
GENERAL_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleGeneralStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<BloodPressureStatusPayload>(
|
||||
boss,
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(
|
||||
BLOOD_PRESSURE_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleBloodPressureStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<WeightStatusPayload>(
|
||||
boss,
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(
|
||||
WEIGHT_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleWeightStatusGenerate,
|
||||
),
|
||||
);
|
||||
await createAndWork<PulseStatusPayload>(
|
||||
boss,
|
||||
PULSE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(PULSE_STATUS_QUEUE, () => WHOLE_PASS, handlePulseStatusGenerate),
|
||||
);
|
||||
await createAndWork<BmiStatusPayload>(
|
||||
boss,
|
||||
BMI_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(BMI_STATUS_QUEUE, () => WHOLE_PASS, handleBmiStatusGenerate),
|
||||
);
|
||||
await createAndWork<MoodStatusPayload>(
|
||||
boss,
|
||||
MOOD_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(MOOD_STATUS_QUEUE, () => WHOLE_PASS, handleMoodStatusGenerate),
|
||||
);
|
||||
await createAndWork<MedicationComplianceStatusPayload>(
|
||||
boss,
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
lockedPass(
|
||||
MEDICATION_COMPLIANCE_STATUS_QUEUE,
|
||||
() => WHOLE_PASS,
|
||||
handleMedicationComplianceStatusGenerate,
|
||||
heavyLane(GENERAL_STATUS_QUEUE, handleGeneralStatusGenerate),
|
||||
),
|
||||
);
|
||||
// v1.7.0 — nightly comprehensive-insight pre-generation.
|
||||
@@ -465,7 +373,11 @@ export async function registerStatusQueues(
|
||||
job.data?.force && job.data.userId
|
||||
? `user:${job.data.userId}`
|
||||
: WHOLE_PASS,
|
||||
handleInsightPregenerateJob,
|
||||
heavyLane(
|
||||
INSIGHT_PREGENERATE_QUEUE,
|
||||
handleInsightPregenerateJob,
|
||||
(job) => !(job.data?.force && job.data.userId),
|
||||
),
|
||||
),
|
||||
);
|
||||
// S4 — event-driven morning digest refresh. One forced comprehensive
|
||||
@@ -796,49 +708,56 @@ export async function registerStatusQueues(
|
||||
lockedPass(
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
(job) => (job.data?.userId ? `user:${job.data.userId}` : WHOLE_PASS),
|
||||
async (jobs): Promise<JobOutcome> => {
|
||||
// Two modes on one queue, so the outcome counts them separately: a
|
||||
// non-boundary night is a scheduled pass that generated nothing, which
|
||||
// is a success with zero counts, not an idle queue.
|
||||
let singleUser = 0;
|
||||
let scheduled = 0;
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.data?.userId) {
|
||||
await warmOneNarrative(job.data);
|
||||
singleUser++;
|
||||
} else {
|
||||
const summary = await runPeriodNarrativeWarm(getWorkerPrisma(), {
|
||||
shouldStop: jobBudget(jobs),
|
||||
heavyLane(
|
||||
PERIOD_NARRATIVE_QUEUE,
|
||||
async (jobs): Promise<JobOutcome> => {
|
||||
// Two modes on one queue, so the outcome counts them separately: a
|
||||
// non-boundary night is a scheduled pass that generated nothing, which
|
||||
// is a success with zero counts, not an idle queue.
|
||||
let singleUser = 0;
|
||||
let scheduled = 0;
|
||||
let generated = 0;
|
||||
let failed = 0;
|
||||
let stoppedEarly = false;
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.data?.userId) {
|
||||
await warmOneNarrative(job.data);
|
||||
singleUser++;
|
||||
} else {
|
||||
const summary = await runPeriodNarrativeWarm(
|
||||
getWorkerPrisma(),
|
||||
{
|
||||
shouldStop: jobBudget(jobs),
|
||||
},
|
||||
);
|
||||
scheduled++;
|
||||
generated += summary.generated;
|
||||
failed += summary.failed;
|
||||
stoppedEarly ||= summary.stoppedEarly;
|
||||
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",
|
||||
});
|
||||
scheduled++;
|
||||
generated += summary.generated;
|
||||
failed += summary.failed;
|
||||
stoppedEarly ||= summary.stoppedEarly;
|
||||
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}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
recordError();
|
||||
await reportWorkerError(PERIOD_NARRATIVE_QUEUE, err, {
|
||||
mode: job.data?.userId ? "single-user" : "scheduled",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return jobDone({
|
||||
single_user: singleUser,
|
||||
scheduled,
|
||||
generated,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
},
|
||||
return jobDone({
|
||||
single_user: singleUser,
|
||||
scheduled,
|
||||
generated,
|
||||
failed,
|
||||
stopped_early: stoppedEarly,
|
||||
});
|
||||
},
|
||||
(job) => !job.data?.userId,
|
||||
),
|
||||
),
|
||||
);
|
||||
// v1.11.1 — combined Coach memory refresh: rolling conversation summary +
|
||||
|
||||
@@ -169,7 +169,7 @@ export const insightRetryOptions = {
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Send options for a nightly AI pass over every account (the status crons).
|
||||
* Send options for a nightly AI pass over every account (the status batch).
|
||||
*
|
||||
* The retries of `insightRetryOptions`, plus a two-hour expiry. Each pass
|
||||
* makes a provider call per account, and on a large instance that outlasted
|
||||
@@ -286,6 +286,57 @@ async function reconcileVolumeQueueRetention(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The idle poll of a queue only its cron feeds. pg-boss polls every two
|
||||
* seconds by default, per worker slot, so the hundred-odd bindings asked the
|
||||
* database for work about fifty times a second while nearly all of them wait
|
||||
* for one tick a day. A cron job a few seconds late costs nothing.
|
||||
*/
|
||||
export const CRON_FED_POLL_SECONDS = 30;
|
||||
|
||||
/**
|
||||
* Scheduled queues that also take sends of their own: a per-account job, a
|
||||
* webhook, a fan-out from the tick itself, a manual run. They keep pg-boss's
|
||||
* two-second poll, because with one job per fetch a worker waits a full poll
|
||||
* interval after every job, and a burst of sends would drain at one job per
|
||||
* interval. `job-queue-runtime-guard.test.ts` holds this list to the send
|
||||
* sites in the tree.
|
||||
*/
|
||||
export const SCHEDULED_AND_SENT_QUEUES: ReadonlySet<string> = new Set([
|
||||
"data-backup",
|
||||
"dawarich-sync",
|
||||
"drain-per-sample-cumulative",
|
||||
"environment-aq-history",
|
||||
"environment-fetch",
|
||||
"free-text-encryption-backfill",
|
||||
"geolite2-fetch",
|
||||
"insight-pregenerate",
|
||||
"intake-slot-dedup",
|
||||
"measurement-maintenance",
|
||||
"med-notes-encryption-backfill",
|
||||
"nightscout-sync",
|
||||
"note-encryption-backfill",
|
||||
"offhost-backup-purge",
|
||||
"oura-sync",
|
||||
"period-narrative-warm",
|
||||
"polar-sync",
|
||||
"pr-detection",
|
||||
"whoop-recovery-sync",
|
||||
"whoop-sleep-sync",
|
||||
"whoop-workout-sync",
|
||||
"withings-activity-sync",
|
||||
"withings-ecg-sync",
|
||||
"withings-sleep-sync",
|
||||
]);
|
||||
|
||||
/** Queues a registrar scheduled that nothing else sends to. */
|
||||
const cronFedQueues = new Set<string>();
|
||||
|
||||
/** The poll `createAndWork` gives `queue` when its binding names none. */
|
||||
export function defaultPollingSeconds(queue: string): number | undefined {
|
||||
return cronFedQueues.has(queue) ? CRON_FED_POLL_SECONDS : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create every queue in `queues`, then schedule every cron in `schedules`.
|
||||
* Centralised so each registrar provisions before it schedules in the exact
|
||||
@@ -321,6 +372,7 @@ export async function createAndSchedule(
|
||||
...(schedules.length > 0 ? [PG_BOSS_SEND_IT_QUEUE] : []),
|
||||
]);
|
||||
for (const [name, cron, sendOptions] of schedules) {
|
||||
if (!SCHEDULED_AND_SENT_QUEUES.has(name)) cronFedQueues.add(name);
|
||||
await boss.schedule(
|
||||
name,
|
||||
cron,
|
||||
@@ -333,6 +385,26 @@ export async function createAndSchedule(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove queues the tree no longer declares: their schedule first, so the
|
||||
* timekeeper stops minting jobs nobody works, then the queue with whatever it
|
||||
* still holds. A queue that is already gone is a no-op, so every boot after
|
||||
* the first does nothing. Never fails a boot.
|
||||
*/
|
||||
export async function retireQueues(
|
||||
boss: PgBoss,
|
||||
queues: readonly string[],
|
||||
): Promise<void> {
|
||||
for (const name of queues) {
|
||||
try {
|
||||
await boss.unschedule(name);
|
||||
await boss.deleteQueue(name);
|
||||
} catch (err) {
|
||||
workerLog("error", `[queue-retire] failed to retire ${name}`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a handler to a queue. The one legal way to reach `boss.work` in this
|
||||
* tree — the `healthlog/job-handler-outcome` lint rule refuses a bare
|
||||
@@ -346,6 +418,10 @@ export async function createAndSchedule(
|
||||
*
|
||||
* Argument order matches `boss.work(queue, options, handler)` so a binding
|
||||
* reads the same as it did before, with `boss` moved to the front.
|
||||
*
|
||||
* A queue only its cron feeds polls at `CRON_FED_POLL_SECONDS` unless the
|
||||
* binding names a poll; the registrar schedules before it binds, so the
|
||||
* queue is known by then.
|
||||
*/
|
||||
export async function createAndWork<T>(
|
||||
boss: PgBoss,
|
||||
@@ -368,5 +444,13 @@ export async function createAndWork<T>(
|
||||
// `JobWithMetadata<T>` extends `Job<T>`, so the metadata handler accepts
|
||||
// everything the plain one does; the overloads above are what keeps the
|
||||
// call sites honest about which shape they asked pg-boss for.
|
||||
await boss.work<T>(queue, options, runJob(queue, handler as JobHandler<T>));
|
||||
const pollingIntervalSeconds =
|
||||
options.pollingIntervalSeconds ?? defaultPollingSeconds(queue);
|
||||
await boss.work<T>(
|
||||
queue,
|
||||
pollingIntervalSeconds === undefined
|
||||
? options
|
||||
: { ...options, pollingIntervalSeconds },
|
||||
runJob(queue, handler as JobHandler<T>),
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Shared user discovery for the nightly 02:xx per-metric status crons
|
||||
* (general / blood-pressure / weight / pulse / bmi / medication-
|
||||
* compliance / mood).
|
||||
* User discovery for the nightly 02:00 status batch, which writes every
|
||||
* per-metric status card (general / blood-pressure / weight / pulse / bmi /
|
||||
* medication-compliance / mood).
|
||||
*
|
||||
* Before this module each cron iterated EVERY user row: accounts that
|
||||
* disabled the coach surface (`disableCoach`) and operator-disabled
|
||||
@@ -12,18 +12,18 @@
|
||||
* Division of nightly labour (documented here once, referenced by the
|
||||
* handlers):
|
||||
*
|
||||
* - 02:xx status crons → users the 04:30 `insight-pregenerate` pass will
|
||||
* - 02:00 status batch → users the 04:30 `insight-pregenerate` pass will
|
||||
* not reach: a comprehensive cache fresher than `PREGENERATE_STALE_MS`,
|
||||
* so that pass skips them, while their per-status notes still age out
|
||||
* daily and only these crons re-fill them.
|
||||
* - 04:30 insight-pregenerate → the rest: users with a comprehensive cache
|
||||
* older than `PREGENERATE_STALE_MS`. That pass warms every per-status
|
||||
* note whenever `statusText` is available for the user, whatever became
|
||||
* of the briefing, so a 02:xx generation for them would be redone two
|
||||
* of the briefing, so a 02:00 generation for them would be redone two
|
||||
* hours later.
|
||||
*
|
||||
* Cheap gates, in SQL and in order (the per-user `statusText` capability in
|
||||
* each handler, and the chokepoint behind it, are what decide):
|
||||
* Cheap gates, in SQL and in order (the per-user `statusText` capability
|
||||
* inside the batch, and the chokepoint behind it, are what decide):
|
||||
* 1. The operator's `insightStatus` switch (master applied). Off, the whole
|
||||
* pass is empty.
|
||||
* 2. The person's AI analysis switch (the `insights` module). Someone who
|
||||
@@ -32,7 +32,7 @@
|
||||
* else.
|
||||
* 3. The pregenerate-candidate skip described above, only while the
|
||||
* `briefing` switch is on; when it is off the 04:30 pass no-ops, so the
|
||||
* 02:xx crons keep covering everyone.
|
||||
* 02:00 batch keeps covering everyone.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
@@ -59,7 +59,7 @@ export async function findStatusCronCandidates(
|
||||
if (users.length === 0) return [];
|
||||
|
||||
// When the briefing switch is off the 04:30 pregenerate pass no-ops, so no
|
||||
// user has a comprehensive claim and the 02:xx crons keep all.
|
||||
// user has a comprehensive claim and the 02:00 batch keeps all.
|
||||
if (!flags.briefing) {
|
||||
return users.map((u) => ({ id: u.id, locale: u.locale }));
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ const subsystemSurface = {
|
||||
|
||||
// register-status.ts
|
||||
"insights-general-status": { audience: "system" },
|
||||
"insights-blood-pressure-status": { audience: "system" },
|
||||
"insights-weight-status": { audience: "system" },
|
||||
"insights-pulse-status": { audience: "system" },
|
||||
"insights-bmi-status": { audience: "system" },
|
||||
"insights-mood-status": { audience: "system" },
|
||||
"insights-medication-compliance-status": { audience: "system" },
|
||||
"insight-pregenerate": { audience: "system" },
|
||||
"insight-status-generate": { audience: "system" },
|
||||
"morning-digest-refresh": { audience: "system" },
|
||||
|
||||
@@ -21,6 +21,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { integrationBackfillSourceOptions } from "@/lib/jobs/integration-backfill-admission";
|
||||
import { isReauthRequired } from "@/lib/integrations/status";
|
||||
import {
|
||||
backfillVerdict,
|
||||
withPageWalkLedger,
|
||||
} from "@/lib/integrations/page-walk";
|
||||
import { syncUserWhoop } from "@/lib/whoop/sync";
|
||||
|
||||
export const WHOOP_BACKFILL_QUEUE = "whoop-backfill";
|
||||
@@ -44,16 +48,19 @@ export interface WhoopBackfillPayload {
|
||||
* reboot mid-walk) overwrites rather than duplicating. Mirrors the landed
|
||||
* `runFitbitBackfillForUser`.
|
||||
*
|
||||
* Verdict-gated: a partial hard failure (a dead token, a write failing, a
|
||||
* fetch hard-fail) THROWS so pg-boss retries the job — stamping the marker over
|
||||
* a partial multi-year walk would freeze the history gap forever and the
|
||||
* configured `retryLimit` would be dead code. A connection parked at
|
||||
* `error_reauth` is NOT clean either, but throwing against a dead grant would
|
||||
* just burn the retry budget on the same no-op: return WITHOUT stamping — boot
|
||||
* discovery re-offers the account after the user reconnects and a clean walk
|
||||
* completes. `syncUserWhoop` has its own reauth short-circuit (returns
|
||||
* `failed: true`), but the guard must still live here so a parked account
|
||||
* RETURNS instead of throwing through three retries.
|
||||
* Verdict-gated (`backfillVerdict`, `integrations/page-walk.ts`): a partial
|
||||
* hard failure (a dead token, a write failing, a fetch hard-fail) THROWS so
|
||||
* pg-boss retries the job — stamping the marker over a partial multi-year
|
||||
* walk would freeze the history gap forever and the configured `retryLimit`
|
||||
* would be dead code. A collection whose walk reached its page ceiling leaves
|
||||
* the marker unset WITHOUT a retry: nothing failed, and boot discovery offers
|
||||
* the account again. A connection parked at `error_reauth` is NOT clean
|
||||
* either, but throwing against a dead grant would just burn the retry budget
|
||||
* on the same no-op: return WITHOUT stamping — boot discovery re-offers the
|
||||
* account after the user reconnects and a clean walk completes.
|
||||
* `syncUserWhoop` has its own reauth short-circuit (returns `failed: true`),
|
||||
* but the guard must still live here so a parked account RETURNS instead of
|
||||
* throwing through three retries.
|
||||
*/
|
||||
export async function runWhoopBackfillForUser(
|
||||
userId: string,
|
||||
@@ -68,9 +75,26 @@ export async function runWhoopBackfillForUser(
|
||||
return { imported: 0 };
|
||||
}
|
||||
|
||||
const { imported, failed } = await syncUserWhoop(userId, { fullSync: true });
|
||||
const {
|
||||
result: { imported, failed },
|
||||
truncated,
|
||||
} = await withPageWalkLedger(() => syncUserWhoop(userId, { fullSync: true }));
|
||||
const verdict = backfillVerdict({ failed, truncated });
|
||||
|
||||
if (failed) {
|
||||
if (verdict === "incomplete") {
|
||||
// A collection longer than its walk may read in one run: the history is
|
||||
// not complete, but nothing failed, so no retry. Boot discovery offers
|
||||
// the account again while the marker stays unset.
|
||||
annotate({
|
||||
action: {
|
||||
name: "whoop.backfill.truncated",
|
||||
details: { imported, collections: truncated.join(",") },
|
||||
},
|
||||
});
|
||||
return { imported };
|
||||
}
|
||||
|
||||
if (verdict === "retry") {
|
||||
// Surface through the pg-boss retry path (retryLimit + backoff set by
|
||||
// `integrationBackfillSourceOptions`). If the failure parked the connection
|
||||
// at error_reauth meanwhile, the next attempt takes the reauth return above.
|
||||
|
||||
@@ -289,6 +289,12 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
|
||||
.describe(
|
||||
'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.',
|
||||
),
|
||||
lastEnteredHereAt: isoDateTime()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.43.7 — present only on a type the app writes back to Apple Health (weight, blood pressure, blood glucose while their entry is `bidirectional` or `writeOnly` and enabled): when the newest reading of the type was entered here rather than arriving from Apple Health, or null when none was. Such readings go to the phone and never come back as an arrival, so `stale` already counts them: the type is quiet only when both this and `lastSeenAt` are. Show it as the server sends it; do not recompute `stale`.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AppleHealthMetricFreshnessEntry",
|
||||
|
||||
+55
-60
@@ -21,6 +21,8 @@ import { dayDiff } from "@/lib/cycle/day-math";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { OuraApiError, classifyOuraResponse } from "./response-classifier";
|
||||
import { envOr, envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
|
||||
const OURA_API_BASE = "https://api.ouraring.com";
|
||||
const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize";
|
||||
@@ -475,51 +477,48 @@ async function fetchCollection<T>(
|
||||
verb: string,
|
||||
query: DateRangeQuery,
|
||||
): Promise<T[]> {
|
||||
const records: T[] = [];
|
||||
let nextToken: string | null | undefined;
|
||||
let pageCount = 0;
|
||||
const maxPages = query.maxPages ?? 100;
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
start_date: query.startDate,
|
||||
end_date: query.endDate,
|
||||
});
|
||||
if (nextToken) params.set("next_token", nextToken);
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as OuraCollection<T> | null;
|
||||
const verdict = classifyOuraResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "oura",
|
||||
method: `${verb}(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new OuraApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
const walk = await walkPages<string, T>(
|
||||
`oura.${verb}`,
|
||||
query.maxPages ?? 100,
|
||||
async (nextToken, page) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: query.startDate,
|
||||
end_date: query.endDate,
|
||||
});
|
||||
}
|
||||
for (const r of json?.data ?? []) records.push(r);
|
||||
nextToken = json?.next_token ?? null;
|
||||
pageCount += 1;
|
||||
} while (nextToken && pageCount < maxPages);
|
||||
if (nextToken) params.set("next_token", nextToken);
|
||||
|
||||
return records;
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(`${OURA_API_BASE}${path}?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as OuraCollection<T> | null;
|
||||
const verdict = classifyOuraResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "oura",
|
||||
method: `${verb}(page=${page})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new OuraApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
});
|
||||
}
|
||||
return { items: json?.data ?? [], next: json?.next_token ?? null };
|
||||
},
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
export function fetchSleep(
|
||||
@@ -642,10 +641,6 @@ export function fetchDailyCyclePhases(
|
||||
|
||||
const SEC_TO_MIN = 1 / 60;
|
||||
|
||||
function round2(n: number): number {
|
||||
return parseFloat(n.toFixed(2));
|
||||
}
|
||||
|
||||
export interface MappedMeasurement {
|
||||
type: string;
|
||||
value: number;
|
||||
@@ -704,7 +699,7 @@ export function mapReadiness(
|
||||
if (typeof r.score === "number") {
|
||||
out.push({
|
||||
type: "RECOVERY_SCORE",
|
||||
value: round2(r.score),
|
||||
value: importValue(r.score),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "recovery",
|
||||
@@ -713,7 +708,7 @@ export function mapReadiness(
|
||||
if (typeof r.temperature_deviation === "number") {
|
||||
out.push({
|
||||
type: "BODY_TEMPERATURE_DEVIATION",
|
||||
value: round2(r.temperature_deviation),
|
||||
value: importValue(r.temperature_deviation),
|
||||
unit: "celsius",
|
||||
measuredAt,
|
||||
fieldTag: "temp_deviation",
|
||||
@@ -784,7 +779,7 @@ function mapSleepTimeline(s: OuraSleep): MappedMeasurement[] | null {
|
||||
const fieldTag = `seg:${new Date(segStartMs).toISOString()}`;
|
||||
out.push({
|
||||
type: "SLEEP_DURATION",
|
||||
value: round2(run * HYPNOGRAM_INTERVAL_MIN),
|
||||
value: importValue(run * HYPNOGRAM_INTERVAL_MIN),
|
||||
unit: "minutes",
|
||||
measuredAt: new Date(segEndMs),
|
||||
fieldTag,
|
||||
@@ -850,7 +845,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof sec === "number" && sec >= 0) {
|
||||
out.push({
|
||||
type: "SLEEP_DURATION",
|
||||
value: round2(sec * SEC_TO_MIN),
|
||||
value: importValue(sec * SEC_TO_MIN),
|
||||
unit: "minutes",
|
||||
measuredAt,
|
||||
fieldTag,
|
||||
@@ -868,7 +863,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof s.efficiency === "number") {
|
||||
out.push({
|
||||
type: "SLEEP_EFFICIENCY",
|
||||
value: round2(s.efficiency),
|
||||
value: importValue(s.efficiency),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "sleep_eff",
|
||||
@@ -878,7 +873,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof s.average_hrv === "number" && s.average_hrv > 0) {
|
||||
out.push({
|
||||
type: "HRV_RMSSD",
|
||||
value: round2(s.average_hrv),
|
||||
value: importValue(s.average_hrv),
|
||||
unit: "ms",
|
||||
measuredAt,
|
||||
fieldTag: "hrv_rmssd",
|
||||
@@ -898,7 +893,7 @@ export function mapSleep(s: OuraSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof s.average_breath === "number" && s.average_breath > 0) {
|
||||
out.push({
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: round2(s.average_breath),
|
||||
value: importValue(s.average_breath),
|
||||
unit: "breaths/min",
|
||||
measuredAt,
|
||||
fieldTag: "resp_rate",
|
||||
@@ -919,7 +914,7 @@ export function mapDailySleep(
|
||||
return [
|
||||
{
|
||||
type: "SLEEP_SCORE",
|
||||
value: round2(d.score),
|
||||
value: importValue(d.score),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "sleep_score",
|
||||
@@ -941,7 +936,7 @@ export function mapDailySpo2(
|
||||
return [
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: round2(avg),
|
||||
value: importValue(avg),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "spo2",
|
||||
@@ -970,7 +965,7 @@ export function mapDailyActivity(
|
||||
if (typeof a.active_calories === "number" && a.active_calories >= 0) {
|
||||
out.push({
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: round2(a.active_calories),
|
||||
value: importValue(a.active_calories),
|
||||
unit: "kcal",
|
||||
measuredAt,
|
||||
fieldTag: "active_energy",
|
||||
@@ -984,7 +979,7 @@ export function mapDailyActivity(
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
// Oura reports the equivalent walking distance in metres — the canonical
|
||||
// WALKING_RUNNING_DISTANCE unit (no conversion).
|
||||
value: round2(a.equivalent_walking_distance),
|
||||
value: importValue(a.equivalent_walking_distance),
|
||||
unit: "m",
|
||||
measuredAt,
|
||||
fieldTag: "distance",
|
||||
@@ -1005,7 +1000,7 @@ export function mapVo2Max(v: OuraVo2Max, tz?: string): MappedMeasurement[] {
|
||||
return [
|
||||
{
|
||||
type: "VO2_MAX",
|
||||
value: round2(v.vo2_max),
|
||||
value: importValue(v.vo2_max),
|
||||
unit: "mL/(kg·min)",
|
||||
measuredAt,
|
||||
fieldTag: "vo2_max",
|
||||
@@ -1029,7 +1024,7 @@ export function mapCardiovascularAge(
|
||||
return [
|
||||
{
|
||||
type: "VASCULAR_AGE",
|
||||
value: round2(c.vascular_age),
|
||||
value: importValue(c.vascular_age),
|
||||
unit: "years",
|
||||
measuredAt,
|
||||
fieldTag: "vascular_age",
|
||||
|
||||
+12
-15
@@ -32,6 +32,7 @@ import type { WorkoutSportType } from "@/lib/validations/workout";
|
||||
import { PolarApiError, classifyPolarResponse } from "./response-classifier";
|
||||
import { mapPolarSportType } from "./sport-map";
|
||||
import { envOr, envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
|
||||
const POLAR_API_BASE = "https://www.polaraccesslink.com";
|
||||
const POLAR_OAUTH_AUTH_URL = "https://flow.polar.com/oauth2/authorization";
|
||||
@@ -401,10 +402,6 @@ export function fetchSpo2(accessToken: string): Promise<PolarSpo2[]> {
|
||||
|
||||
const SEC_TO_MIN = 1 / 60;
|
||||
|
||||
function round2(n: number): number {
|
||||
return parseFloat(n.toFixed(2));
|
||||
}
|
||||
|
||||
/** A single mapped reading destined for one `Measurement` row. The sync layer
|
||||
* stamps `source = POLAR` + `externalId = <resource>:<date>:<fieldTag>` unless
|
||||
* the mapper supplies its own full `externalId` (the per-segment sleep rows
|
||||
@@ -446,7 +443,7 @@ export interface MappedMeasurement {
|
||||
* rescale 1–6 → 0–100 (1 = worst, 6 = best). */
|
||||
function rechargeStatusToScore(status: number): number {
|
||||
const clamped = Math.max(1, Math.min(6, status));
|
||||
return round2(((clamped - 1) / 5) * 100);
|
||||
return importValue(((clamped - 1) / 5) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -479,7 +476,7 @@ export function mapNightlyRecharge(
|
||||
) {
|
||||
out.push({
|
||||
type: "HRV_RMSSD",
|
||||
value: round2(r.heart_rate_variability_avg),
|
||||
value: importValue(r.heart_rate_variability_avg),
|
||||
unit: "ms",
|
||||
measuredAt,
|
||||
fieldTag: "hrv_rmssd",
|
||||
@@ -497,7 +494,7 @@ export function mapNightlyRecharge(
|
||||
if (typeof r.breathing_rate_avg === "number" && r.breathing_rate_avg > 0) {
|
||||
out.push({
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: round2(r.breathing_rate_avg),
|
||||
value: importValue(r.breathing_rate_avg),
|
||||
unit: "breaths/min",
|
||||
measuredAt,
|
||||
fieldTag: "resp_rate",
|
||||
@@ -511,7 +508,7 @@ export function mapNightlyRecharge(
|
||||
if (typeof r.ans_charge === "number") {
|
||||
out.push({
|
||||
type: "ANS_CHARGE",
|
||||
value: round2(r.ans_charge),
|
||||
value: importValue(r.ans_charge),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "ans_charge",
|
||||
@@ -600,7 +597,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof s.sleep_score === "number") {
|
||||
out.push({
|
||||
type: "SLEEP_PERFORMANCE",
|
||||
value: round2(s.sleep_score),
|
||||
value: importValue(s.sleep_score),
|
||||
unit: "%",
|
||||
measuredAt: new Date(endMs),
|
||||
fieldTag: "sleep_score",
|
||||
@@ -618,7 +615,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof sec === "number" && sec >= 0) {
|
||||
out.push({
|
||||
type: "SLEEP_DURATION",
|
||||
value: round2(sec * SEC_TO_MIN),
|
||||
value: importValue(sec * SEC_TO_MIN),
|
||||
unit: "minutes",
|
||||
measuredAt,
|
||||
fieldTag,
|
||||
@@ -629,7 +626,7 @@ export function mapSleep(s: PolarSleep, tz?: string): MappedMeasurement[] {
|
||||
if (typeof s.sleep_score === "number") {
|
||||
out.push({
|
||||
type: "SLEEP_PERFORMANCE",
|
||||
value: round2(s.sleep_score),
|
||||
value: importValue(s.sleep_score),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "sleep_score",
|
||||
@@ -664,7 +661,7 @@ export function mapActivity(
|
||||
if (typeof activeCalories === "number" && activeCalories >= 0) {
|
||||
out.push({
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: round2(activeCalories),
|
||||
value: importValue(activeCalories),
|
||||
unit: "kcal",
|
||||
measuredAt,
|
||||
fieldTag: "active_energy",
|
||||
@@ -677,7 +674,7 @@ export function mapActivity(
|
||||
if (typeof distance === "number" && distance >= 0) {
|
||||
out.push({
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
value: round2(distance),
|
||||
value: importValue(distance),
|
||||
unit: "meters",
|
||||
measuredAt,
|
||||
fieldTag: "distance",
|
||||
@@ -699,7 +696,7 @@ export function mapCardioLoad(
|
||||
return [
|
||||
{
|
||||
type: "CARDIO_LOAD",
|
||||
value: round2(c.cardio_load),
|
||||
value: importValue(c.cardio_load),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "cardio_load",
|
||||
@@ -719,7 +716,7 @@ export function mapSpo2(r: PolarSpo2): MappedMeasurement[] {
|
||||
return [
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: round2(pct),
|
||||
value: importValue(pct),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "spo2",
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
consumeForIntake,
|
||||
restoreForIntake,
|
||||
} from "@/lib/medications/inventory/consumption";
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { getUserTodayBounds, localHmAsUtc } from "@/lib/tz/local-day";
|
||||
import { wallClockInTz } from "@/lib/tz/wall-clock";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
@@ -597,10 +597,12 @@ export async function handleCallback(update: TelegramUpdate) {
|
||||
|
||||
let skippedScheduledFor: Date | null = null;
|
||||
if (!existing) {
|
||||
const endOfDay = new Date();
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
const skipMoment = new Date();
|
||||
const tz = user.timezone || DEFAULT_TIMEZONE;
|
||||
// The rest of the person's own day, not of the server's: the process
|
||||
// runs in UTC, so a Berlin skip after 01:00 used to snooze the next
|
||||
// local morning's reminders as well.
|
||||
const endOfDay = getUserTodayBounds(skipMoment, tz).end;
|
||||
|
||||
// v1.16.9 — a Telegram skip answers a specific dose prompt, so it
|
||||
// must converge onto the slot's pending row like a take does. The
|
||||
|
||||
+56
-57
@@ -20,6 +20,8 @@ import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline";
|
||||
import { WhoopApiError, classifyWhoopResponse } from "./response-classifier";
|
||||
import { envOr, envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
|
||||
const WHOOP_API_BASE = "https://api.prod.whoop.com/developer";
|
||||
const WHOOP_OAUTH_AUTH_URL = "https://api.prod.whoop.com/oauth/oauth2/auth";
|
||||
@@ -268,7 +270,10 @@ export interface WhoopProfile {
|
||||
interface CollectionQuery {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
/** Hard ceiling on pages walked (defence against a runaway cursor). */
|
||||
/**
|
||||
* Ceiling on pages walked (defence against a runaway cursor). A walk that
|
||||
* reaches it is reported truncated (`walkPages`).
|
||||
*/
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
@@ -278,49 +283,45 @@ async function fetchCollection<T>(
|
||||
verb: string,
|
||||
query: CollectionQuery = {},
|
||||
): Promise<T[]> {
|
||||
const records: T[] = [];
|
||||
let nextToken: string | null | undefined;
|
||||
let pageCount = 0;
|
||||
const maxPages = query.maxPages ?? 1000;
|
||||
const walk = await walkPages<string, T>(
|
||||
`whoop.${verb}`,
|
||||
query.maxPages ?? 1000,
|
||||
async (nextToken, page) => {
|
||||
const params = new URLSearchParams({ limit: String(WHOOP_PAGE_LIMIT) });
|
||||
if (query.start) params.set("start", query.start.toISOString());
|
||||
if (query.end) params.set("end", query.end.toISOString());
|
||||
if (nextToken) params.set("nextToken", nextToken);
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: String(WHOOP_PAGE_LIMIT) });
|
||||
if (query.start) params.set("start", query.start.toISOString());
|
||||
if (query.end) params.set("end", query.end.toISOString());
|
||||
if (nextToken) params.set("nextToken", nextToken);
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(`${WHOOP_API_BASE}${path}?${params}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as WhoopCollection<T> | null;
|
||||
const verdict = classifyWhoopResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "whoop",
|
||||
method: `${verb}(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WhoopApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(`${WHOOP_API_BASE}${path}?${params}`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
}
|
||||
|
||||
for (const r of json?.records ?? []) records.push(r);
|
||||
nextToken = json?.next_token ?? null;
|
||||
pageCount += 1;
|
||||
} while (nextToken && pageCount < maxPages);
|
||||
|
||||
return records;
|
||||
const json = (await res
|
||||
.json()
|
||||
.catch(() => null)) as WhoopCollection<T> | null;
|
||||
const verdict = classifyWhoopResponse(res.status);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "whoop",
|
||||
method: `${verb}(page=${page})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WhoopApiError({
|
||||
verb,
|
||||
classification: verdict.classification,
|
||||
httpStatus: verdict.httpStatus,
|
||||
reason: verdict.reason,
|
||||
});
|
||||
}
|
||||
return { items: json?.records ?? [], next: json?.next_token ?? null };
|
||||
},
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
async function fetchSingle<T>(
|
||||
@@ -522,10 +523,6 @@ export interface MappedMeasurement {
|
||||
reconstructed?: boolean;
|
||||
}
|
||||
|
||||
function round2(n: number): number {
|
||||
return parseFloat(n.toFixed(2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one WHOOP recovery record into Measurement readings. An unscored record
|
||||
* (`score === null`) yields nothing. `recovery.score.recovery_score` →
|
||||
@@ -540,21 +537,21 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
|
||||
const out: MappedMeasurement[] = [
|
||||
{
|
||||
type: "RECOVERY_SCORE",
|
||||
value: round2(r.score.recovery_score),
|
||||
value: importValue(r.score.recovery_score),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "recovery",
|
||||
},
|
||||
{
|
||||
type: "HRV_RMSSD",
|
||||
value: round2(r.score.hrv_rmssd_milli),
|
||||
value: importValue(r.score.hrv_rmssd_milli),
|
||||
unit: "ms",
|
||||
measuredAt,
|
||||
fieldTag: "hrv_rmssd",
|
||||
},
|
||||
{
|
||||
type: "RESTING_HEART_RATE",
|
||||
value: round2(r.score.resting_heart_rate),
|
||||
value: importValue(r.score.resting_heart_rate),
|
||||
unit: "bpm",
|
||||
measuredAt,
|
||||
fieldTag: "rhr",
|
||||
@@ -563,7 +560,7 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
|
||||
if (typeof r.score.spo2_percentage === "number") {
|
||||
out.push({
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: round2(r.score.spo2_percentage),
|
||||
value: importValue(r.score.spo2_percentage),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag: "spo2",
|
||||
@@ -572,7 +569,7 @@ export function mapRecovery(r: WhoopRecovery): MappedMeasurement[] {
|
||||
if (typeof r.score.skin_temp_celsius === "number") {
|
||||
out.push({
|
||||
type: "SKIN_TEMPERATURE",
|
||||
value: round2(r.score.skin_temp_celsius),
|
||||
value: importValue(r.score.skin_temp_celsius),
|
||||
unit: "celsius",
|
||||
measuredAt,
|
||||
fieldTag: "skin_temp",
|
||||
@@ -665,7 +662,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
|
||||
need.need_from_recent_nap_milli;
|
||||
out.push({
|
||||
type: "SLEEP_NEED",
|
||||
value: round2(totalNeedMilli * MS_TO_MIN),
|
||||
value: importValue(totalNeedMilli * MS_TO_MIN),
|
||||
unit: "minutes",
|
||||
measuredAt,
|
||||
fieldTag: "sleep_need",
|
||||
@@ -684,7 +681,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
|
||||
if (typeof value === "number") {
|
||||
out.push({
|
||||
type,
|
||||
value: round2(value),
|
||||
value: importValue(value),
|
||||
unit: "%",
|
||||
measuredAt,
|
||||
fieldTag,
|
||||
@@ -695,7 +692,7 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] {
|
||||
if (typeof s.score.respiratory_rate === "number") {
|
||||
out.push({
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: round2(s.score.respiratory_rate),
|
||||
value: importValue(s.score.respiratory_rate),
|
||||
unit: "breaths/min",
|
||||
measuredAt,
|
||||
fieldTag: "resp_rate",
|
||||
@@ -727,14 +724,14 @@ export function mapCycle(c: WhoopCycle): MappedMeasurement[] {
|
||||
const out: MappedMeasurement[] = [
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
value: round2(c.score.strain),
|
||||
value: importValue(c.score.strain),
|
||||
unit: "score",
|
||||
measuredAt,
|
||||
fieldTag: "day_strain",
|
||||
},
|
||||
{
|
||||
type: "ENERGY_EXPENDITURE_KJ",
|
||||
value: round2(c.score.kilojoule),
|
||||
value: importValue(c.score.kilojoule),
|
||||
unit: "kJ",
|
||||
measuredAt,
|
||||
fieldTag: "energy_kj",
|
||||
@@ -801,12 +798,14 @@ export function mapBody(b: WhoopBodyMeasurement): MappedBodyMeasurement {
|
||||
const positive = (n: number | undefined): n is number =>
|
||||
typeof n === "number" && Number.isFinite(n) && n > 0;
|
||||
return {
|
||||
weightKg: positive(b.weight_kilogram) ? round2(b.weight_kilogram) : null,
|
||||
weightKg: positive(b.weight_kilogram)
|
||||
? importValue(b.weight_kilogram)
|
||||
: null,
|
||||
maxHeartRate: positive(b.max_heart_rate)
|
||||
? Math.round(b.max_heart_rate)
|
||||
: null,
|
||||
heightCm: positive(b.height_meter)
|
||||
? round2(b.height_meter * M_TO_CM)
|
||||
? importValue(b.height_meter * M_TO_CM)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
+77
-65
@@ -9,6 +9,8 @@ import {
|
||||
classifyWithingsResponse,
|
||||
} from "./response-classifier";
|
||||
import { envOr, envValue } from "@/lib/env";
|
||||
import { importValue } from "@/lib/integrations/import-value";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
|
||||
const WITHINGS_OAUTH_URL =
|
||||
"https://account.withings.com/oauth2_user/authorize2";
|
||||
@@ -248,6 +250,24 @@ export interface WithingsMeasureGroup {
|
||||
export const WITHINGS_MEASTYPES: readonly number[] =
|
||||
Object.keys(MEASURE_TYPE_MAP).map(Number);
|
||||
|
||||
/** Ceiling on the pages one Withings list walk reads (`walkPages`). */
|
||||
export const WITHINGS_MAX_PAGES = 1000;
|
||||
|
||||
/**
|
||||
* The offset of the next page of a Withings list body, or null when it has
|
||||
* no more. A body that says `more` without an offset past the current one
|
||||
* hands back the current offset, which `walkPages` reports as a repeat
|
||||
* instead of stopping quietly.
|
||||
*/
|
||||
export function withingsNextOffset(
|
||||
body: { more?: unknown; offset?: unknown },
|
||||
offset: number,
|
||||
): number | null {
|
||||
if (body.more !== true && body.more !== 1) return null;
|
||||
const next = Number(body.offset);
|
||||
return Number.isFinite(next) && next > offset ? next : offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch measurements from Withings API.
|
||||
*
|
||||
@@ -272,77 +292,69 @@ export async function fetchMeasurements(
|
||||
baseParams.enddate = String(Math.floor(endDate.getTime() / 1000));
|
||||
}
|
||||
|
||||
const results: WithingsMeasure[] = [];
|
||||
let offset = 0;
|
||||
let pageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
...baseParams,
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchMeasurements(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
||||
const walk = await walkPages<number, WithingsMeasure>(
|
||||
"withings.measure",
|
||||
WITHINGS_MAX_PAGES,
|
||||
async (cursor, page) => {
|
||||
const offset = cursor ?? 0;
|
||||
const params = new URLSearchParams({
|
||||
...baseParams,
|
||||
offset: String(offset),
|
||||
});
|
||||
}
|
||||
|
||||
const body = json.body ?? {};
|
||||
const groups: WithingsMeasureGroup[] = body.measuregrps ?? [];
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
for (const group of groups) {
|
||||
const measuredAt = new Date(group.date * 1000);
|
||||
|
||||
for (const m of group.measures) {
|
||||
const mapping = MEASURE_TYPE_MAP[m.type];
|
||||
if (!mapping) continue;
|
||||
|
||||
// Withings stores value * 10^unit (unit is usually negative)
|
||||
const value = m.value * Math.pow(10, m.unit);
|
||||
|
||||
results.push({
|
||||
type: mapping.type,
|
||||
value: parseFloat(value.toFixed(2)),
|
||||
measuredAt,
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchMeasurements(page=${page})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "measure",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError:
|
||||
typeof json?.error === "string" ? json.error : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hasMore = body.more === true || body.more === 1;
|
||||
if (!hasMore) break;
|
||||
|
||||
const nextOffset = Number(body.offset);
|
||||
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
|
||||
offset = nextOffset;
|
||||
pageCount += 1;
|
||||
if (pageCount > 1000) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
const body = json.body ?? {};
|
||||
const groups: WithingsMeasureGroup[] = body.measuregrps ?? [];
|
||||
const items: WithingsMeasure[] = [];
|
||||
for (const group of groups) {
|
||||
const measuredAt = new Date(group.date * 1000);
|
||||
for (const m of group.measures) {
|
||||
const mapping = MEASURE_TYPE_MAP[m.type];
|
||||
if (!mapping) continue;
|
||||
// Withings stores value * 10^unit (unit is usually negative)
|
||||
const value = m.value * Math.pow(10, m.unit);
|
||||
items.push({
|
||||
type: mapping.type,
|
||||
value: importValue(value),
|
||||
measuredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { items, next: withingsNextOffset(body, offset) };
|
||||
},
|
||||
0,
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,7 +49,11 @@ import {
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
|
||||
import { hasActivityScope } from "./client";
|
||||
import {
|
||||
hasActivityScope,
|
||||
WITHINGS_MAX_PAGES,
|
||||
withingsNextOffset,
|
||||
} from "./client";
|
||||
import {
|
||||
WithingsApiError,
|
||||
classifyWithingsResponse,
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
} from "@/lib/integrations/status";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
|
||||
const WITHINGS_MEASURE_URL = "https://wbsapi.withings.net/v2/measure";
|
||||
|
||||
@@ -145,57 +150,51 @@ export async function fetchWithingsActivity(
|
||||
data_fields: "steps,distance,calories",
|
||||
};
|
||||
|
||||
const results: WithingsActivityEntry[] = [];
|
||||
let offset = 0;
|
||||
let pageCount = 0;
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
...baseParams,
|
||||
offset: String(offset),
|
||||
});
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchWithingsActivity(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "activity",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
||||
const walk = await walkPages<number, WithingsActivityEntry>(
|
||||
"withings.activity",
|
||||
WITHINGS_MAX_PAGES,
|
||||
async (cursor, page) => {
|
||||
const offset = cursor ?? 0;
|
||||
const params = new URLSearchParams({
|
||||
...baseParams,
|
||||
offset: String(offset),
|
||||
});
|
||||
}
|
||||
|
||||
const body = json.body ?? {};
|
||||
const activities: WithingsActivityEntry[] = body.activities ?? [];
|
||||
for (const entry of activities) {
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
const hasMore = body.more === true || body.more === 1;
|
||||
if (!hasMore) break;
|
||||
const nextOffset = Number(body.offset);
|
||||
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
|
||||
offset = nextOffset;
|
||||
pageCount += 1;
|
||||
if (pageCount > 1000) break;
|
||||
}
|
||||
return results;
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_MEASURE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchWithingsActivity(page=${page})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "activity",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError:
|
||||
typeof json?.error === "string" ? json.error : undefined,
|
||||
});
|
||||
}
|
||||
const body = json.body ?? {};
|
||||
const activities: WithingsActivityEntry[] = body.activities ?? [];
|
||||
return { items: activities, next: withingsNextOffset(body, offset) };
|
||||
},
|
||||
0,
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -80,6 +80,8 @@ import {
|
||||
recordSyncFailure,
|
||||
recordSyncSuccess,
|
||||
} from "@/lib/integrations/status";
|
||||
import { walkPages } from "@/lib/integrations/page-walk";
|
||||
import { WITHINGS_MAX_PAGES, withingsNextOffset } from "./client";
|
||||
|
||||
const WITHINGS_HEART_URL = "https://wbsapi.withings.net/v2/heart";
|
||||
|
||||
@@ -139,60 +141,55 @@ export async function fetchWithingsHeartList(
|
||||
startdate: number,
|
||||
enddate: number,
|
||||
): Promise<WithingsHeartEntry[]> {
|
||||
const results: WithingsHeartEntry[] = [];
|
||||
let offset = 0;
|
||||
let pageCount = 0;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams({
|
||||
action: "list",
|
||||
startdate: String(startdate),
|
||||
enddate: String(enddate),
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_HEART_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchWithingsHeartList(page=${pageCount})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error: verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "heart",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError: typeof json?.error === "string" ? json.error : undefined,
|
||||
const walk = await walkPages<number, WithingsHeartEntry>(
|
||||
"withings.heart",
|
||||
WITHINGS_MAX_PAGES,
|
||||
async (cursor, page) => {
|
||||
const offset = cursor ?? 0;
|
||||
const params = new URLSearchParams({
|
||||
action: "list",
|
||||
startdate: String(startdate),
|
||||
enddate: String(enddate),
|
||||
offset: String(offset),
|
||||
});
|
||||
}
|
||||
|
||||
const body = json.body ?? {};
|
||||
const series: WithingsHeartEntry[] = body.series ?? [];
|
||||
results.push(...series);
|
||||
const pageStart = performance.now();
|
||||
const res = await safeFetch(WITHINGS_HEART_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: params.toString(),
|
||||
});
|
||||
const json = await res.json();
|
||||
const verdict = classifyWithingsResponse(res.status, json);
|
||||
getEvent()?.addExternalCall({
|
||||
service: "withings",
|
||||
method: `fetchWithingsHeartList(page=${page})`,
|
||||
duration_ms: Math.round(performance.now() - pageStart),
|
||||
status: res.status,
|
||||
error:
|
||||
verdict.classification === "success" ? undefined : verdict.reason,
|
||||
});
|
||||
if (verdict.classification !== "success") {
|
||||
throw new WithingsApiError({
|
||||
verb: "heart",
|
||||
classification: verdict.classification,
|
||||
withingsStatus: verdict.withingsStatus,
|
||||
reason: verdict.reason,
|
||||
upstreamError:
|
||||
typeof json?.error === "string" ? json.error : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const hasMore = body.more === true || body.more === 1;
|
||||
if (!hasMore) break;
|
||||
const nextOffset = Number(body.offset);
|
||||
if (!Number.isFinite(nextOffset) || nextOffset <= offset) break;
|
||||
offset = nextOffset;
|
||||
pageCount += 1;
|
||||
if (pageCount > 1000) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
const body = json.body ?? {};
|
||||
const series: WithingsHeartEntry[] = body.series ?? [];
|
||||
return { items: series, next: withingsNextOffset(body, offset) };
|
||||
},
|
||||
0,
|
||||
);
|
||||
return walk.items;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
* Reads inside one request are memoised on the request's wide event, so a
|
||||
* page that resolves the AI capabilities five times reads the settings row
|
||||
* once. A background job opens one wide event for its whole run, and the
|
||||
* status crons loop over every candidate inside it: memoising there meant the
|
||||
* status batch loops over every candidate inside it: memoising there meant the
|
||||
* switches, the modules and the consent kinds were read once at the start of
|
||||
* the run and served to every later user, and the wire re-check read the same
|
||||
* cached answer. Turning the Assistant off at 02:05 did not stop the 02:00
|
||||
* pass.
|
||||
*
|
||||
* Driven here over the real loop, the real capability loader and Postgres:
|
||||
* the first user's generation turns the switch off, and the remaining users
|
||||
* must not reach a generator, while the wire re-check inside the first user's
|
||||
* own run must see the change too.
|
||||
* Driven here over the real loop, the real capability loader and Postgres.
|
||||
* The batch is stubbed past its first step, the `statusText` capability it
|
||||
* resolves before building anything: the first user's run turns the switch
|
||||
* off, the remaining users must not get past that step, and the wire re-check
|
||||
* inside the first user's own run must see the change too.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -27,7 +28,30 @@ vi.mock("@/lib/jobs/status-cron-candidates", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { runStatusCronGenerate } from "@/lib/jobs/reminder/insights-handlers";
|
||||
const batch = vi.hoisted(() => ({
|
||||
run: async (_userId: string): Promise<void> => {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/status-batch", async () => {
|
||||
const { aiCapabilityForJob } = await import("@/lib/ai/capabilities/gate");
|
||||
return {
|
||||
generateStatusBatchForUser: vi.fn(async (userId: string) => {
|
||||
const result = {
|
||||
served: 0,
|
||||
batched: 0,
|
||||
fellBack: 0,
|
||||
batchCallMade: false,
|
||||
};
|
||||
if (!(await aiCapabilityForJob(userId, "statusText")).available) {
|
||||
return result;
|
||||
}
|
||||
await batch.run(userId);
|
||||
return { ...result, batched: 1, batchCallMade: true };
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { runStatusBatchCron } from "@/lib/jobs/reminder/insights-handlers";
|
||||
import { aiEgressRefusal } from "@/lib/ai/capabilities/egress";
|
||||
|
||||
let counter = 0;
|
||||
@@ -74,7 +98,7 @@ describe("a switch turned off mid-run", () => {
|
||||
const reached: string[] = [];
|
||||
const wire: Array<string | null> = [];
|
||||
|
||||
const generate = vi.fn(async (userId: string) => {
|
||||
batch.run = async (userId: string) => {
|
||||
reached.push(userId);
|
||||
// The wire re-check before the switch moves: open.
|
||||
wire.push(
|
||||
@@ -87,9 +111,9 @@ describe("a switch turned off mid-run", () => {
|
||||
(await aiEgressRefusal("statusText", userId, ["anthropic"]))?.reason ??
|
||||
null,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await runStatusCronGenerate("job.test.fresh_switch", generate);
|
||||
await runStatusBatchCron("job.test.fresh_switch", () => false);
|
||||
|
||||
expect(reached).toEqual([candidates.ids[0]]);
|
||||
expect(wire[0]).toBeNull();
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Migration 0412: an update of `measurements` that changes nothing keeps its
|
||||
* stamps and stays on its page.
|
||||
*
|
||||
* `updated_at` sits in the sync-feed index, so an update that moved it could
|
||||
* never be heap-only, and every provider re-sync that rewrote unchanged rows
|
||||
* grew the table by a full row version and an entry in every index. Proven
|
||||
* here against Postgres: the no-op update keeps `updated_at` and
|
||||
* `sync_version` and is counted as HOT in the transaction's own statistics,
|
||||
* a real change still moves both, and the nightly check reads the table's
|
||||
* statistics and vacuums only when dead rows pass its threshold.
|
||||
*/
|
||||
import { Client } from "pg";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
resolveSteps,
|
||||
runMeasurementMaintenance,
|
||||
tableHealth,
|
||||
} from "@/lib/jobs/measurement-maintenance";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
async function seedReading() {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "hot-update",
|
||||
email: "hot-update@example.test",
|
||||
role: "USER",
|
||||
timezone: "UTC",
|
||||
},
|
||||
});
|
||||
return prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
value: 72.3,
|
||||
unit: "kg",
|
||||
measuredAt: new Date("2026-10-01T07:00:00.000Z"),
|
||||
source: "WITHINGS",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("an update that changes nothing", () => {
|
||||
it("keeps updated_at and sync_version and stays on its page", async () => {
|
||||
const row = await seedReading();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const hot = await prisma.$transaction(async (tx) => {
|
||||
await tx.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: 72.3, deletedAt: null, syncVersion: { increment: 1 } },
|
||||
});
|
||||
const [stats] = await tx.$queryRaw<Array<{ hot: bigint; upd: bigint }>>`
|
||||
SELECT n_tup_hot_upd AS hot, n_tup_upd AS upd
|
||||
FROM pg_stat_xact_user_tables WHERE relname = 'measurements'`;
|
||||
return stats;
|
||||
});
|
||||
expect(Number(hot.upd)).toBe(1);
|
||||
expect(Number(hot.hot)).toBe(1);
|
||||
|
||||
const after = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
});
|
||||
expect(after.updatedAt.getTime()).toBe(row.updatedAt.getTime());
|
||||
expect(after.syncVersion).toBe(row.syncVersion);
|
||||
});
|
||||
|
||||
it("still moves both stamps when a value changes", async () => {
|
||||
const row = await seedReading();
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
await prisma.measurement.update({
|
||||
where: { id: row.id },
|
||||
data: { value: 72.4, syncVersion: { increment: 1 } },
|
||||
});
|
||||
const after = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id: row.id },
|
||||
});
|
||||
expect(after.updatedAt.getTime()).toBeGreaterThan(row.updatedAt.getTime());
|
||||
expect(after.syncVersion).toBe(row.syncVersion + 1);
|
||||
});
|
||||
|
||||
it("gives the table room on each page and an earlier autovacuum", async () => {
|
||||
const [rel] = await prisma.$queryRaw<Array<{ options: string[] | null }>>`
|
||||
SELECT reloptions AS options FROM pg_class WHERE relname = 'measurements'`;
|
||||
expect(rel.options).toEqual(
|
||||
expect.arrayContaining([
|
||||
"fillfactor=85",
|
||||
"autovacuum_vacuum_scale_factor=0.05",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the nightly check", () => {
|
||||
it("reads the statistics and does nothing while no vacuum is due", async () => {
|
||||
await seedReading();
|
||||
const db = new Client({ connectionString: process.env.DATABASE_URL });
|
||||
await db.connect();
|
||||
try {
|
||||
await db.query("ANALYZE measurements");
|
||||
const health = await tableHealth(db);
|
||||
expect(health.heapBytes).toBeGreaterThan(0);
|
||||
const steps = resolveSteps({}, health);
|
||||
expect(steps).toEqual({ vacuum: false, reindex: false });
|
||||
const summary = await runMeasurementMaintenance(db, steps);
|
||||
expect(summary.outcome).toBe("nothing_due");
|
||||
expect(summary.steps).toEqual([]);
|
||||
} finally {
|
||||
await db.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user