mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Merge branch 'release/v1.45.0' into feat/v145-backup
# Conflicts: # src/__tests__/baselines/source-file-lines.json # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
0cefac9d68
94 files changed
+14850
-658
No files matched your search
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 125,
|
||||
"total": 148250,
|
||||
"count": 124,
|
||||
"total": 147278,
|
||||
"entries": {
|
||||
"app/api/dashboard/summary/route.ts": 1032,
|
||||
"app/api/documents/inbound/route.ts": 811,
|
||||
"app/api/insights/generate/route.ts": 1209,
|
||||
"app/api/measurements/batch/route.ts": 1576,
|
||||
"app/api/measurements/batch/route.ts": 1569,
|
||||
"app/api/measurements/route.ts": 938,
|
||||
"app/api/measurements/series/route.ts": 879,
|
||||
"app/api/medications/[id]/route.ts": 1281,
|
||||
"app/api/medications/intake/bulk/route.ts": 1044,
|
||||
"app/api/workouts/batch/route.ts": 971,
|
||||
@@ -52,11 +51,11 @@
|
||||
"lib/ai/coach/types.ts": 1011,
|
||||
"lib/ai/codex-client.ts": 1094,
|
||||
"lib/ai/prompts/insight-generator.ts": 1121,
|
||||
"lib/ai/provider.ts": 1644,
|
||||
"lib/ai/provider.ts": 1632,
|
||||
"lib/ai/safety/outbound-screen.ts": 1463,
|
||||
"lib/analytics/score/reader.ts": 998,
|
||||
"lib/analytics/sleep-night.ts": 1345,
|
||||
"lib/analytics/summaries-slice.ts": 1314,
|
||||
"lib/analytics/summaries-slice.ts": 1248,
|
||||
"lib/api-handler.ts": 1978,
|
||||
"lib/auth/session.ts": 811,
|
||||
"lib/cache/server-cache.ts": 851,
|
||||
@@ -107,10 +106,10 @@
|
||||
"lib/jobs/reminder/register-status.ts": 878,
|
||||
"lib/managed-profiles/handover.ts": 986,
|
||||
"lib/mcp/record-search.ts": 1467,
|
||||
"lib/mcp/rich-reads.ts": 1468,
|
||||
"lib/mcp/rich-reads.ts": 1467,
|
||||
"lib/mcp/tools.ts": 2654,
|
||||
"lib/measurements/dense-intraday-retention.ts": 1057,
|
||||
"lib/measurements/import-apple-health-export.ts": 1729,
|
||||
"lib/measurements/import-apple-health-export.ts": 1722,
|
||||
"lib/medications/scheduling/cadence.ts": 978,
|
||||
"lib/medications/scheduling/recurrence.ts": 1108,
|
||||
"lib/medications/scheduling/slot-upsert.ts": 926,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
|
||||
"count": 410,
|
||||
"total": 148267,
|
||||
"total": 148056,
|
||||
"entries": {
|
||||
"app/achievements/page.tsx :: AchievementsPage": 206,
|
||||
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
|
||||
@@ -38,10 +38,10 @@
|
||||
"app/api/mcp/oauth/token/route.ts :: POST > withBackgroundEvent(arg)": 248,
|
||||
"app/api/measurement-reminders/[id]/route.ts :: apiHandler(arg) #2": 240,
|
||||
"app/api/measurements/[id]/route.ts :: apiHandler(arg) #2": 203,
|
||||
"app/api/measurements/batch/route.ts :: postBatch": 1277,
|
||||
"app/api/measurements/batch/route.ts :: postBatch": 1262,
|
||||
"app/api/measurements/route.ts :: apiHandler(arg)": 286,
|
||||
"app/api/measurements/route.ts :: postMeasurement": 377,
|
||||
"app/api/measurements/series/route.ts :: apiHandler(arg)": 670,
|
||||
"app/api/measurements/series/route.ts :: apiHandler(arg)": 549,
|
||||
"app/api/medications/[id]/dose-history/route.ts :: apiHandler(arg)": 256,
|
||||
"app/api/medications/[id]/intake/[eventId]/route.ts :: apiHandler(arg)": 390,
|
||||
"app/api/medications/[id]/intake/route.ts :: postIntake": 607,
|
||||
@@ -299,7 +299,7 @@
|
||||
"lib/analytics/correlations-fast-path.ts :: computeCorrelationHypothesesFastPath": 256,
|
||||
"lib/analytics/intraday-pulse-io.ts :: loadIntradayPulse": 206,
|
||||
"lib/analytics/score/reader.ts :: computeUserHealthScore": 324,
|
||||
"lib/analytics/summaries-slice.ts :: computeFromRollups": 441,
|
||||
"lib/analytics/summaries-slice.ts :: computeFromRollups": 377,
|
||||
"lib/api-handler.ts :: apiHandler": 207,
|
||||
"lib/api-handler.ts :: apiHandler > wrapped": 201,
|
||||
"lib/auth/profile-update.ts :: applyProfileUpdate": 353,
|
||||
@@ -389,7 +389,7 @@
|
||||
"lib/measurements/dense-intraday-retention.ts :: runDenseIntradayRetention > walkSource > writeDay > foldOnce": 212,
|
||||
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative": 344,
|
||||
"lib/measurements/drain-per-sample-cumulative.ts :: drainPerSampleCumulative > writeDay": 205,
|
||||
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1256,
|
||||
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml": 1245,
|
||||
"lib/measurements/import-apple-health-export.ts :: streamParseExportXml > (anonymous) #2": 323,
|
||||
"lib/medications/efficacy/build-efficacy.ts :: buildMedicationEfficacy": 268,
|
||||
"lib/medications/intake-slot-dedup.ts :: dedupeUserIntakeSlots": 321,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
|
||||
"count": 2959,
|
||||
"total": 10525,
|
||||
"total": 10524,
|
||||
"entries": {
|
||||
"__tests__/account-payload-consumer-guard.test.ts": 2,
|
||||
"__tests__/acting-account-boundary-guard.test.ts": 7,
|
||||
@@ -1885,7 +1885,7 @@
|
||||
"lib/analytics/sleep-night.ts": 3,
|
||||
"lib/analytics/source-priority.ts": 2,
|
||||
"lib/analytics/source-rank-sql.ts": 3,
|
||||
"lib/analytics/summaries-slice.ts": 37,
|
||||
"lib/analytics/summaries-slice.ts": 36,
|
||||
"lib/analytics/trend-delta.ts": 1,
|
||||
"lib/analytics/trends.ts": 6,
|
||||
"lib/analytics/value-bands.ts": 1,
|
||||
|
||||
@@ -79,6 +79,8 @@ const WRITE_SITES: Record<string, string> = {
|
||||
"a primitive; every caller runs the tail over the verdicts' dirty identities",
|
||||
"lib/measurements/import-apple-health-export.ts":
|
||||
"the import worker refolds the import's span and re-warms every imported type",
|
||||
"lib/measurements/import-unchanged-rows.ts":
|
||||
"the Apple export import's natural-key rescue, inside the import; its worker runs both legs once over the whole import",
|
||||
"lib/import/health-connect/import.ts":
|
||||
"the Health Connect import worker refolds the import's span and re-warms every imported type",
|
||||
"lib/export/measurements-restore.ts":
|
||||
|
||||
@@ -93,7 +93,6 @@ const HELPER: Entry = { helper: true };
|
||||
|
||||
/** Files with a database mean over values. */
|
||||
const SQL_READERS: Record<string, Entry> = {
|
||||
"app/api/measurements/series/route.ts": HELPER,
|
||||
"lib/ai/coach/tools/availability.ts": HELPER,
|
||||
"lib/analytics/summaries-slice.ts": HELPER,
|
||||
"lib/doctor-report/dense-buckets.ts": HELPER,
|
||||
@@ -102,6 +101,8 @@ const SQL_READERS: Record<string, Entry> = {
|
||||
// The dry run's hour means, weighed by both methods through the helper.
|
||||
"lib/jobs/pulse-day-mean.ts": HELPER,
|
||||
"lib/measurements/daily-series-read.ts": HELPER,
|
||||
// The series route's long-window dense read.
|
||||
"lib/measurements/dense-series-read.ts": HELPER,
|
||||
"lib/rollups/measurement-rollups.ts": HELPER,
|
||||
"lib/rollups/tiered-context.ts": HELPER,
|
||||
"lib/insights/correlation-channel-series.ts": {
|
||||
|
||||
@@ -576,15 +576,21 @@ describe("a batch of duplicates only (v1.42)", () => {
|
||||
});
|
||||
|
||||
describe("cache invalidation by origin (v1.42)", () => {
|
||||
// Every call also says how far back the batch reached (`touchedFrom`), so
|
||||
// closed blocks of older readings stay cached.
|
||||
it.each([
|
||||
["foreground", { evict: true }],
|
||||
["manual", { evict: true }],
|
||||
["background", undefined],
|
||||
["push", undefined],
|
||||
[undefined, undefined],
|
||||
["background", {}],
|
||||
["push", {}],
|
||||
[undefined, {}],
|
||||
])("syncTrigger %s → %o", async (trigger, expected) => {
|
||||
await post([weight(`uuid-${trigger}`, new Date(NOW - DAY))], trigger);
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, expected);
|
||||
const at = new Date(NOW - DAY);
|
||||
await post([weight(`uuid-${trigger}`, at)], trigger);
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
|
||||
...expected,
|
||||
touchedFrom: at,
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts for a background batch that wrote a hand-entered reading", async () => {
|
||||
@@ -599,6 +605,7 @@ describe("cache invalidation by origin (v1.42)", () => {
|
||||
);
|
||||
expect(invalidateUserMeasurements).toHaveBeenCalledWith(USER, {
|
||||
evict: true,
|
||||
touchedFrom: new Date(NOW - DAY),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,14 @@ import {
|
||||
oppositeMergeSource,
|
||||
type MergeCandidate,
|
||||
} from "@/lib/measurements/cross-source-merge";
|
||||
import {
|
||||
externalKeyOf,
|
||||
onceInBatch,
|
||||
readStoredSampleIdentities,
|
||||
readStoredStatsRows,
|
||||
sampleIsStoredDuplicate,
|
||||
statsWriteUnchanged,
|
||||
} from "@/lib/measurements/batch-stored-rows";
|
||||
import {
|
||||
lockMeasurementUser,
|
||||
reconcileExternalMeasurement,
|
||||
@@ -643,6 +651,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
/**
|
||||
* `stats:` re-posts reported `updated` with nothing to write (see
|
||||
* `isUnchangedStatsRow`). Counted in the response's `updated` like before,
|
||||
* and in nothing that starts work.
|
||||
*/
|
||||
let unchangedStatsCount = 0;
|
||||
let duplicateCount = 0;
|
||||
let failedCount = 0;
|
||||
// v1.44 — subset of `insertedCount` written by the set-based insert.
|
||||
@@ -769,82 +783,52 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// before this one is reached, and then the reconciler has to decide.
|
||||
// `stats:` rows are never prefiltered (overwrite semantics), and neither
|
||||
// is a value the reconciler's own plausibility gate would refuse.
|
||||
const externalKeyCounts = new Map<string, number>();
|
||||
const naturalKeyCounts = new Map<string, number>();
|
||||
const naturalKeyOf = (
|
||||
type: unknown,
|
||||
source: unknown,
|
||||
measuredAt: Date,
|
||||
sleepStage: unknown,
|
||||
) => `${type}::${source}::${measuredAt.getTime()}::${sleepStage ?? "null"}`;
|
||||
for (const p of prepared) {
|
||||
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
|
||||
externalKeyCounts.set(
|
||||
externalKey,
|
||||
(externalKeyCounts.get(externalKey) ?? 0) + 1,
|
||||
);
|
||||
const naturalKey = naturalKeyOf(
|
||||
p.row.type,
|
||||
p.row.source,
|
||||
p.row.measuredAt as Date,
|
||||
p.row.sleepStage,
|
||||
);
|
||||
naturalKeyCounts.set(
|
||||
naturalKey,
|
||||
(naturalKeyCounts.get(naturalKey) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
const sampleExternalIds = [
|
||||
const onceInThisBatch = onceInBatch(prepared.map((p) => p.row));
|
||||
const existingByExternalKey = await readStoredSampleIdentities(
|
||||
prisma,
|
||||
user.id,
|
||||
[
|
||||
...new Set(
|
||||
prepared
|
||||
.map((p) => p.row.externalId as string)
|
||||
.filter((externalId) => !isStatsExternalId(externalId)),
|
||||
),
|
||||
],
|
||||
);
|
||||
const isExactStoredDuplicate = (p: Prepared): boolean =>
|
||||
sampleIsStoredDuplicate(
|
||||
existingByExternalKey.get(
|
||||
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
|
||||
),
|
||||
p.row,
|
||||
) &&
|
||||
onceInThisBatch(p.row) &&
|
||||
classifyRangeRejection(
|
||||
p.row.type as MeasurementType,
|
||||
p.row.value as number,
|
||||
) === null;
|
||||
|
||||
// An unchanged `stats:` re-post is reported `updated` without a write
|
||||
// (see `batch-stored-rows.ts`), under the same once-per-batch key rule.
|
||||
const storedStats = await readStoredStatsRows(prisma, user.id, [
|
||||
...new Set(
|
||||
prepared
|
||||
.map((p) => p.row.externalId as string)
|
||||
.filter((externalId) => !isStatsExternalId(externalId)),
|
||||
.filter((externalId) => isStatsExternalId(externalId)),
|
||||
),
|
||||
];
|
||||
const existingByExternalKey = new Map<
|
||||
string,
|
||||
{ measuredAt: Date; sleepStage: unknown }
|
||||
>();
|
||||
if (sampleExternalIds.length > 0) {
|
||||
const existing = await prisma.measurement.findMany({
|
||||
where: { userId: user.id, externalId: { in: sampleExternalIds } },
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
},
|
||||
});
|
||||
for (const row of existing) {
|
||||
existingByExternalKey.set(
|
||||
`${row.type}::${row.source}::${row.externalId}`,
|
||||
{ measuredAt: row.measuredAt, sleepStage: row.sleepStage },
|
||||
);
|
||||
}
|
||||
}
|
||||
function isExactStoredDuplicate(p: Prepared): boolean {
|
||||
const externalKey = `${p.row.type}::${p.row.source}::${p.row.externalId}`;
|
||||
const stored = existingByExternalKey.get(externalKey);
|
||||
if (!stored) return false;
|
||||
const measuredAt = p.row.measuredAt as Date;
|
||||
const naturalKey = naturalKeyOf(
|
||||
p.row.type,
|
||||
p.row.source,
|
||||
measuredAt,
|
||||
p.row.sleepStage,
|
||||
]);
|
||||
const isUnchangedStatsRow = (p: Prepared): boolean =>
|
||||
onceInThisBatch(p.row) &&
|
||||
classifyRangeRejection(
|
||||
p.row.type as MeasurementType,
|
||||
p.row.value as number,
|
||||
) === null &&
|
||||
statsWriteUnchanged(
|
||||
storedStats.get(
|
||||
externalKeyOf(p.row.type, p.row.source, p.row.externalId),
|
||||
),
|
||||
desiredFor(p),
|
||||
);
|
||||
return (
|
||||
stored.measuredAt.getTime() === measuredAt.getTime() &&
|
||||
(stored.sleepStage ?? null) === (p.row.sleepStage ?? null) &&
|
||||
externalKeyCounts.get(externalKey) === 1 &&
|
||||
naturalKeyCounts.get(naturalKey) === 1 &&
|
||||
classifyRangeRejection(
|
||||
p.row.type as MeasurementType,
|
||||
p.row.value as number,
|
||||
) === null
|
||||
);
|
||||
}
|
||||
|
||||
// v1.42 — the `folded_window` guard (`folded-window.ts`). A raw Apple
|
||||
// Health sample of a dense or mean type, old enough to have been folded,
|
||||
@@ -1063,6 +1047,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (statsRow && isUnchangedStatsRow(p)) {
|
||||
results[p.index] = { index: p.index, status: "updated" };
|
||||
unchangedStatsCount++;
|
||||
pushMergeCandidate(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!statsRow && hasSameReadingSibling(p)) {
|
||||
results[p.index] = {
|
||||
index: p.index,
|
||||
@@ -1093,16 +1084,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
const naturalKey = naturalKeyOf(
|
||||
p.row.type,
|
||||
p.row.source,
|
||||
p.row.measuredAt as Date,
|
||||
p.row.sleepStage,
|
||||
);
|
||||
if (
|
||||
externalKeyCounts.get(key) === 1 &&
|
||||
naturalKeyCounts.get(naturalKey) === 1
|
||||
) {
|
||||
if (onceInThisBatch(p.row)) {
|
||||
pendingInserts.push(p);
|
||||
pushMergeCandidate(p);
|
||||
continue;
|
||||
@@ -1311,7 +1293,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
details: {
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
updated: updatedCount + unchangedStatsCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
failed: failedCount,
|
||||
@@ -1449,7 +1431,8 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
meta: {
|
||||
processed: entries.length,
|
||||
inserted: insertedCount,
|
||||
updated: updatedCount,
|
||||
updated: updatedCount + unchangedStatsCount,
|
||||
stats_unchanged: unchangedStatsCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
// v1.32.1 (issue #585) — per-reason skip breakdown; see the
|
||||
@@ -1497,7 +1480,17 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
syncTrigger === "foreground" ||
|
||||
syncTrigger === "manual" ||
|
||||
wroteManualRow;
|
||||
invalidateUserMeasurements(user.id, evict ? { evict: true } : undefined);
|
||||
// Every row this batch created, changed or retired is in
|
||||
// `writtenIdentities`, so its earliest `measuredAt` bounds what changed:
|
||||
// closed blocks of older readings (the pre-fold remainder, the day
|
||||
// view's past history blocks) stay cached across the sync.
|
||||
const touchedFrom = new Date(
|
||||
Math.min(...writtenIdentities.map((w) => w.measuredAt.getTime())),
|
||||
);
|
||||
invalidateUserMeasurements(user.id, {
|
||||
...(evict ? { evict: true } : {}),
|
||||
...(Number.isFinite(touchedFrom.getTime()) ? { touchedFrom } : {}),
|
||||
});
|
||||
|
||||
// v1.37.19 — shared post-mutation tail for every distinct
|
||||
// (type, day) the batch touched. Updates participate as well as
|
||||
@@ -1563,7 +1556,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const response = apiSuccess({
|
||||
processed: entries.length,
|
||||
inserted: insertedCount + hourInsertedCount,
|
||||
updated: updatedCount + hourUpdatedCount,
|
||||
updated: updatedCount + unchangedStatsCount + hourUpdatedCount,
|
||||
duplicates: duplicateCount,
|
||||
failed: failedCount,
|
||||
skipped,
|
||||
|
||||
@@ -21,6 +21,51 @@ vi.mock("@/lib/db", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// The route's dense read is one statement (`dense-series-read.ts`, held to
|
||||
// the two statements it replaced by an integration parity test). Here it
|
||||
// issues those two statements again, so the route's shaping of buckets and
|
||||
// figures is pinned against the same queued results as before.
|
||||
vi.mock("@/lib/measurements/dense-series-read", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@/lib/measurements/dense-series-read")
|
||||
>();
|
||||
const { usesHourlyMeanDay } =
|
||||
await import("@/lib/measurements/day-statistic");
|
||||
return {
|
||||
...actual,
|
||||
readDenseSeries: async (args: {
|
||||
userId: string;
|
||||
type: Parameters<typeof usesHourlyMeanDay>[0];
|
||||
grain: "day" | "hour";
|
||||
userTz: string;
|
||||
since: Date;
|
||||
seriesRows: string;
|
||||
}) => {
|
||||
const { prisma } = await import("@/lib/db");
|
||||
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
|
||||
const bucketRows = await prisma.$queryRawUnsafe(
|
||||
actual.denseBucketsSql(
|
||||
hourWeighted,
|
||||
`${args.seriesRows} WHERE m."measured_at" >= $4`,
|
||||
),
|
||||
args.userId,
|
||||
args.grain,
|
||||
args.userTz,
|
||||
args.since,
|
||||
);
|
||||
const [rawAgg] = (await prisma.$queryRawUnsafe(
|
||||
`WITH src AS (SELECT m.* FROM ${args.seriesRows} WHERE m."measured_at" >= $4) ${actual.denseFiguresSql(usesHourlyMeanDay(args.type))}`,
|
||||
args.userId,
|
||||
args.grain,
|
||||
args.userTz,
|
||||
args.since,
|
||||
)) as unknown[];
|
||||
return { bucketRows, rawAgg };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
// v1.11.4 — the sleep branch resolves the user's tz to bucket per-night;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* window, judged by that direction) and `referenceLines` (the person's own
|
||||
* target lines, else the clinical ones, in the unit of the points).
|
||||
*/
|
||||
import { readDenseSeries } from "@/lib/measurements/dense-series-read";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -33,14 +34,7 @@ import { readSleepNights } from "@/lib/measurements/list/sleep-nights";
|
||||
import { VALUE_RANGES } from "@/lib/validations/measurement";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
import {
|
||||
dayWeightedRows,
|
||||
hourWeightSql,
|
||||
readingsMean,
|
||||
weightedHourMeanSql,
|
||||
windowMeanSql,
|
||||
zoneDayFrame,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { readingsMean } from "@/lib/measurements/day-mean";
|
||||
import { seriesRowsFrom } from "@/lib/measurements/series-canonical";
|
||||
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
|
||||
import { resolveUserTimezone, userDayKey } from "@/lib/tz/resolver";
|
||||
@@ -461,135 +455,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// weighted mean of its local hours' means, so a workout hour with hundreds
|
||||
// of times the readings does not outweigh the rest of the day (see
|
||||
// `day-statistic.ts`). The hour grain is already one mean per hour.
|
||||
const hourWeighted = grain === "day" && usesHourlyMeanDay(type);
|
||||
const bucketRows = await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}>
|
||||
>(
|
||||
hourWeighted
|
||||
? `
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
'hour',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_hour
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $4
|
||||
),
|
||||
hourly AS (
|
||||
SELECT
|
||||
date_trunc($2, local_hour) AS local_day,
|
||||
AVG("value") AS hour_mean,
|
||||
COUNT(*) AS n,
|
||||
MIN(COALESCE("value_min", "value")) AS min_value,
|
||||
MAX(COALESCE("value_max", "value")) AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_hour
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
|
||||
MIN(min_value)::double precision AS min_value,
|
||||
MAX(max_value)::double precision AS max_value
|
||||
FROM hourly
|
||||
GROUP BY local_day
|
||||
ORDER BY bucket_start ASC
|
||||
`
|
||||
: `
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
$2,
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_day
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $4
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
AVG("value")::double precision AS mean,
|
||||
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
|
||||
MAX(COALESCE("value_max", "value"))::double precision AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_day
|
||||
ORDER BY bucket_start ASC
|
||||
`,
|
||||
user.id,
|
||||
const { bucketRows, rawAgg } = await readDenseSeries({
|
||||
userId: user.id,
|
||||
type,
|
||||
grain,
|
||||
userTz,
|
||||
since,
|
||||
);
|
||||
// Stats stay aggregated over the RAW rows (not the day buckets) so the
|
||||
// strip's mean/min/max/stdDev/count are the same figures the raw path
|
||||
// computed: `AVG`/`MIN`/`MAX` over `value`, `STDDEV_POP` matching the
|
||||
// JS population-variance helper, exact row count. Aggregate-only —
|
||||
// no row transfer. For an hourly-mean type (pulse) the mean is the mean
|
||||
// of the local days' values, each day the mean of its local hours'
|
||||
// means, so it is the mean of the day points the chart draws; count,
|
||||
// min, max and the spread stay over every reading.
|
||||
const [rawAgg] = usesHourlyMeanDay(type)
|
||||
? await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
WITH src AS (
|
||||
SELECT m.*
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
)
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
|
||||
AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
|
||||
GROUP BY m."type"
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
userTz,
|
||||
)
|
||||
: await prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}>
|
||||
>(
|
||||
`
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM ${seriesRows}
|
||||
WHERE m."measured_at" >= $2
|
||||
`,
|
||||
user.id,
|
||||
since,
|
||||
);
|
||||
seriesRows,
|
||||
});
|
||||
const round2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const dayId = (d: Date) =>
|
||||
grain === "day"
|
||||
|
||||
@@ -25,7 +25,9 @@ import localFont from "next/font/local";
|
||||
|
||||
// `next/font/local` is evaluated at build time from the literal arguments of
|
||||
// each call, so every option below has to be written out in place.
|
||||
export const interCyrillicExt = localFont({
|
||||
// The bindings are never read: importing this module is what attaches the
|
||||
// faces, so each name carries the leading underscore of an intended unused.
|
||||
const _interCyrillicExt = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./inter/inter-cyrillic-ext.woff2",
|
||||
@@ -61,7 +63,7 @@ export const interCyrillicExt = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interCyrillic = localFont({
|
||||
const _interCyrillic = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-cyrillic.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-cyrillic.woff2", weight: "500", style: "normal" },
|
||||
@@ -80,7 +82,7 @@ export const interCyrillic = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interGreekExt = localFont({
|
||||
const _interGreekExt = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-greek-ext.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-greek-ext.woff2", weight: "500", style: "normal" },
|
||||
@@ -96,7 +98,7 @@ export const interGreekExt = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interGreek = localFont({
|
||||
const _interGreek = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-greek.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-greek.woff2", weight: "500", style: "normal" },
|
||||
@@ -116,7 +118,7 @@ export const interGreek = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interVietnamese = localFont({
|
||||
const _interVietnamese = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-vietnamese.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-vietnamese.woff2", weight: "500", style: "normal" },
|
||||
@@ -136,7 +138,7 @@ export const interVietnamese = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interLatinExt = localFont({
|
||||
const _interLatinExt = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-latin-ext.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-latin-ext.woff2", weight: "500", style: "normal" },
|
||||
@@ -156,7 +158,7 @@ export const interLatinExt = localFont({
|
||||
],
|
||||
});
|
||||
|
||||
export const interLatin = localFont({
|
||||
const _interLatin = localFont({
|
||||
src: [
|
||||
{ path: "./inter/inter-latin.woff2", weight: "400", style: "normal" },
|
||||
{ path: "./inter/inter-latin.woff2", weight: "500", style: "normal" },
|
||||
|
||||
+23
-9
@@ -3,6 +3,18 @@ import type { Instrumentation } from "next";
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
// The event-loop monitor comes first, so the loading of the database
|
||||
// client and of the worker graph is inside its view, and every stall it
|
||||
// reports names the boot step that was running (`boot-steps.ts`).
|
||||
const [{ startEventLoopLagMonitor }, { beginBootStep }] = await Promise.all(
|
||||
[
|
||||
import("@/lib/observability/event-loop-lag"),
|
||||
import("@/lib/boot/boot-steps"),
|
||||
],
|
||||
);
|
||||
startEventLoopLagMonitor();
|
||||
beginBootStep("readiness_summary");
|
||||
|
||||
// First-boot readiness summary: a human-readable block on the core secrets
|
||||
// (with the exact fix on a bad one) before any fail-closed loader throws,
|
||||
// plus a one-glance status of the optional channels. Runs for every process
|
||||
@@ -18,6 +30,7 @@ export async function register() {
|
||||
// Does the configured encryption key open this database? A process whose
|
||||
// key does not refuses every API request with one clear 503 and starts
|
||||
// neither the producer nor the worker (a wrong key must never write).
|
||||
beginBootStep("key_check");
|
||||
try {
|
||||
const [{ runBootKeyCheck }, { prisma }] = await Promise.all([
|
||||
import("@/lib/boot/key-check"),
|
||||
@@ -29,6 +42,7 @@ export async function register() {
|
||||
}
|
||||
|
||||
// Every connection is pinned to UTC; say clearly if one is not.
|
||||
beginBootStep("time_zone_check");
|
||||
try {
|
||||
const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([
|
||||
import("@/lib/boot/time-zone-check"),
|
||||
@@ -41,6 +55,7 @@ export async function register() {
|
||||
|
||||
// Every pulse reader follows the operator's pulse day-mean method; mirror
|
||||
// it in this process and keep the mirror current.
|
||||
beginBootStep("pulse_method_sync");
|
||||
try {
|
||||
const { startPulseDayMeanMethodSync } =
|
||||
await import("@/lib/measurements/pulse-day-mean-method");
|
||||
@@ -49,15 +64,10 @@ export async function register() {
|
||||
// Without the mirror the process keeps the default method.
|
||||
}
|
||||
|
||||
// Every Node process watches its own event loop — web-only
|
||||
// containers included; a stall report from the process serving
|
||||
// requests is the whole point.
|
||||
const { startEventLoopLagMonitor } =
|
||||
await import("@/lib/observability/event-loop-lag");
|
||||
startEventLoopLagMonitor();
|
||||
|
||||
// The same for its heap: a trend line, and one report naming the work in
|
||||
// flight before the heap runs out (a heap-limit crash logs nothing else).
|
||||
// Every Node process watches its heap as it watches its event loop: a
|
||||
// trend line, and one report naming the work in flight before the heap
|
||||
// runs out (a heap-limit crash logs nothing else).
|
||||
beginBootStep("monitors");
|
||||
try {
|
||||
const [{ startHttpInFlightTracking }, { startHeapMonitor }] =
|
||||
await Promise.all([
|
||||
@@ -116,6 +126,7 @@ export async function register() {
|
||||
// process owns no schedules, supervision, or consumers.
|
||||
void (async () => {
|
||||
try {
|
||||
beginBootStep("producer_start");
|
||||
const { startGlobalBossProducer } =
|
||||
await import("@/lib/jobs/boss-instance");
|
||||
const boss = await startGlobalBossProducer(process.env.DATABASE_URL);
|
||||
@@ -125,6 +136,8 @@ export async function register() {
|
||||
registerShutdownHandlers(boss);
|
||||
}
|
||||
ready(boss !== null);
|
||||
const { reportBootSteps } = await import("@/lib/boot/boot-steps");
|
||||
await reportBootSteps();
|
||||
} catch (err) {
|
||||
ready(false);
|
||||
// Queue-backed garnishes degrade independently; never make the web
|
||||
@@ -147,6 +160,7 @@ export async function register() {
|
||||
startupEvt.setBackground({ task_name: "startup" });
|
||||
|
||||
try {
|
||||
beginBootStep("worker_import");
|
||||
const { startReminderWorker } =
|
||||
await import("@/lib/jobs/reminder-worker");
|
||||
await startReminderWorker();
|
||||
|
||||
@@ -21,8 +21,10 @@ describe("per-process database connection budget", () => {
|
||||
const originalConnectionLimit = process.env.DB_CONNECTION_LIMIT;
|
||||
const originalPoolTimeout = process.env.DB_POOL_TIMEOUT;
|
||||
const originalLegacyPoolMax = process.env.DATABASE_POOL_MAX;
|
||||
const originalProcessType = process.env.HEALTHLOG_PROCESS_TYPE;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.HEALTHLOG_PROCESS_TYPE = "web";
|
||||
process.env.DATABASE_URL =
|
||||
"postgresql://healthlog:test@db:5432/healthlog?connection_limit=20&pool_timeout=20";
|
||||
delete process.env.DB_CONNECTION_LIMIT;
|
||||
@@ -39,6 +41,7 @@ describe("per-process database connection budget", () => {
|
||||
restore("DB_CONNECTION_LIMIT", originalConnectionLimit);
|
||||
restore("DB_POOL_TIMEOUT", originalPoolTimeout);
|
||||
restore("DATABASE_POOL_MAX", originalLegacyPoolMax);
|
||||
restore("HEALTHLOG_PROCESS_TYPE", originalProcessType);
|
||||
});
|
||||
|
||||
it("splits the URL connection_limit across Prisma and pg-boss", () => {
|
||||
@@ -48,6 +51,17 @@ describe("per-process database connection budget", () => {
|
||||
expect(getPrismaPoolMax() + getPgBossPoolMax()).toBe(getConnectionBudget());
|
||||
});
|
||||
|
||||
it("gives a process that runs the worker four pg-boss connections", () => {
|
||||
for (const role of ["all", "worker"]) {
|
||||
process.env.HEALTHLOG_PROCESS_TYPE = role;
|
||||
expect(getPgBossPoolMax()).toBe(4);
|
||||
expect(getPrismaPoolMax()).toBe(16);
|
||||
}
|
||||
process.env.DB_CONNECTION_LIMIT = "3";
|
||||
expect(getPgBossPoolMax()).toBe(2);
|
||||
expect(getPrismaPoolMax()).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the existing pool_timeout seconds for both pool constructors", () => {
|
||||
expect(getPoolConnectionTimeoutMs()).toBe(20_000);
|
||||
});
|
||||
@@ -138,14 +152,14 @@ describe("buildSessionOptions", () => {
|
||||
it("keeps work_mem when the timeout is disabled", () => {
|
||||
process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0";
|
||||
delete process.env.DATABASE_WORK_MEM;
|
||||
expect(buildSessionOptions()).toBe("-c work_mem=16MB -c TimeZone=UTC");
|
||||
expect(buildSessionOptions()).toBe("-c work_mem=16MB -c jit=off -c TimeZone=UTC");
|
||||
});
|
||||
|
||||
it("sets the default work_mem beside the timeouts", () => {
|
||||
delete process.env.DATABASE_STATEMENT_TIMEOUT_MS;
|
||||
delete process.env.DATABASE_WORK_MEM;
|
||||
expect(buildSessionOptions()).toBe(
|
||||
"-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c TimeZone=UTC",
|
||||
"-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c jit=off -c TimeZone=UTC",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* (`PUT /api/auth/me/ai-usage`); a managed profile has no choice of its own
|
||||
* and follows the instance default.
|
||||
*/
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
@@ -83,14 +84,7 @@ export function aiUsageSettingsFromRow(
|
||||
export function loadAiUsageSettings(): Promise<AiUsageSettings> {
|
||||
const read = async (): Promise<AiUsageSettings> => {
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
},
|
||||
});
|
||||
const row = await readOperatorRow();
|
||||
return aiUsageSettingsFromRow(row);
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
|
||||
@@ -388,7 +388,17 @@ function buildDerivedHealthTokens(): ReadonlySet<string> {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
|
||||
let memoDerivedHealthTokensCache:
|
||||
ReturnType<typeof memoDerivedHealthTokensBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function memoDerivedHealthTokens(): ReturnType<
|
||||
typeof memoDerivedHealthTokensBuild
|
||||
> {
|
||||
return (memoDerivedHealthTokensCache ??= memoDerivedHealthTokensBuild());
|
||||
}
|
||||
function memoDerivedHealthTokensBuild() {
|
||||
return buildDerivedHealthTokens();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: the guard suite asserts the derived set is non-empty and that
|
||||
@@ -396,13 +406,13 @@ const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
|
||||
* metric labels fails the suite rather than quietly narrowing the allow-list.
|
||||
*/
|
||||
export function derivedHealthTokens(): ReadonlySet<string> {
|
||||
return DERIVED_HEALTH_TOKENS;
|
||||
return memoDerivedHealthTokens();
|
||||
}
|
||||
|
||||
/** True when any whole word of the message is a health noun from the bundles. */
|
||||
function matchesDerivedHealthToken(message: string): boolean {
|
||||
for (const word of healthWordTokens(message)) {
|
||||
if (DERIVED_HEALTH_TOKENS.has(word)) return true;
|
||||
if (memoDerivedHealthTokens().has(word)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+2
-14
@@ -1,4 +1,5 @@
|
||||
import type { AIProvider, CompletionResult } from "./types";
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { bindResponseTimeout } from "./effective-timeout";
|
||||
import { bindReasoningEffort } from "./reasoning-effort";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -1145,16 +1146,6 @@ const PRESENCE_USER_SELECT = {
|
||||
aiResponseTimeoutSeconds: true,
|
||||
} as const;
|
||||
|
||||
const PRESENCE_SETTINGS_SELECT = {
|
||||
adminAiKeyEncrypted: true,
|
||||
adminAiModel: true,
|
||||
adminAiBaseUrl: true,
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
} as const;
|
||||
|
||||
const NO_PRESENCE: ProviderPresence = Object.freeze({
|
||||
entries: [],
|
||||
localOcrEnabled: false,
|
||||
@@ -1217,10 +1208,7 @@ export function probeProviderChain(
|
||||
where: { id: recordId },
|
||||
select: PRESENCE_USER_SELECT,
|
||||
}),
|
||||
prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: PRESENCE_SETTINGS_SELECT,
|
||||
}),
|
||||
readOperatorRow(),
|
||||
]);
|
||||
if (!row) return NO_PRESENCE;
|
||||
const policy = providerCredentialPolicy(authority, row.managedProfileAt);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* Server-only. The decision itself is the pure `resolveReasoning` in
|
||||
* `./resolve.ts`; this file only gathers its inputs.
|
||||
*/
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
@@ -70,10 +71,7 @@ function loadMemoisedControls(): Promise<ReasoningAdminControls> {
|
||||
"ai-reasoning-controls",
|
||||
async () => {
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true },
|
||||
});
|
||||
const row = await readOperatorRow();
|
||||
return {
|
||||
enabled: row?.aiReasoningEnabled ?? true,
|
||||
maxEffort: parseReasoningMaxEffort(row?.aiReasoningMaxEffort),
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The pre-fold remainder of the dashboard slice: per type, the all-time
|
||||
* figures of every reading older than the rollup fold window.
|
||||
*
|
||||
* The slice splices it into its per-type figures, so "all-time" means all time
|
||||
* on the rollup path too (empty for any account younger than the
|
||||
* window). It keeps one source per (type, day) the way the rollup half
|
||||
* and the live path do: without it, a vital recorded by two devices
|
||||
* counted both of them on every pre-fold day. Each source is folded per
|
||||
* day first (a hash aggregate, a few rows per day however dense the
|
||||
* stream), each day then keeps its ladder-canonical source, so the
|
||||
* sort never sees more than one row per source per day.
|
||||
* An hourly-mean type folds each source's day as the mean of its
|
||||
* hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
|
||||
* the remainder once each, the statistic the DAY rollup stores.
|
||||
*
|
||||
* It is a closed block: it aggregates only readings measured before
|
||||
* `foldSplit`, the start of a UTC day five years back, so a sync of newer
|
||||
* readings cannot change it. On a long history it was the slice's largest
|
||||
* statement (every reading older than five years, folded per source and day),
|
||||
* and the slice is rebuilt after every sync. It is cached under a closed-block
|
||||
* key (`closedBlockKeyPart`): a measurement write that reaches back before
|
||||
* `foldSplit`, and every write that does not say how far back it reached,
|
||||
* drops it; the split moves on at the next UTC day, which names a new key.
|
||||
* The TTL bounds an entry that a write in another process (a split worker)
|
||||
* cannot reach.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
|
||||
import { caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { dayWeightedRowsSql, foldMeanSql } from "@/lib/measurements/day-mean";
|
||||
|
||||
export type PreFoldRemainderRow = {
|
||||
type: string;
|
||||
count: number;
|
||||
days: number;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
};
|
||||
|
||||
/** A closed block's lifetime when no write reaches it. */
|
||||
const PRE_FOLD_REMAINDER_TTL_MS = 15 * 60_000;
|
||||
|
||||
/** The remainder as a live read, uncached. */
|
||||
export function readPreFoldRemainderLive(
|
||||
userId: string,
|
||||
foldSplit: Date,
|
||||
rankPerSource: string,
|
||||
): Promise<PreFoldRemainderRow[]> {
|
||||
return prisma.$queryRaw<PreFoldRemainderRow[]>`
|
||||
WITH src AS (
|
||||
SELECT *
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', m."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(m."value")::double precision AS total,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
SUM(m."value" * m.day_weight)::double precision AS wsum,
|
||||
SUM(m.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
|
||||
FROM per_source p
|
||||
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
SUM(c.cnt)::int AS count,
|
||||
COUNT(*)::int AS days,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max,
|
||||
${Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
)}::double precision AS mean
|
||||
FROM canon c
|
||||
GROUP BY c."type"
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The remainder through its closed-block cache cell. `rankPerSource` is the
|
||||
* ladder CASE over `p."type"` / `p."source"`, built from `priorityJson`,
|
||||
* which names the cell.
|
||||
*/
|
||||
export async function readPreFoldRemainder(
|
||||
userId: string,
|
||||
foldSplit: Date,
|
||||
priorityJson: unknown,
|
||||
rankPerSource: string,
|
||||
): Promise<PreFoldRemainderRow[]> {
|
||||
const ladder = createHash("sha256")
|
||||
.update(JSON.stringify(priorityJson ?? null))
|
||||
.digest("base64url")
|
||||
.slice(0, 16);
|
||||
const cache = caches.analytics as ServerCache<PreFoldRemainderRow[]>;
|
||||
const { value } = await cache.wrap(
|
||||
`${userId}|pre-fold-remainder|${closedBlockKeyPart(foldSplit)}|${ladder}`,
|
||||
() => readPreFoldRemainderLive(userId, foldSplit, rankPerSource),
|
||||
PRE_FOLD_REMAINDER_TTL_MS,
|
||||
);
|
||||
return value;
|
||||
}
|
||||
@@ -127,6 +127,16 @@ function assertInterval(value: string): string {
|
||||
* is whitelisted and, when given, scopes both the inner pick and the outer
|
||||
* filter to a trailing window. `onlyType`, when given, scopes both halves to
|
||||
* that one metric.
|
||||
*
|
||||
* The pick groups before it sorts. Its order (rank, then source name) depends
|
||||
* only on a row's `(type, source)`, so every reading of one source on one day
|
||||
* sorts alike, and the distinct `(type, day, source)` triples carry the whole
|
||||
* decision. A hash aggregate reduces the window's rows to those triples (a
|
||||
* few thousand) and `DISTINCT ON` sorts only them; sorting every raw row by
|
||||
* a CASE expression no index can supply spilled to disk on a large account.
|
||||
* The picked source per day is the same by construction;
|
||||
* `tests/integration/canonical-source-pick-parity.test.ts` holds the old
|
||||
* statement as its reference.
|
||||
*/
|
||||
export function canonicalMeasurementsCte(
|
||||
rankUnqualified: string,
|
||||
@@ -152,16 +162,20 @@ export function canonicalMeasurementsCte(
|
||||
SELECT mm.*
|
||||
FROM measurements mm
|
||||
JOIN (
|
||||
SELECT DISTINCT ON ("type", date_trunc('day', "measured_at"))
|
||||
"type" AS t,
|
||||
date_trunc('day', "measured_at") AS d,
|
||||
"source" AS canon
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "deleted_at" IS NULL
|
||||
${sinceInner}
|
||||
${typeInner}
|
||||
ORDER BY "type", date_trunc('day', "measured_at"), (${rankUnqualified}), "source"::text
|
||||
SELECT DISTINCT ON ("type", d)
|
||||
"type" AS t,
|
||||
d,
|
||||
"source" AS canon
|
||||
FROM (
|
||||
SELECT "type", date_trunc('day', "measured_at") AS d, "source"
|
||||
FROM measurements
|
||||
WHERE "user_id" = $1
|
||||
AND "deleted_at" IS NULL
|
||||
${sinceInner}
|
||||
${typeInner}
|
||||
GROUP BY "type", date_trunc('day', "measured_at"), "source"
|
||||
) g
|
||||
ORDER BY "type", d, (${rankUnqualified}), "source"::text
|
||||
) c
|
||||
ON c.t = mm."type"
|
||||
AND c.d = date_trunc('day', mm."measured_at")
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -69,8 +68,6 @@ import {
|
||||
SESSION_DAY_FRAME,
|
||||
dayValue,
|
||||
dayWeightedRows,
|
||||
dayWeightedRowsSql,
|
||||
foldMeanSql,
|
||||
isHourlyMeanTypeSql,
|
||||
windowMeanSql,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
@@ -78,6 +75,7 @@ import { wireSummary, type DataSummary } from "@/lib/analytics/trends";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { readPreFoldRemainder } from "@/lib/analytics/pre-fold-remainder";
|
||||
import {
|
||||
ensureUserRollupsFresh,
|
||||
readRollupBuckets,
|
||||
@@ -704,73 +702,9 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
|
||||
sumYy: true,
|
||||
},
|
||||
}),
|
||||
// v1.37.19 — all-time remainder OLDER than the fold window,
|
||||
// spliced into the per-type figures below so "all-time" means all time
|
||||
// on the rollup path too (empty for any account younger than the
|
||||
// window). It keeps one source per (type, day) the way the rollup half
|
||||
// and the live path do: without it, a vital recorded by two devices
|
||||
// counted both of them on every pre-fold day. Each source is folded per
|
||||
// day first (a hash aggregate, a few rows per day however dense the
|
||||
// stream), each day then keeps its ladder-canonical source, so the
|
||||
// sort never sees more than one row per source per day.
|
||||
// An hourly-mean type folds each source's day as the mean of its
|
||||
// hours' means (`wsum` / `wdays`, see `day-mean.ts`) and the days into
|
||||
// the remainder once each, the statistic the DAY rollup stores.
|
||||
prisma.$queryRaw<
|
||||
Array<{
|
||||
type: string;
|
||||
count: number;
|
||||
days: number;
|
||||
min: number;
|
||||
max: number;
|
||||
mean: number;
|
||||
}>
|
||||
>`
|
||||
WITH src AS (
|
||||
SELECT *
|
||||
FROM measurements m
|
||||
WHERE m."user_id" = ${userId}
|
||||
AND m."deleted_at" IS NULL
|
||||
AND m."measured_at" < ${foldSplit}
|
||||
),
|
||||
per_source AS (
|
||||
SELECT
|
||||
m."type",
|
||||
m."source",
|
||||
date_trunc('day', m."measured_at") AS day,
|
||||
COUNT(*)::int AS cnt,
|
||||
SUM(m."value")::double precision AS total,
|
||||
MIN(m."value")::double precision AS min_value,
|
||||
MAX(m."value")::double precision AS max_value,
|
||||
SUM(m."value" * m.day_weight)::double precision AS wsum,
|
||||
SUM(m.day_weight)::double precision AS wdays
|
||||
FROM ${dayWeightedRowsSql("src", null, { bySource: true })} m
|
||||
GROUP BY m."type", m."source", 3
|
||||
),
|
||||
canon AS (
|
||||
SELECT DISTINCT ON (p."type", p.day)
|
||||
p."type", p.cnt, p.total, p.min_value, p.max_value, p.wsum, p.wdays
|
||||
FROM per_source p
|
||||
ORDER BY p."type", p.day, (${Prisma.raw(rankPerSource)}), p."source"::text
|
||||
)
|
||||
SELECT
|
||||
c."type"::text AS type,
|
||||
SUM(c.cnt)::int AS count,
|
||||
COUNT(*)::int AS days,
|
||||
MIN(c.min_value)::double precision AS min,
|
||||
MAX(c.max_value)::double precision AS max,
|
||||
${Prisma.raw(
|
||||
foldMeanSql({
|
||||
typeColumn: 'c."type"',
|
||||
total: "c.total",
|
||||
count: "c.cnt",
|
||||
weightedSum: "c.wsum",
|
||||
weightSum: "c.wdays",
|
||||
}),
|
||||
)}::double precision AS mean
|
||||
FROM canon c
|
||||
GROUP BY c."type"
|
||||
`,
|
||||
// The all-time remainder older than the fold window (a closed block,
|
||||
// see `pre-fold-remainder.ts`).
|
||||
readPreFoldRemainder(userId, foldSplit, priorityJson, rankPerSource),
|
||||
]);
|
||||
|
||||
const preFoldByType = new Map<
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* out; the operator/CLI escape hatch (`scripts/disable-mfa.ts`, toggling the
|
||||
* policy off) is the recovery path of last resort.
|
||||
*/
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
@@ -44,10 +45,7 @@ export async function resolveMfaEnrollmentRequired(
|
||||
// Already has a confirmed TOTP secret → enrolled, nothing required.
|
||||
if (user.totpConfirmedAt) return false;
|
||||
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { mfaRequired: true },
|
||||
});
|
||||
const settings = await readOperatorRow();
|
||||
const policyApplies = Boolean(settings?.mfaRequired) || user.mfaEnforced;
|
||||
if (!policyApplies) return false;
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
beginBootStep,
|
||||
bootSteps,
|
||||
currentBootStep,
|
||||
endBootSteps,
|
||||
} from "../boot-steps";
|
||||
|
||||
describe("boot steps", () => {
|
||||
it("times each step until the next begins, and names the running one", () => {
|
||||
beginBootStep("a", 100);
|
||||
expect(currentBootStep()).toBe("a");
|
||||
beginBootStep("b", 130);
|
||||
expect(currentBootStep()).toBe("b");
|
||||
const steps = endBootSteps(200);
|
||||
expect(currentBootStep()).toBeNull();
|
||||
expect(steps.slice(-2)).toEqual([
|
||||
{ name: "a", startedAtMs: 100, durationMs: 30 },
|
||||
{ name: "b", startedAtMs: 130, durationMs: 70 },
|
||||
]);
|
||||
expect(bootSteps().slice(-2)).toEqual(steps.slice(-2));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Named boot steps with their timings, and the step running right now.
|
||||
*
|
||||
* A stall at boot used to be one number: "the loop was blocked 718 ms". The
|
||||
* event-loop monitor now names the step that was running when the late tick
|
||||
* fired (`currentBootStep`), and the boot writes one `boot.steps` line with
|
||||
* how long each step took, so a slow boot says where its time went.
|
||||
*
|
||||
* Kept on `globalThis`: the steps are marked from the instrumentation bundle
|
||||
* and from the worker modules it loads, which are separate module copies in
|
||||
* a production build.
|
||||
*/
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export interface BootStep {
|
||||
name: string;
|
||||
/** Milliseconds since the process started (`performance.timeOrigin`). */
|
||||
startedAtMs: number;
|
||||
durationMs: number | null;
|
||||
}
|
||||
|
||||
const KEY = Symbol.for("healthlog.boot.steps");
|
||||
|
||||
interface State {
|
||||
steps: BootStep[];
|
||||
current: BootStep | null;
|
||||
}
|
||||
|
||||
function state(): State {
|
||||
const slot = globalThis as Record<symbol, unknown>;
|
||||
let current = slot[KEY] as State | undefined;
|
||||
if (!current) {
|
||||
current = { steps: [], current: null };
|
||||
slot[KEY] = current;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin `name`, ending the step before it. Steps run one after another; a
|
||||
* step that starts work in the background ends when the next one begins.
|
||||
*/
|
||||
export function beginBootStep(
|
||||
name: string,
|
||||
now: number = performance.now(),
|
||||
): void {
|
||||
const s = state();
|
||||
if (s.current && s.current.durationMs === null) {
|
||||
s.current.durationMs = Math.round(now - s.current.startedAtMs);
|
||||
}
|
||||
const step: BootStep = {
|
||||
name,
|
||||
startedAtMs: Math.round(now),
|
||||
durationMs: null,
|
||||
};
|
||||
s.steps.push(step);
|
||||
s.current = step;
|
||||
}
|
||||
|
||||
/** End the running step without starting another. */
|
||||
export function endBootSteps(now: number = performance.now()): BootStep[] {
|
||||
const s = state();
|
||||
if (s.current && s.current.durationMs === null) {
|
||||
s.current.durationMs = Math.round(now - s.current.startedAtMs);
|
||||
}
|
||||
s.current = null;
|
||||
return [...s.steps];
|
||||
}
|
||||
|
||||
/** The step running now, or null once the boot is through. */
|
||||
export function currentBootStep(): string | null {
|
||||
return state().current?.name ?? null;
|
||||
}
|
||||
|
||||
/** Every step so far, for the boot line. */
|
||||
export function bootSteps(): BootStep[] {
|
||||
return [...state().steps];
|
||||
}
|
||||
|
||||
/**
|
||||
* End the boot and write one `boot.steps` line with every step and its
|
||||
* duration, so a slow start says where its time went. Never throws.
|
||||
*/
|
||||
export async function reportBootSteps(
|
||||
meta: Record<string, string | number> = {},
|
||||
): Promise<void> {
|
||||
const steps = endBootSteps();
|
||||
try {
|
||||
await withBackgroundEvent("boot.steps", async () => {
|
||||
annotate({
|
||||
meta: {
|
||||
...meta,
|
||||
boot_steps: steps
|
||||
.map((s) => `${s.name}=${s.durationMs ?? 0}`)
|
||||
.join(","),
|
||||
boot_total_ms: Math.round(performance.now()),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// A diagnostic must never break boot.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { closedBlockKeyPart, closedBlockUntouched } from "../closed-block";
|
||||
import { invalidateUserMeasurements } from "../invalidate";
|
||||
import { caches, type ServerCache } from "../server-cache";
|
||||
|
||||
const analytics = caches.analytics as ServerCache<{ v: number }>;
|
||||
|
||||
const USER = "u-closed";
|
||||
const SPLIT = Date.UTC(2021, 9, 22);
|
||||
const closedKey = `${USER}|pre-fold-remainder|${closedBlockKeyPart(SPLIT)}|x`;
|
||||
const openKey = `${USER}|dashboard-snapshot|en`;
|
||||
|
||||
describe("closed analytics blocks", () => {
|
||||
beforeEach(() => {
|
||||
analytics.deleteByPrefix(`${USER}|`);
|
||||
analytics.set(closedKey, { v: 1 });
|
||||
analytics.set(openKey, { v: 2 });
|
||||
});
|
||||
|
||||
it("reads the instant a closed block ends before", () => {
|
||||
expect(closedBlockUntouched(closedKey, new Date(SPLIT))).toBe(true);
|
||||
expect(closedBlockUntouched(closedKey, new Date(SPLIT - 1))).toBe(false);
|
||||
expect(closedBlockUntouched(openKey, new Date(SPLIT))).toBe(false);
|
||||
});
|
||||
|
||||
it("survives a write of newer readings, evicting or marking stale", () => {
|
||||
for (const evict of [false, true]) {
|
||||
invalidateUserMeasurements(USER, {
|
||||
evict,
|
||||
touchedFrom: new Date(SPLIT + 86_400_000),
|
||||
});
|
||||
expect(analytics.get(closedKey)).toEqual({ v: 1 });
|
||||
expect(analytics.get(openKey)).toBeNull();
|
||||
analytics.set(openKey, { v: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it("goes with a write that reaches before it, or does not say", () => {
|
||||
invalidateUserMeasurements(USER, { touchedFrom: new Date(SPLIT - 1) });
|
||||
expect(analytics.get(closedKey)).toBeNull();
|
||||
analytics.set(closedKey, { v: 1 });
|
||||
invalidateUserMeasurements(USER);
|
||||
expect(analytics.get(closedKey)).toBeNull();
|
||||
});
|
||||
});
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* The key part that marks an analytics entry as a closed block: a value that
|
||||
* depends only on readings measured before `before`. Such a block is the
|
||||
* same however many syncs land after it, as long as none of them touches a
|
||||
* reading that old, so a measurement write that says how far back it
|
||||
* reached (`touchedFrom`) leaves it in place. Every other invalidation, and
|
||||
* every write that does not say, drops it as before.
|
||||
*/
|
||||
export function closedBlockKeyPart(before: Date | number): string {
|
||||
const ms = typeof before === "number" ? before : before.getTime();
|
||||
return `before:${ms}`;
|
||||
}
|
||||
|
||||
const CLOSED_BLOCK_RE = /\|before:(\d+)(?:\||$)/;
|
||||
|
||||
/**
|
||||
* Whether `key` is a closed block that a write reaching back to
|
||||
* `touchedFrom` cannot have changed. Exported for its test.
|
||||
*/
|
||||
export function closedBlockUntouched(key: string, touchedFrom: Date): boolean {
|
||||
const match = CLOSED_BLOCK_RE.exec(key);
|
||||
return match !== null && Number(match[1]) <= touchedFrom.getTime();
|
||||
}
|
||||
Vendored
+16
-3
@@ -20,6 +20,7 @@
|
||||
|
||||
import { forgetCoverageProbe } from "@/lib/rollups/coverage-inflight";
|
||||
|
||||
import { closedBlockUntouched } from "./closed-block";
|
||||
import { caches } from "./server-cache";
|
||||
|
||||
/**
|
||||
@@ -74,16 +75,28 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
|
||||
*/
|
||||
export function invalidateUserMeasurements(
|
||||
userId: string,
|
||||
opts?: { evict?: boolean },
|
||||
opts?: {
|
||||
evict?: boolean;
|
||||
/**
|
||||
* The earliest `measuredAt` the write created, changed or removed. With
|
||||
* it, closed blocks of older readings (`closedBlockKeyPart`) stay.
|
||||
* Leave it out unless the caller knows every row it touched.
|
||||
*/
|
||||
touchedFrom?: Date;
|
||||
},
|
||||
): void {
|
||||
// A coverage probe already running read the set before this write; the
|
||||
// next reader must not join it.
|
||||
forgetCoverageProbe(userId);
|
||||
const touchedFrom = opts?.touchedFrom;
|
||||
const spare = touchedFrom
|
||||
? (key: string) => closedBlockUntouched(key, touchedFrom)
|
||||
: undefined;
|
||||
// The `${userId}|` prefix covers the slim / thick analytics cells, the
|
||||
// iOS summary cell, AND the v1.7.0 dashboard snapshot
|
||||
// (`${userId}|dashboard-snapshot`) in one pass.
|
||||
if (opts?.evict) {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
caches.analytics.deleteByPrefix(`${userId}|`, spare);
|
||||
} else {
|
||||
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
|
||||
// the highest-frequency dirty signal (every iOS Apple-Health sync
|
||||
@@ -94,7 +107,7 @@ export function invalidateUserMeasurements(
|
||||
// summary cells read via plain `cached` are unaffected: a marked-stale
|
||||
// entry has `expiresAt === now`, so their next read is a clean miss and
|
||||
// rebuilds fresh — identical to the old evict for those keys.
|
||||
caches.analytics.markStaleByPrefix(`${userId}|`);
|
||||
caches.analytics.markStaleByPrefix(`${userId}|`, spare);
|
||||
}
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.workouts.deleteByPrefix(`${userId}|`);
|
||||
|
||||
Vendored
+11
-11
@@ -88,15 +88,15 @@ export interface ServerCacheStats {
|
||||
* fence — invalidation flips `invalidated` and detaches the slot from
|
||||
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
|
||||
* next read starts a fresh build instead of joining the stale one.
|
||||
* Callers already awaiting the detached promise still receive its value
|
||||
* (equivalent to having read a moment before the write); it just never
|
||||
* enters the cache.
|
||||
* Callers already awaiting the detached promise still get its value (as
|
||||
* if read just before the write); it never enters the cache.
|
||||
*/
|
||||
interface PendingBuild<T> {
|
||||
promise: Promise<T>;
|
||||
invalidated: boolean;
|
||||
}
|
||||
|
||||
type KeyFilter = (key: string) => boolean; // closed blocks, `invalidate.ts`
|
||||
export class ServerCache<T> {
|
||||
private readonly map = new Map<
|
||||
string,
|
||||
@@ -124,9 +124,9 @@ export class ServerCache<T> {
|
||||
}
|
||||
|
||||
/** Fence + detach every in-flight builder under `prefix`. */
|
||||
private detachPendingByPrefix(prefix: string): void {
|
||||
private detachPendingByPrefix(prefix: string, spare?: KeyFilter): void {
|
||||
for (const [key, slot] of this.pending) {
|
||||
if (key.startsWith(prefix)) {
|
||||
if (key.startsWith(prefix) && !spare?.(key)) {
|
||||
slot.invalidated = true;
|
||||
this.pending.delete(key);
|
||||
}
|
||||
@@ -283,17 +283,17 @@ export class ServerCache<T> {
|
||||
* so the next read is a clean miss — identical to the old evict
|
||||
* behaviour).
|
||||
*/
|
||||
markStaleByPrefix(prefix: string): number {
|
||||
markStaleByPrefix(prefix: string, spare?: KeyFilter): number {
|
||||
// v1.16.9 — fence in-flight builders too: a refresh that started
|
||||
// before the write would otherwise complete after this mark and
|
||||
// re-cache the pre-write body as FRESH for the full TTL. Detaching
|
||||
// keeps the SWR contract — the marked entry still serves stale, and
|
||||
// the next read kicks off a fresh (post-write) recompute.
|
||||
this.detachPendingByPrefix(prefix);
|
||||
this.detachPendingByPrefix(prefix, spare);
|
||||
let marked = 0;
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.map) {
|
||||
if (key.startsWith(prefix)) {
|
||||
if (key.startsWith(prefix) && !spare?.(key)) {
|
||||
entry.expiresAt = now;
|
||||
marked += 1;
|
||||
}
|
||||
@@ -320,12 +320,12 @@ export class ServerCache<T> {
|
||||
* entire user-bucket from a cache without having to enumerate every
|
||||
* possible cache-key variant the user might have warmed.
|
||||
*/
|
||||
deleteByPrefix(prefix: string): number {
|
||||
deleteByPrefix(prefix: string, spare?: KeyFilter): number {
|
||||
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
|
||||
this.detachPendingByPrefix(prefix);
|
||||
this.detachPendingByPrefix(prefix, spare);
|
||||
let removed = 0;
|
||||
for (const key of this.map.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
if (key.startsWith(prefix) && !spare?.(key)) {
|
||||
const entry = this.map.get(key);
|
||||
this.map.delete(key);
|
||||
this.totalWeight -= entry?.weight ?? 0;
|
||||
|
||||
+18
-5
@@ -14,6 +14,8 @@
|
||||
* once in `sections`. Nothing here writes, and nothing here talks to a model;
|
||||
* `model-projection.ts` is the one way a day leaves for one.
|
||||
*/
|
||||
import pLimit from "p-limit";
|
||||
|
||||
import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
import { readDayMovement } from "@/lib/dawarich/movement";
|
||||
@@ -35,6 +37,9 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { localDayWindow } from "@/lib/tz/local-day";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/** Record sections read at once for one day (see `loadDay`). */
|
||||
const DAY_SECTION_CONCURRENCY = 6;
|
||||
|
||||
const NO_READINGS: DayReadings = {
|
||||
rowsByType: new Map(),
|
||||
night: null,
|
||||
@@ -99,6 +104,7 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
? await loadUserSourcePriority(recordId)
|
||||
: null;
|
||||
|
||||
const sectionLimit = pLimit(DAY_SECTION_CONCURRENCY);
|
||||
const recordSections = [...access.readable].filter(
|
||||
(section) => RECORD_SECTION_READERS[section] !== undefined,
|
||||
);
|
||||
@@ -115,13 +121,20 @@ export async function loadDay(args: LoadDayArgs): Promise<DayResponse> {
|
||||
priority,
|
||||
)
|
||||
: Promise.resolve(NO_READINGS),
|
||||
// About twenty-five section readers, each a query or two, beside the
|
||||
// readings, scores and movement: unbounded they took more connections
|
||||
// than the pool holds, and a neighbouring request waited for a slot.
|
||||
// Six at a time keeps the day under a third of the pool; the parts come
|
||||
// back in section order as before.
|
||||
Promise.all(
|
||||
recordSections.map((section) =>
|
||||
(
|
||||
RECORD_SECTION_READERS[section] as (
|
||||
frame: DayFrame,
|
||||
) => Promise<SectionPart>
|
||||
)(frame),
|
||||
sectionLimit(() =>
|
||||
(
|
||||
RECORD_SECTION_READERS[section] as (
|
||||
frame: DayFrame,
|
||||
) => Promise<SectionPart>
|
||||
)(frame),
|
||||
),
|
||||
),
|
||||
),
|
||||
scoresReadable
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { MeasurementType } from "@/generated/prisma/enums";
|
||||
|
||||
import type { DayNotable } from "@/lib/day/contract";
|
||||
import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats";
|
||||
import { closedBlockKeyPart } from "@/lib/cache/closed-block";
|
||||
import { caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
@@ -267,10 +268,16 @@ async function readExtremeHistory(args: {
|
||||
) {
|
||||
const last = shiftDateKey(start, HISTORY_BLOCK_DAYS - 1);
|
||||
const open = last >= today;
|
||||
// A closed block names the instant its readings end before, with a day
|
||||
// of margin for a night that runs past midnight, so a sync that only
|
||||
// touched newer readings leaves it cached (`closedBlockKeyPart`).
|
||||
const closed = closedBlockKeyPart(
|
||||
startOfLocalDayKey(shiftDateKey(last, 2), args.tz),
|
||||
);
|
||||
blocks.push({
|
||||
start,
|
||||
end: open ? today : last,
|
||||
key: `${prefix}|${start}|${open ? today : "closed"}`,
|
||||
key: `${prefix}|${start}|${open ? today : closed}`,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+28
-5
@@ -6,6 +6,7 @@ import {
|
||||
sessionOptionsDisabled,
|
||||
TIME_ZONE_OPTION,
|
||||
} from "@/lib/db-session";
|
||||
import { shouldRunWorker } from "@/lib/process-type";
|
||||
|
||||
export {
|
||||
pinTimeZoneOnConnect,
|
||||
@@ -21,7 +22,17 @@ const globalForPrisma = globalThis as unknown as {
|
||||
|
||||
const DEFAULT_CONNECTION_BUDGET = 20;
|
||||
const DEFAULT_POOL_TIMEOUT_SECONDS = 20;
|
||||
const PG_BOSS_CONNECTIONS = 2;
|
||||
/**
|
||||
* pg-boss connections. A web-only process only sends jobs, and two sockets
|
||||
* carry that. A process that runs the worker polls: about a hundred bindings
|
||||
* (two dozen of them every two seconds, several with two to five slots), the
|
||||
* cron timekeeper, supervision, and every job's own start and completion
|
||||
* writes, plus the web sends when web and worker share the process. On two
|
||||
* sockets those queued behind each other; four keep a fetch from waiting on
|
||||
* a completion. The connections come out of the same per-process budget.
|
||||
*/
|
||||
const PG_BOSS_PRODUCER_CONNECTIONS = 2;
|
||||
const PG_BOSS_WORKER_CONNECTIONS = 4;
|
||||
|
||||
function positiveInteger(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
@@ -53,8 +64,14 @@ export function getConnectionBudget(): number {
|
||||
return Math.max(2, configured);
|
||||
}
|
||||
|
||||
export function getPgBossPoolMax(): number {
|
||||
return Math.min(PG_BOSS_CONNECTIONS, getConnectionBudget() - 1);
|
||||
export function getPgBossPoolMax(
|
||||
role: "worker" | "producer" = shouldRunWorker() ? "worker" : "producer",
|
||||
): number {
|
||||
const wanted =
|
||||
role === "worker"
|
||||
? PG_BOSS_WORKER_CONNECTIONS
|
||||
: PG_BOSS_PRODUCER_CONNECTIONS;
|
||||
return Math.min(wanted, getConnectionBudget() - 1);
|
||||
}
|
||||
|
||||
export function getPrismaPoolMax(): number {
|
||||
@@ -129,7 +146,13 @@ export function getWorkMem(): string {
|
||||
* session carries them from the first query.
|
||||
*
|
||||
* The timeouts are left out when disabled (timeout 0); `work_mem` is always
|
||||
* set. Up to v1.41 the whole string was `undefined` at timeout 0, which would
|
||||
* set, and so is `jit=off`. Postgres compiles a statement to machine code
|
||||
* once its estimated cost passes `jit_above_cost`, which every analytics
|
||||
* fold over a year of readings does; on the benchmark account that
|
||||
* compilation took 100 to 140 ms of a 220 ms statement and saved nothing,
|
||||
* because these statements run once and are bound by reading rows, not by
|
||||
* evaluating expressions. JIT changes how a plan runs, never what it
|
||||
* returns. Up to v1.41 the whole string was `undefined` at timeout 0, which would
|
||||
* have taken `work_mem` with it. `undefined` now means exactly one thing:
|
||||
* the operator turned the startup options off for a connection pooler
|
||||
* ({@link sessionOptionsDisabled}).
|
||||
@@ -141,7 +164,7 @@ export function buildSessionOptions(): string | undefined {
|
||||
timeoutMs > 0
|
||||
? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} `
|
||||
: "";
|
||||
return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`;
|
||||
return `${timeouts}-c work_mem=${getWorkMem()} -c jit=off ${TIME_ZONE_OPTION}`;
|
||||
}
|
||||
|
||||
export function prismaPoolConfig(): PoolConfig {
|
||||
|
||||
@@ -152,11 +152,19 @@ function buildAnalyteNamePattern(): RegExp {
|
||||
);
|
||||
}
|
||||
|
||||
const DERIVED_ANALYTE_NAMES = buildAnalyteNamePattern();
|
||||
let derivedAnalyteNamesCache:
|
||||
ReturnType<typeof derivedAnalyteNamesBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function derivedAnalyteNames(): ReturnType<typeof derivedAnalyteNamesBuild> {
|
||||
return (derivedAnalyteNamesCache ??= derivedAnalyteNamesBuild());
|
||||
}
|
||||
function derivedAnalyteNamesBuild() {
|
||||
return buildAnalyteNamePattern();
|
||||
}
|
||||
|
||||
/** Test seam: the guard suite pins that the derived pattern covers every locale. */
|
||||
export function derivedAnalyteNamePattern(): RegExp {
|
||||
return DERIVED_ANALYTE_NAMES;
|
||||
return derivedAnalyteNames();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,7 +183,7 @@ export function looksLikeLabDocument(text: string): boolean {
|
||||
for (const signal of LAB_SIGNALS) {
|
||||
const hit =
|
||||
signal.test(folded) ||
|
||||
(signal === ANALYTE_SIGNAL && DERIVED_ANALYTE_NAMES.test(folded));
|
||||
(signal === ANALYTE_SIGNAL && derivedAnalyteNames().test(folded));
|
||||
if (hit && ++hits >= 2) return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
|
||||
@@ -57,16 +57,7 @@ export function loadAssistantSwitches(): Promise<AssistantFlagSet | null> {
|
||||
"assistant-flags",
|
||||
async () => {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
},
|
||||
});
|
||||
const settings = await readOperatorRow();
|
||||
return resolveAssistantFlags({
|
||||
enabled: settings?.assistantEnabled ?? true,
|
||||
coach: settings?.assistantCoachEnabled ?? true,
|
||||
|
||||
@@ -355,14 +355,24 @@ function buildLocalisedAliases(): Record<string, string> {
|
||||
return index;
|
||||
}
|
||||
|
||||
const LOCALISED_LAB_ALIASES = buildLocalisedAliases();
|
||||
let memoLocalisedLabAliasesCache:
|
||||
ReturnType<typeof memoLocalisedLabAliasesBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function memoLocalisedLabAliases(): ReturnType<
|
||||
typeof memoLocalisedLabAliasesBuild
|
||||
> {
|
||||
return (memoLocalisedLabAliasesCache ??= memoLocalisedLabAliasesBuild());
|
||||
}
|
||||
function memoLocalisedLabAliasesBuild() {
|
||||
return buildLocalisedAliases();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: the guard suite asserts the derived index covers every shipped
|
||||
* locale and contradicts no hand-written alias. Production reads the constant.
|
||||
*/
|
||||
export function localisedLabAliases(): Readonly<Record<string, string>> {
|
||||
return LOCALISED_LAB_ALIASES;
|
||||
return memoLocalisedLabAliases();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -462,7 +472,7 @@ export function resolveLabCoding(
|
||||
// every resolution that worked before working the same way.
|
||||
const resolvedKey = LAB_LOINC_BY_KEY[key]
|
||||
? key
|
||||
: (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]);
|
||||
: (LAB_ALIASES[key] ?? memoLocalisedLabAliases()[key]);
|
||||
if (!resolvedKey) return null;
|
||||
const mapping = LAB_LOINC_BY_KEY[resolvedKey];
|
||||
if (!mapping) return null;
|
||||
|
||||
@@ -110,7 +110,14 @@ function buildLocalisedIndex(): Record<string, QualitativeConcept> {
|
||||
return index;
|
||||
}
|
||||
|
||||
const LOCALISED_SNOMED = buildLocalisedIndex();
|
||||
let localisedSnomedCache: ReturnType<typeof localisedSnomedBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function localisedSnomed(): ReturnType<typeof localisedSnomedBuild> {
|
||||
return (localisedSnomedCache ??= localisedSnomedBuild());
|
||||
}
|
||||
function localisedSnomedBuild() {
|
||||
return buildLocalisedIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: the guard suite asserts the derived index covers every shipped
|
||||
@@ -120,7 +127,7 @@ const LOCALISED_SNOMED = buildLocalisedIndex();
|
||||
export function localisedQualitativeIndex(): Readonly<
|
||||
Record<string, QualitativeConcept>
|
||||
> {
|
||||
return LOCALISED_SNOMED;
|
||||
return localisedSnomed();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +151,7 @@ export function qualitativeValueConcept(
|
||||
valueText: string,
|
||||
): FhirCodeableConcept {
|
||||
const key = normaliseQualitativeKey(valueText);
|
||||
const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key];
|
||||
const concept = HAND_WRITTEN_SNOMED[key] ?? localisedSnomed()[key];
|
||||
if (!concept) {
|
||||
return { text: valueText };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getServerTranslator } from "../server-translator";
|
||||
import {
|
||||
lazyServerCatalogs,
|
||||
loadedServerLocales,
|
||||
serverCatalog,
|
||||
} from "../server-catalogs";
|
||||
|
||||
describe("server catalogs", () => {
|
||||
it("reads a locale from messages/ on first use and keeps one copy", () => {
|
||||
const de = serverCatalog("de");
|
||||
expect(de).toEqual(
|
||||
JSON.parse(
|
||||
readFileSync(join(process.cwd(), "messages", "de.json"), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(serverCatalog("de")).toBe(de);
|
||||
expect(loadedServerLocales()).toContain("de");
|
||||
});
|
||||
|
||||
it("loads only the locale an entry of the lazy record is read for", () => {
|
||||
const record = lazyServerCatalogs();
|
||||
const before = new Set(loadedServerLocales());
|
||||
void record.it;
|
||||
const added = loadedServerLocales().filter((l) => !before.has(l));
|
||||
expect(added.every((l) => l === "it")).toBe(true);
|
||||
expect(record.it).toBe(serverCatalog("it"));
|
||||
});
|
||||
|
||||
it("lets an assignment replace an entry", () => {
|
||||
const record = lazyServerCatalogs();
|
||||
record.ko = { common: { save: "x" } };
|
||||
expect(record.ko).toEqual({ common: { save: "x" } });
|
||||
expect(serverCatalog("ko")).not.toEqual({ common: { save: "x" } });
|
||||
});
|
||||
|
||||
it("refuses a locale that is not shipped", () => {
|
||||
expect(() => serverCatalog("xx" as "de")).toThrow(/unknown locale/);
|
||||
});
|
||||
|
||||
it("serves the server translator from the same catalogs", () => {
|
||||
const t = getServerTranslator("fr");
|
||||
const fr = serverCatalog("fr") as { common: { save: string } };
|
||||
expect(t.t("common.save")).toBe(fr.common.save);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The match form of a label, without the catalogs.
|
||||
*
|
||||
* `localised-label-index.ts` builds its indexes from every shipped catalog
|
||||
* and is server-only; this fold is the part client code needs too (the
|
||||
* medication target map folds a free-text name the same way), so it lives
|
||||
* in a leaf that imports nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Letters Unicode decomposition leaves alone because their mark is part of
|
||||
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
|
||||
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
|
||||
* way without this. Expressed as ASCII equivalents, the same target the
|
||||
* combining-mark strip below produces.
|
||||
*/
|
||||
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/ł/g, "l"],
|
||||
[/ø/g, "o"],
|
||||
[/đ/g, "d"],
|
||||
[/ß/g, "ss"],
|
||||
[/æ/g, "ae"],
|
||||
[/œ/g, "oe"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fold a label or a free-text term to its match form: compatibility-normalised
|
||||
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
|
||||
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
|
||||
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
|
||||
* "Pain (0–10)" and "pain 0 10" agree).
|
||||
*
|
||||
* Applied to BOTH sides of every comparison — a bundle label and the caller's
|
||||
* word go through the same function, so the index cannot drift from the lookup.
|
||||
*/
|
||||
export function foldLabel(raw: string): string {
|
||||
let folded = raw
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
|
||||
folded = folded.replace(pattern, ascii);
|
||||
}
|
||||
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
* for every user, in every language). The t() chain (active locale →
|
||||
* EN → raw key) now resolves its bundles through three paths:
|
||||
*
|
||||
* 1. SSR pass: this module seeds its cache with the full server-side
|
||||
* catalog map (`shared-resolve`) inside a `typeof window ===
|
||||
* 1. SSR pass: this module seeds its cache with the lazy server-side
|
||||
* catalog map (`server-catalogs`) inside a `typeof window ===
|
||||
* "undefined"` branch. Turbopack compiles the branch away for the
|
||||
* browser bundle (verified — zero catalog bytes reach any client
|
||||
* chunk), while the server render of every client component
|
||||
@@ -33,6 +33,10 @@
|
||||
* seed bundles directly (`primeMessages`).
|
||||
*/
|
||||
import { type Locale } from "./config";
|
||||
import type { lazyServerCatalogs } from "./server-catalogs";
|
||||
|
||||
/** The SSR branch's view of `./server-catalogs` (types only; see below). */
|
||||
type ServerCatalogsModule = { lazyServerCatalogs: typeof lazyServerCatalogs };
|
||||
|
||||
export type MessageBundle = Record<string, unknown>;
|
||||
|
||||
@@ -63,10 +67,12 @@ function initialCache(): Partial<Record<Locale, MessageBundle>> {
|
||||
// where a bare require of a TS module throws) — there the cache starts
|
||||
// empty and `vitest.setup.ts` primes every bundle up front.
|
||||
try {
|
||||
const { allMessages } =
|
||||
const { lazyServerCatalogs } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require("./shared-resolve") as typeof import("./shared-resolve");
|
||||
return { ...allMessages };
|
||||
require("./server-catalogs") as ServerCatalogsModule;
|
||||
// Entries load on first read, so the render loads the locales it
|
||||
// renders in, not all of them.
|
||||
return lazyServerCatalogs();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -24,44 +24,10 @@
|
||||
* what the resolver ACCEPTS.
|
||||
*/
|
||||
import { locales, type Locale } from "./config";
|
||||
import { foldLabel } from "./fold-label";
|
||||
import { allMessages, resolveKey } from "./shared-resolve";
|
||||
|
||||
/**
|
||||
* Letters Unicode decomposition leaves alone because their mark is part of
|
||||
* the glyph rather than a combining character — a stroke, or a ligature. NFKD
|
||||
* turns "ż" into "z" + a dot but leaves "ł" whole, so Polish would fold half
|
||||
* way without this. Expressed as ASCII equivalents, the same target the
|
||||
* combining-mark strip below produces.
|
||||
*/
|
||||
const UNDECOMPOSED_LETTERS: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/ł/g, "l"],
|
||||
[/ø/g, "o"],
|
||||
[/đ/g, "d"],
|
||||
[/ß/g, "ss"],
|
||||
[/æ/g, "ae"],
|
||||
[/œ/g, "oe"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Fold a label or a free-text term to its match form: compatibility-normalised
|
||||
* (so "VO₂" and "VO2" agree), diacritics stripped (so "Glycémie" answers to
|
||||
* "glycemie" and "Siła" to "sila"), lower-cased, and every run of
|
||||
* non-alphanumerics collapsed to a single `_` with the ends trimmed (so
|
||||
* "Pain (0–10)" and "pain 0 10" agree).
|
||||
*
|
||||
* Applied to BOTH sides of every comparison — a bundle label and the caller's
|
||||
* word go through the same function, so the index cannot drift from the lookup.
|
||||
*/
|
||||
export function foldLabel(raw: string): string {
|
||||
let folded = raw
|
||||
.normalize("NFKD")
|
||||
.replace(/\p{Diacritic}/gu, "")
|
||||
.toLowerCase();
|
||||
for (const [pattern, ascii] of UNDECOMPOSED_LETTERS) {
|
||||
folded = folded.replace(pattern, ascii);
|
||||
}
|
||||
return folded.replace(/[^\p{L}\p{N}]+/gu, "_").replace(/^_+|_+$/gu, "");
|
||||
}
|
||||
export { foldLabel };
|
||||
|
||||
/** One locale's rendering of `key`, or `null` when that bundle omits it. */
|
||||
export function labelIn(locale: Locale, key: string): string | null {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The message catalogs on the server: read from disk on first use, one
|
||||
* locale at a time, once per process.
|
||||
*
|
||||
* The catalogs used to be eight static JSON imports. A production build gives
|
||||
* every server bundle its own copy of a shared module (route handlers, the
|
||||
* RSC/SSR renderer, the instrumentation bundle that runs the worker), so each
|
||||
* of them carried the whole eight-locale chunk as a source string and parsed
|
||||
* all eight on load: a boot profile of an idle process found 32 parsed
|
||||
* catalog objects and two 9.7 MB chunk sources, about a fifth of the live
|
||||
* heap, for a process whose users read one or two locales.
|
||||
*
|
||||
* Now nothing is bundled: a catalog is `messages/<locale>.json` read with
|
||||
* `readFileSync` the first time a translator or a matcher asks for that
|
||||
* locale, and parked on `globalThis`, so every bundle copy in the process
|
||||
* shares one parsed object per locale. The read is synchronous because
|
||||
* `t()` is; it happens once per locale per process and takes a few
|
||||
* milliseconds.
|
||||
*
|
||||
* The directory is `messages/` under the working directory: the checkout in
|
||||
* development and tests, `/app` in the image, which copies it there.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { locales, type Locale } from "./config";
|
||||
|
||||
export type ServerCatalog = Record<string, unknown>;
|
||||
|
||||
const KEY = Symbol.for("healthlog.i18n.serverCatalogs");
|
||||
|
||||
function cache(): Map<Locale, ServerCatalog> {
|
||||
const slot = globalThis as unknown as {
|
||||
[KEY]?: Map<Locale, ServerCatalog>;
|
||||
};
|
||||
slot[KEY] ??= new Map();
|
||||
return slot[KEY];
|
||||
}
|
||||
|
||||
function catalogDirectory(): string {
|
||||
// Not a bundler input: the catalogs are read at run time, and tracing
|
||||
// `process.cwd()` would pull the whole checkout into the output.
|
||||
return join(/* turbopackIgnore: true */ process.cwd(), "messages");
|
||||
}
|
||||
|
||||
/** One locale's catalog, read and parsed on first use. */
|
||||
export function serverCatalog(locale: Locale): ServerCatalog {
|
||||
const loaded = cache();
|
||||
const hit = loaded.get(locale);
|
||||
if (hit) return hit;
|
||||
if (!(locales as readonly string[]).includes(locale)) {
|
||||
throw new Error(`unknown locale: ${String(locale)}`);
|
||||
}
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(join(catalogDirectory(), `${locale}.json`), "utf8"),
|
||||
) as ServerCatalog;
|
||||
loaded.set(locale, parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Locales whose catalog this process has read so far (for tests and logs). */
|
||||
export function loadedServerLocales(): Locale[] {
|
||||
return [...cache().keys()];
|
||||
}
|
||||
|
||||
/**
|
||||
* A `Record<Locale, catalog>` whose entries load on first read. Indexing one
|
||||
* locale loads only that one; iterating every locale (the matchers that
|
||||
* accept a word in any language) loads them all, as before. An assignment
|
||||
* replaces the entry, which is what the SSR cache in `load-locale.ts` and
|
||||
* the test helpers do.
|
||||
*/
|
||||
export function lazyServerCatalogs(): Record<Locale, ServerCatalog> {
|
||||
const record = {} as Record<Locale, ServerCatalog>;
|
||||
for (const locale of locales) {
|
||||
Object.defineProperty(record, locale, {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => serverCatalog(locale),
|
||||
set: (value: ServerCatalog) => {
|
||||
Object.defineProperty(record, locale, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
@@ -9,38 +9,27 @@
|
||||
* Since the i18n bundle split, `allMessages` is SERVER-ONLY: the root
|
||||
* layout picks the active locale's bundle out of it for the RSC handoff
|
||||
* and `getServerTranslator()` resolves against it. Do NOT import this
|
||||
* module from client code — the seven static JSON imports would drag
|
||||
* every locale back into the client chunk (the ~1.4 MiB regression the
|
||||
* split removed). Client code uses `./load-locale` (static EN fallback
|
||||
* floor + dynamic per-locale imports) and `./resolve-key` instead.
|
||||
* module from client code: it reads the catalogs from disk. Client code
|
||||
* uses `./load-locale` (dynamic per-locale imports) and `./resolve-key`
|
||||
* instead.
|
||||
*/
|
||||
import deMessages from "../../../messages/de.json";
|
||||
import enMessages from "../../../messages/en.json";
|
||||
import frMessages from "../../../messages/fr.json";
|
||||
import esMessages from "../../../messages/es.json";
|
||||
import itMessages from "../../../messages/it.json";
|
||||
import plMessages from "../../../messages/pl.json";
|
||||
import koMessages from "../../../messages/ko.json";
|
||||
import nlMessages from "../../../messages/nl.json";
|
||||
import { locales, type Locale } from "./config";
|
||||
import { resolveKey } from "./resolve-key";
|
||||
import { lazyServerCatalogs } from "./server-catalogs";
|
||||
|
||||
export { resolveKey };
|
||||
|
||||
/**
|
||||
* Single source of truth for the per-locale message bundle on the
|
||||
* server (layout RSC handoff, server translator, jobs).
|
||||
* server (layout RSC handoff, server translator, jobs). Each entry is read
|
||||
* from `messages/<locale>.json` the first time it is indexed and shared by
|
||||
* every bundle in the process (`server-catalogs.ts`), so a process holds the
|
||||
* locales its users read, not all of them.
|
||||
*/
|
||||
export const allMessages: Record<Locale, Record<string, unknown>> = {
|
||||
de: deMessages,
|
||||
en: enMessages,
|
||||
fr: frMessages,
|
||||
es: esMessages,
|
||||
it: itMessages,
|
||||
pl: plMessages,
|
||||
ko: koMessages,
|
||||
nl: nlMessages,
|
||||
};
|
||||
export const allMessages: Record<
|
||||
Locale,
|
||||
Record<string, unknown>
|
||||
> = lazyServerCatalogs();
|
||||
|
||||
/**
|
||||
* Every shipped translation of one key, in `locales` order, deduplicated and
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
import {
|
||||
pickCanonicalWorkoutRows,
|
||||
workoutDuplicateSlotKey,
|
||||
workoutDuplicateClusterKeys,
|
||||
workoutSourceLadder,
|
||||
type WorkoutPickerRow,
|
||||
} from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
@@ -591,7 +591,7 @@ export function canonicalDayActiveEnergy<
|
||||
* one source's rows count: the best-ranked on the ladder, a source off the
|
||||
* ladder ranking below every listed one, and among sources off the ladder
|
||||
* the one with the largest impulse. Rows of that one source still add up
|
||||
* (two distinct sessions of the same sport in one five-minute slot).
|
||||
* (two distinct sessions of the same sport in one duplicate cluster).
|
||||
* Returns the positive impulses, one per session. Pure; exported for tests.
|
||||
*/
|
||||
export function canonicalWorkoutTrimps<
|
||||
@@ -615,9 +615,9 @@ export function canonicalWorkoutTrimps<
|
||||
string,
|
||||
{ picked: Map<string, number>; dropped: number }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const key = workoutDuplicateSlotKey(row);
|
||||
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
|
||||
const keys = workoutDuplicateClusterKeys(rows);
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const slot = slots.get(keys[index]!) ?? { picked: new Map(), dropped: 0 };
|
||||
const trimp = trimpOf(row);
|
||||
if (trimp > 0) {
|
||||
if (picked.has(row.id)) {
|
||||
@@ -626,7 +626,7 @@ export function canonicalWorkoutTrimps<
|
||||
slot.dropped = Math.max(slot.dropped, trimp);
|
||||
}
|
||||
}
|
||||
slots.set(key, slot);
|
||||
slots.set(keys[index]!, slot);
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (const slot of slots.values()) {
|
||||
|
||||
@@ -108,9 +108,10 @@ describe("worker pg-boss pool", () => {
|
||||
it("uses the pg-boss share of the process connection budget", () => {
|
||||
createWorkerBoss("postgres://healthlog@test/db");
|
||||
|
||||
// A worker polls; a web-only producer keeps two (see `db.ts`).
|
||||
expect(PgBoss).toHaveBeenCalledWith({
|
||||
connectionString: "postgres://healthlog@test/db",
|
||||
max: 2,
|
||||
max: 4,
|
||||
connectionTimeoutMillis: 20_000,
|
||||
options: "-c TimeZone=UTC",
|
||||
});
|
||||
|
||||
@@ -9,6 +9,10 @@ import type { Job } from "pg-boss";
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/logging/signal", () => ({ emitSignal: vi.fn() }));
|
||||
const memory = vi.hoisted(() => ({ pressure: false }));
|
||||
vi.mock("@/lib/observability/container-memory", () => ({
|
||||
memoryPressureActive: () => memory.pressure,
|
||||
}));
|
||||
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
import { heavyLane, LANE_WAIT_WARN_MS, laneWanted } from "../heavy-lane";
|
||||
@@ -266,3 +270,52 @@ describe("a long wait for the lane", () => {
|
||||
await held;
|
||||
});
|
||||
});
|
||||
|
||||
describe("the lane under container memory pressure", () => {
|
||||
it("sends a heavy job back while the container is near its limit", async () => {
|
||||
const log: string[] = [];
|
||||
const a = gate(log, "import");
|
||||
const { back, defer } = deferrals();
|
||||
memory.pressure = true;
|
||||
try {
|
||||
const outcome = await heavyLane(
|
||||
"apple-health-import-v2",
|
||||
a.handler,
|
||||
undefined,
|
||||
defer,
|
||||
)([job("upload")]);
|
||||
expect(outcome).toMatchObject({ ok: true, did: { lane_deferred: 1 } });
|
||||
expect(a.handler).not.toHaveBeenCalled();
|
||||
expect(back).toEqual([
|
||||
{ queue: "apple-health-import-v2", ids: ["upload"] },
|
||||
]);
|
||||
} finally {
|
||||
memory.pressure = false;
|
||||
}
|
||||
});
|
||||
|
||||
it("asks a holder to give way at its next boundary", async () => {
|
||||
let seen: boolean[] = [];
|
||||
const { defer } = deferrals();
|
||||
let release!: () => void;
|
||||
const gateDone = new Promise<void>((r) => (release = r));
|
||||
const running = heavyLane(
|
||||
"apple-health-import-v2",
|
||||
async () => {
|
||||
seen.push(laneWanted());
|
||||
await gateDone;
|
||||
seen.push(laneWanted());
|
||||
return jobDone({ jobs: 1 });
|
||||
},
|
||||
undefined,
|
||||
defer,
|
||||
)([job("upload")]);
|
||||
await tick();
|
||||
memory.pressure = true;
|
||||
release();
|
||||
await running;
|
||||
memory.pressure = false;
|
||||
expect(seen).toEqual([false, true]);
|
||||
seen = [];
|
||||
});
|
||||
});
|
||||
@@ -74,10 +74,13 @@ export function getGlobalBoss(): PgBoss | null {
|
||||
return ((globalThis as Record<string, unknown>)[BOSS_KEY] as PgBoss) ?? null;
|
||||
}
|
||||
|
||||
function databaseOptions(connectionString: string) {
|
||||
function databaseOptions(
|
||||
connectionString: string,
|
||||
role: "worker" | "producer",
|
||||
) {
|
||||
return {
|
||||
connectionString,
|
||||
max: getPgBossPoolMax(),
|
||||
max: getPgBossPoolMax(role),
|
||||
connectionTimeoutMillis: getPoolConnectionTimeoutMs(),
|
||||
// pg-boss keeps its own tables in `timestamptz`, so its queue math does
|
||||
// not depend on the zone; the pin keeps every HealthLog session alike.
|
||||
@@ -86,12 +89,12 @@ function databaseOptions(connectionString: string) {
|
||||
}
|
||||
|
||||
export function createWorkerBoss(connectionString: string): PgBoss {
|
||||
return new PgBoss(databaseOptions(connectionString));
|
||||
return new PgBoss(databaseOptions(connectionString, "worker"));
|
||||
}
|
||||
|
||||
function producerOptions(connectionString: string) {
|
||||
return {
|
||||
...databaseOptions(connectionString),
|
||||
...databaseOptions(connectionString, "producer"),
|
||||
migrate: false,
|
||||
schedule: false,
|
||||
supervise: false,
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
* job that has stood in line for `LANE_WAIT_WARN_MS` is reported at `warn`
|
||||
* (`job.heavy_lane.waiting_long`), once per holder, so a lane that is held
|
||||
* for hours shows in the alerts instead of only in `info` lines.
|
||||
*
|
||||
* The container memory guard (`observability/container-memory.ts`) closes
|
||||
* the lane for a while when the cgroup's working set reaches 85 % of its
|
||||
* limit: a heavy job that arrives then goes back to the queue the same way,
|
||||
* and a holder that runs in slices gives way at its next boundary, so the
|
||||
* peak of an import does not land on top of a memory peak and end in a
|
||||
* SIGKILL. The guard holds jobs back for fifteen minutes at most.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { Job } from "pg-boss";
|
||||
@@ -40,6 +47,7 @@ import type { Job } from "pg-boss";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { memoryPressureActive } from "@/lib/observability/container-memory";
|
||||
import { emitSignal } from "@/lib/logging/signal";
|
||||
|
||||
export interface HeavyQueue {
|
||||
@@ -155,7 +163,8 @@ const holdScope = new AsyncLocalStorage<LaneHold>();
|
||||
*/
|
||||
export function laneWanted(): boolean {
|
||||
const current = holdScope.getStore();
|
||||
return current !== undefined && current === hold && current.wantedBy !== null;
|
||||
if (current === undefined || current !== hold) return false;
|
||||
return current.wantedBy !== null || memoryPressureActive();
|
||||
}
|
||||
|
||||
function reportLongWait(
|
||||
@@ -201,6 +210,19 @@ export function heavyLane<J extends Job<unknown>>(
|
||||
return async (jobs) => {
|
||||
if (!jobs.some(inLane)) return handler(jobs);
|
||||
const current = hold;
|
||||
if (!current && memoryPressureActive()) {
|
||||
const deferral = await defer(queue, jobs);
|
||||
annotate({
|
||||
action: { name: "job.heavy_lane.deferred" },
|
||||
meta: {
|
||||
queue,
|
||||
holder: "memory_pressure",
|
||||
jobs: jobs.length,
|
||||
deferred: deferral.deferred,
|
||||
},
|
||||
});
|
||||
return jobDone({ lane_deferred: deferral.deferred });
|
||||
}
|
||||
if (current) {
|
||||
const deferral = await defer(queue, jobs);
|
||||
if (queue !== current.queue) current.wantedBy ??= queue;
|
||||
|
||||
@@ -17,27 +17,35 @@
|
||||
import { markWorkerStarted, recordError } from "@/lib/jobs/worker-status";
|
||||
import { createWorkerBoss, setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { reportJobsCutOffAtBoot } from "@/lib/jobs/job-observer";
|
||||
import { beginBootStep, reportBootSteps } from "@/lib/boot/boot-steps";
|
||||
import { assertSubsystemEnabled } from "@/lib/process-type";
|
||||
import { DATABASE_URL, workerLog } from "./reminder/shared";
|
||||
import {
|
||||
registerShutdownHandlers,
|
||||
reconcileImportJobsAtBoot,
|
||||
probeIntegrationStatusAtBoot,
|
||||
} from "./reminder/worker-lifecycle";
|
||||
import {
|
||||
registerIntegrationSyncQueues,
|
||||
enqueueIntegrationSyncBootDiscovery,
|
||||
} from "./reminder/register-integration-sync";
|
||||
import { registerStatusQueues } from "./reminder/register-status";
|
||||
import {
|
||||
registerRollupQueues,
|
||||
enqueueRollupBootDiscovery,
|
||||
} from "./reminder/register-rollup";
|
||||
import { registerReminderQueues } from "./reminder/register-reminders";
|
||||
import {
|
||||
registerMaintenanceQueues,
|
||||
enqueueMaintenanceBootDiscovery,
|
||||
} from "./reminder/register-maintenance";
|
||||
import type { PgBoss } from "pg-boss";
|
||||
|
||||
/**
|
||||
* Give the event loop a turn. Each registrar's module graph is evaluated in
|
||||
* one synchronous stretch when it is first imported; loading the five one
|
||||
* after another, with a turn in between, keeps the longest stall to the
|
||||
* largest graph instead of the sum of all five, so requests and the health
|
||||
* probe are answered in between.
|
||||
*/
|
||||
const yieldToLoop = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||
|
||||
/**
|
||||
* Import one registrar module as its own boot step. The registrars and the
|
||||
* handler graphs behind them are loaded here, after `boss.start()`, rather
|
||||
* than when this module is imported.
|
||||
*/
|
||||
async function loadRegistrar<T>(
|
||||
name: string,
|
||||
load: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
beginBootStep(`load_${name}`);
|
||||
await yieldToLoop();
|
||||
const mod = await load();
|
||||
await yieldToLoop();
|
||||
return mod;
|
||||
}
|
||||
|
||||
export async function startReminderWorker() {
|
||||
// v1.4: refuse to boot if the operator marked this container as
|
||||
@@ -58,6 +66,7 @@ export async function startReminderWorker() {
|
||||
|
||||
// Read before any `boss.work` binding below can take a job: whatever is
|
||||
// still active now was started by a process that is gone.
|
||||
beginBootStep("boss_start");
|
||||
const bootedAt = new Date();
|
||||
await boss.start();
|
||||
await reportJobsCutOffAtBoot(bootedAt);
|
||||
@@ -65,20 +74,62 @@ export async function startReminderWorker() {
|
||||
markWorkerStarted();
|
||||
|
||||
// Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT.
|
||||
registerShutdownHandlers(boss);
|
||||
const lifecycle = await loadRegistrar(
|
||||
"worker_lifecycle",
|
||||
() => import("./reminder/worker-lifecycle"),
|
||||
);
|
||||
lifecycle.registerShutdownHandlers(boss);
|
||||
|
||||
// Register every domain's queues (createQueue + schedule + boss.work). Each
|
||||
// registrar returns the queue names it provisioned; the aggregate is the
|
||||
// boot-level mirror of the per-registrar `allQueues` arrays the dead-queue
|
||||
// guards pin. Order preserves the monolith's createQueue / schedule / work
|
||||
// sequence (integration → status → rollup → reminders → maintenance).
|
||||
const registeredQueues = [
|
||||
...(await registerIntegrationSyncQueues(boss)),
|
||||
...(await registerStatusQueues(boss)),
|
||||
...(await registerRollupQueues(boss)),
|
||||
...(await registerReminderQueues(boss)),
|
||||
...(await registerMaintenanceQueues(boss)),
|
||||
];
|
||||
const integration = await loadRegistrar(
|
||||
"integration_sync",
|
||||
() => import("./reminder/register-integration-sync"),
|
||||
);
|
||||
const registeredQueues = await registerInStep(
|
||||
"integration_sync",
|
||||
boss,
|
||||
integration.registerIntegrationSyncQueues,
|
||||
);
|
||||
const status = await loadRegistrar(
|
||||
"status",
|
||||
() => import("./reminder/register-status"),
|
||||
);
|
||||
registeredQueues.push(
|
||||
...(await registerInStep("status", boss, status.registerStatusQueues)),
|
||||
);
|
||||
const rollup = await loadRegistrar(
|
||||
"rollup",
|
||||
() => import("./reminder/register-rollup"),
|
||||
);
|
||||
registeredQueues.push(
|
||||
...(await registerInStep("rollup", boss, rollup.registerRollupQueues)),
|
||||
);
|
||||
const reminders = await loadRegistrar(
|
||||
"reminders",
|
||||
() => import("./reminder/register-reminders"),
|
||||
);
|
||||
registeredQueues.push(
|
||||
...(await registerInStep(
|
||||
"reminders",
|
||||
boss,
|
||||
reminders.registerReminderQueues,
|
||||
)),
|
||||
);
|
||||
const maintenance = await loadRegistrar(
|
||||
"maintenance",
|
||||
() => import("./reminder/register-maintenance"),
|
||||
);
|
||||
registeredQueues.push(
|
||||
...(await registerInStep(
|
||||
"maintenance",
|
||||
boss,
|
||||
maintenance.registerMaintenanceQueues,
|
||||
)),
|
||||
);
|
||||
workerLog(
|
||||
"info",
|
||||
`[reminder-worker] registered ${registeredQueues.length} queues across 5 domain registrars`,
|
||||
@@ -86,22 +137,35 @@ export async function startReminderWorker() {
|
||||
|
||||
// v1.4.34 — reconcile any `ImportJob` rows that were mid-parse when the
|
||||
// worker last shut down, flipping orphaned rows to `failed`.
|
||||
await reconcileImportJobsAtBoot();
|
||||
beginBootStep("boot_reconcile");
|
||||
await lifecycle.reconcileImportJobsAtBoot();
|
||||
|
||||
// v1.4.48 — boot probe for legacy `integration_statuses` rows that still
|
||||
// carry `consecutive_failures_by_kind = NULL`.
|
||||
await probeIntegrationStatusAtBoot();
|
||||
await lifecycle.probeIntegrationStatusAtBoot();
|
||||
|
||||
// Fire-and-forget boot discovery for every self-converging backfill /
|
||||
// consolidation pass. Each is idempotent across reboots and never fails
|
||||
// worker boot on a miss (errors come back through the helper's result).
|
||||
await enqueueRollupBootDiscovery();
|
||||
await enqueueIntegrationSyncBootDiscovery();
|
||||
await enqueueMaintenanceBootDiscovery();
|
||||
beginBootStep("boot_discovery");
|
||||
await rollup.enqueueRollupBootDiscovery();
|
||||
await integration.enqueueIntegrationSyncBootDiscovery();
|
||||
await maintenance.enqueueMaintenanceBootDiscovery();
|
||||
|
||||
await reportBootSteps({ boot_queue_count: registeredQueues.length });
|
||||
return boss;
|
||||
}
|
||||
|
||||
/** Run one registrar as its own boot step. */
|
||||
async function registerInStep(
|
||||
name: string,
|
||||
boss: PgBoss,
|
||||
register: (boss: PgBoss) => Promise<readonly string[]>,
|
||||
): Promise<string[]> {
|
||||
beginBootStep(`register_${name}`);
|
||||
return [...(await register(boss))];
|
||||
}
|
||||
|
||||
// Run standalone
|
||||
if (
|
||||
process.argv[1]?.endsWith("reminder-worker.ts") ||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/jobs/apple-health-import-worker", () => ({
|
||||
reconcileOrphanImportJobs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/integration-status-null-probe", () => ({
|
||||
probeIntegrationStatusNullBuckets: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent: vi.fn() }));
|
||||
vi.mock("../shared", () => ({ getWorkerPrisma: vi.fn(), workerLog: vi.fn() }));
|
||||
|
||||
import {
|
||||
registerShutdownHandlers,
|
||||
SHUTDOWN_JOB_TIMEOUT_MS,
|
||||
} from "../worker-lifecycle";
|
||||
|
||||
function fakeProcess() {
|
||||
const listeners = new Map<string, () => void>();
|
||||
const exits: Array<number | string | null | undefined> = [];
|
||||
const proc = {
|
||||
once: (event: string, fn: () => void) => {
|
||||
listeners.set(event, fn);
|
||||
return proc;
|
||||
},
|
||||
exit: (code?: number | string | null) => {
|
||||
exits.push(code);
|
||||
},
|
||||
};
|
||||
return {
|
||||
proc: proc as unknown as Pick<NodeJS.Process, "once" | "exit">,
|
||||
signal: (name: string) => listeners.get(name)?.(),
|
||||
exits,
|
||||
};
|
||||
}
|
||||
|
||||
describe("graceful shutdown", () => {
|
||||
it("holds the process exit until running jobs have finished", async () => {
|
||||
let finishJobs!: () => void;
|
||||
const stopped = new Promise<void>((r) => (finishJobs = r));
|
||||
const stop = vi.fn(() => stopped);
|
||||
const fake = fakeProcess();
|
||||
registerShutdownHandlers({ stop }, fake.proc);
|
||||
|
||||
fake.signal("SIGTERM");
|
||||
expect(stop).toHaveBeenCalledWith({
|
||||
graceful: true,
|
||||
timeout: SHUTDOWN_JOB_TIMEOUT_MS,
|
||||
});
|
||||
// Next's handler closes the server and exits; the exit waits.
|
||||
fake.proc.exit(143);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fake.exits).toEqual([]);
|
||||
|
||||
finishJobs();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(fake.exits).toEqual([143]);
|
||||
});
|
||||
|
||||
it("exits at the deadline when a job does not finish", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fake = fakeProcess();
|
||||
registerShutdownHandlers(
|
||||
{ stop: vi.fn(() => new Promise<void>(() => {})) },
|
||||
fake.proc,
|
||||
);
|
||||
fake.signal("SIGTERM");
|
||||
fake.proc.exit(143);
|
||||
await vi.advanceTimersByTimeAsync(SHUTDOWN_JOB_TIMEOUT_MS);
|
||||
expect(fake.exits).toEqual([]);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(fake.exits).toEqual([143]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("drains once, whatever signals follow", () => {
|
||||
const stop = vi.fn(() => Promise.resolve());
|
||||
const fake = fakeProcess();
|
||||
registerShutdownHandlers({ stop }, fake.proc);
|
||||
fake.signal("SIGTERM");
|
||||
fake.signal("SIGINT");
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -16,30 +16,55 @@ import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
|
||||
import { getWorkerPrisma, workerLog } from "./shared";
|
||||
|
||||
/** How long running jobs get to finish on a stop signal. */
|
||||
export const SHUTDOWN_JOB_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Graceful shutdown: drain in-flight jobs on SIGTERM/SIGINT (sent by Docker
|
||||
* Compose `docker stop`, Kubernetes pod termination, Coolify redeploys).
|
||||
* Without this, pending handlers were force-killed and could either be lost
|
||||
* or replayed on next start. The listeners register once — re-entering the
|
||||
* worker boot (e.g. on hot-reload in dev) is a no-op for the handlers because
|
||||
* they capture `boss` by closure and the first signal stops everything.
|
||||
*
|
||||
* Next's own signal handler closes the HTTP server (finishing the requests
|
||||
* in flight) and then calls `process.exit`. That exit used to win the race
|
||||
* against `boss.stop`, so a running job was cut off mid-way and stayed
|
||||
* `active` until its expiry, which kept an exclusive queue blocked. The
|
||||
* handler therefore holds `process.exit` until the boss has stopped: both
|
||||
* drains run side by side, the process leaves when the slower one is done,
|
||||
* and never later than {@link SHUTDOWN_JOB_TIMEOUT_MS} plus a margin, so a
|
||||
* stuck handler cannot block a deploy. Compose gives the container 45 s
|
||||
* (`stop_grace_period`) for this.
|
||||
*
|
||||
* The listeners register once; re-entering the worker boot (hot reload in
|
||||
* dev) is a no-op for them because the first signal stops everything.
|
||||
*/
|
||||
export function registerShutdownHandlers(boss: PgBoss): void {
|
||||
let shutdownInProgress = false;
|
||||
const onSignal = async (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (shutdownInProgress) return;
|
||||
shutdownInProgress = true;
|
||||
export function registerShutdownHandlers(
|
||||
boss: Pick<PgBoss, "stop">,
|
||||
proc: Pick<NodeJS.Process, "once" | "exit"> = process,
|
||||
): void {
|
||||
let drained: Promise<void> | null = null;
|
||||
const onSignal = (signal: "SIGTERM" | "SIGINT") => {
|
||||
if (drained) return;
|
||||
workerLog("error", `received ${signal}, draining boss`);
|
||||
try {
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. timeout cap so a stuck handler can't block deploys.
|
||||
await boss.stop({ graceful: true, timeout: 30_000 });
|
||||
} catch (err) {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
}
|
||||
// graceful=true waits for active handlers to finish, then closes the
|
||||
// pg connection pool. The timeout caps it.
|
||||
drained = boss
|
||||
.stop({ graceful: true, timeout: SHUTDOWN_JOB_TIMEOUT_MS })
|
||||
.catch((err: unknown) => {
|
||||
workerLog("error", "boss.stop failed during shutdown", err);
|
||||
});
|
||||
const exit = proc.exit.bind(proc);
|
||||
const deadline = new Promise<void>((resolve) =>
|
||||
setTimeout(resolve, SHUTDOWN_JOB_TIMEOUT_MS + 5_000).unref(),
|
||||
);
|
||||
let exiting = false;
|
||||
proc.exit = ((code?: number | string | null) => {
|
||||
if (exiting) return undefined as never;
|
||||
exiting = true;
|
||||
void Promise.race([drained, deadline]).finally(() => exit(code));
|
||||
return undefined as never;
|
||||
}) as typeof proc.exit;
|
||||
};
|
||||
process.once("SIGTERM", () => void onSignal("SIGTERM"));
|
||||
process.once("SIGINT", () => void onSignal("SIGINT"));
|
||||
proc.once("SIGTERM", () => onSignal("SIGTERM"));
|
||||
proc.once("SIGINT", () => onSignal("SIGINT"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
getLabHistory,
|
||||
resolveRichMetric,
|
||||
MCP_METRIC_STATUS_DISCOVERY,
|
||||
LOCALISED_METRIC_NAME_COLLISIONS,
|
||||
localisedMetricNameCollisions,
|
||||
metricStatusDiscoveryRows,
|
||||
} from "../rich-reads";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
@@ -292,7 +292,7 @@ describe("resolveRichMetric — localised metric names", () => {
|
||||
// the set is empty and pinned empty: a NEW collision fails here instead of
|
||||
// silently resolving to whichever entry happened to be built first.
|
||||
it("refuses a word two different metrics claim, and only those words", () => {
|
||||
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
|
||||
expect([...localisedMetricNameCollisions()].sort()).toEqual([]);
|
||||
});
|
||||
|
||||
// The property, not a sample: over every metric and every shipped locale,
|
||||
|
||||
@@ -85,13 +85,21 @@ export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> =
|
||||
* `locales`, so a seventh language starts resolving the moment its bundle
|
||||
* lands, with no edit here.
|
||||
*/
|
||||
const NUTRIENT_NAME_INDEX = buildLocalisedLabelIndex<NutrientCode>(
|
||||
NUTRIENT_CODES.map((code) => ({
|
||||
id: code,
|
||||
messageKey: nutrientLabelKey(code),
|
||||
value: code,
|
||||
})),
|
||||
);
|
||||
let nutrientNameIndexCache:
|
||||
ReturnType<typeof nutrientNameIndexBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function nutrientNameIndex(): ReturnType<typeof nutrientNameIndexBuild> {
|
||||
return (nutrientNameIndexCache ??= nutrientNameIndexBuild());
|
||||
}
|
||||
function nutrientNameIndexBuild() {
|
||||
return buildLocalisedLabelIndex<NutrientCode>(
|
||||
NUTRIENT_CODES.map((code) => ({
|
||||
id: code,
|
||||
messageKey: nutrientLabelKey(code),
|
||||
value: code,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Overview mode (no `nutrient` arg) mirrors `GET /api/nutrients`'s bounds. */
|
||||
const OVERVIEW_DEFAULT_DAYS = 14;
|
||||
@@ -148,7 +156,7 @@ export function resolveNutrientCode(input: string): NutrientCode | null {
|
||||
if (!raw) return null;
|
||||
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
|
||||
if (isNutrientCode(key)) return key;
|
||||
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
|
||||
return nutrientNameIndex().index.get(foldLabel(raw)) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-14
@@ -551,16 +551,14 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
|
||||
//
|
||||
// Every OTHER measurement type is still entered, value-less, because
|
||||
// ambiguity has to be judged over the whole label space rather than over the
|
||||
// exposed slice of it. French and Italian once called body fat and fat mass
|
||||
// by one phrase ("masse grasse" / "massa grassa") while only one of the two
|
||||
// was exposed, so an index that knew about the exposed one alone would have
|
||||
// seen no collision and answered kilograms to someone who asked for a
|
||||
// percentage. Those labels are distinct now; the whole-space check stays so a
|
||||
// future bundle cannot reintroduce the trap unseen.
|
||||
//
|
||||
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
|
||||
// `vo2max` are protocol slugs, not translations of anything.
|
||||
const LOCALISED_METRIC_INDEX = (() => {
|
||||
// exposed slice of it: French and Italian once named body fat and fat mass
|
||||
// alike while only one was exposed, which answered kilograms to a question
|
||||
// about a percentage. The alias tables stay English (`resting_hr`, `spo2`
|
||||
// are protocol slugs). Built on first use: the index reads every catalog.
|
||||
let metricIndex: ReturnType<typeof buildLocalisedMetricIndex> | undefined;
|
||||
const localisedMetricIndex = () =>
|
||||
(metricIndex ??= buildLocalisedMetricIndex());
|
||||
function buildLocalisedMetricIndex() {
|
||||
const exposed = new Map<MeasurementType, RichMetric>();
|
||||
const claim = (metric: RichMetric | null) => {
|
||||
if (metric && !exposed.has(metric.measurementType)) {
|
||||
@@ -579,7 +577,7 @@ const LOCALISED_METRIC_INDEX = (() => {
|
||||
value: exposed.get(type as MeasurementType) ?? null,
|
||||
}));
|
||||
return buildLocalisedLabelIndex(entries);
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Words two different measurement types claim in some bundle, and which
|
||||
@@ -587,8 +585,9 @@ const LOCALISED_METRIC_INDEX = (() => {
|
||||
* is empty: a NEW member is a regression to look at rather than something to
|
||||
* resolve by coin flip.
|
||||
*/
|
||||
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
|
||||
LOCALISED_METRIC_INDEX.ambiguous;
|
||||
export function localisedMetricNameCollisions(): ReadonlySet<string> {
|
||||
return localisedMetricIndex().ambiguous;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a free-text metric name to a single scalar series. Forgiving for an
|
||||
@@ -632,7 +631,7 @@ export function resolveRichMetric(input: string): RichMetric | null {
|
||||
// on PULSE_WAVE_VELOCITY, because "pulse-wave velocity" contains "puls".
|
||||
// A value-less hit (a metric this surface does not expose) falls through
|
||||
// rather than short-circuiting, so the English behaviour below is untouched.
|
||||
const localised = LOCALISED_METRIC_INDEX.index.get(foldLabel(raw));
|
||||
const localised = localisedMetricIndex().index.get(foldLabel(raw));
|
||||
if (localised) return localised;
|
||||
|
||||
// 5. English display-name substring match (e.g. "heart-rate variability").
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* The workout picker as it stood while duplicates were matched by fixed
|
||||
* five-minute clock slots. Kept only as the reference the parity tests in
|
||||
* `pick-canonical-workout-rows.property.test.ts` compare the duplicate window
|
||||
* against: on every input where no two workouts of one sport within five
|
||||
* minutes straddle a slot boundary, both must return the same rows.
|
||||
*/
|
||||
import type { MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
import {
|
||||
getSourceLadder,
|
||||
parseSourcePriority,
|
||||
} from "@/lib/validations/source-priority";
|
||||
|
||||
/**
|
||||
* Minimum row shape — same posture as `SourcePickerRow`. The helper
|
||||
* only consults the three fields; downstream callers can read every
|
||||
* other column they fetched alongside.
|
||||
*/
|
||||
export interface WorkoutPickerRow {
|
||||
userId?: string;
|
||||
startedAt: Date;
|
||||
/**
|
||||
* Session length. Together with `sportType` + `startedAt` this is the
|
||||
* exact-session identity used to collapse repeated same-source sync rows.
|
||||
* Optional only so fixtures can omit it; every production call site MUST
|
||||
* select it, and `workout-canonical-identity-guard.test.ts` freezes that set.
|
||||
*/
|
||||
durationSec?: number;
|
||||
sportType: string;
|
||||
source: MeasurementSource;
|
||||
avgHeartRate?: number | null;
|
||||
maxHeartRate?: number | null;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* HealthKit can resend the same session with a new external UUID. The source
|
||||
* ladder correctly chooses Apple over WHOOP, but previously kept every Apple
|
||||
* row in that winning bucket, which made the web list show the same workout
|
||||
* two or three times.
|
||||
*
|
||||
* The identity is `(sportType, startedAt, durationSec)`. `endedAt` was part of
|
||||
* this key when the collapse first shipped and has been dropped deliberately:
|
||||
*
|
||||
* - It is not a free extra check. On a workout with pauses, `endedAt` is the
|
||||
* wall-clock end while `durationSec` counts only moving time, so the two
|
||||
* legitimately disagree, and a stored row can carry either convention. Two
|
||||
* re-sends of one session can therefore differ in `endedAt` alone, and the
|
||||
* stricter key let that duplicate through.
|
||||
* - It bought nothing in exchange. Nobody starts a second workout of the
|
||||
* same sport in the same instant with the same duration, so start plus
|
||||
* duration already identifies the session uniquely.
|
||||
* - Requiring it split the callers. Four of the five call sites select
|
||||
* `durationSec` but not `endedAt`, so the collapse ran on the workout list
|
||||
* and silently did nothing for the Coach narration, the per-sport baseline
|
||||
* and the deep-link cluster. One list, one number, two answers.
|
||||
*
|
||||
* A row without `durationSec` cannot be identified and passes through
|
||||
* uncollapsed. That is a fixture-only path: every production call site selects
|
||||
* the field and `workout-canonical-identity-guard.test.ts` keeps it that way.
|
||||
*/
|
||||
function exactSessionKey(row: WorkoutPickerRow): string | null {
|
||||
if (row.durationSec == null) return null;
|
||||
return [row.sportType, row.startedAt.getTime(), row.durationSec].join(":");
|
||||
}
|
||||
|
||||
function rowRichness(row: WorkoutPickerRow): number {
|
||||
return (
|
||||
(row.avgHeartRate != null ? 2 : 0) +
|
||||
(row.maxHeartRate != null ? 2 : 0) +
|
||||
(row.metadata != null ? 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
|
||||
const bySession = new Map<string, T>();
|
||||
const passthrough: T[] = [];
|
||||
for (const row of rows) {
|
||||
const key = exactSessionKey(row);
|
||||
if (key == null) {
|
||||
passthrough.push(row);
|
||||
continue;
|
||||
}
|
||||
const existing = bySession.get(key);
|
||||
if (!existing || rowRichness(row) > rowRichness(existing)) {
|
||||
bySession.set(key, row);
|
||||
}
|
||||
}
|
||||
return [...passthrough, ...bySession.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket-slot length. Two workouts whose `startedAt` falls in the
|
||||
* same 5-minute slot AND share the same sportType are treated as the
|
||||
* "same" workout from different sources. Tightens further than the
|
||||
* 1-minute clock skew between HK + Withings would suggest because
|
||||
* iOS posts HK `startDate` rounded to the second but Withings stamps
|
||||
* its `startdate` independently — five minutes gives both heads
|
||||
* enough slack on a heavy-traffic morning sync without crossing the
|
||||
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
|
||||
* runs five minutes apart in practice).
|
||||
*/
|
||||
const BUCKET_SLOT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The cross-source duplicate slot a workout falls in. Exported for the one
|
||||
* reader that has to look past the picked row: the strain engine, whose winner
|
||||
* may carry no heart-rate series while its dropped twin does.
|
||||
*/
|
||||
export function legacyWorkoutDuplicateSlotKey(row: WorkoutPickerRow): string {
|
||||
return bucketKey(row);
|
||||
}
|
||||
|
||||
function bucketKey(row: WorkoutPickerRow): string {
|
||||
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
|
||||
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
const WHOOP_ZONE_DURATION_KEYS = [
|
||||
"zone_zero_milli",
|
||||
"zone_one_milli",
|
||||
"zone_two_milli",
|
||||
"zone_three_milli",
|
||||
"zone_four_milli",
|
||||
"zone_five_milli",
|
||||
] as const;
|
||||
|
||||
function sanitizedWhoopZoneDurations(
|
||||
metadata: unknown,
|
||||
): Record<string, number> | null {
|
||||
const raw = jsonRecord(jsonRecord(metadata)?.zoneDurations);
|
||||
if (!raw) return null;
|
||||
|
||||
const sanitized: Record<string, number> = {};
|
||||
for (const key of WHOOP_ZONE_DURATION_KEYS) {
|
||||
const value = raw[key];
|
||||
if (value === undefined) continue;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return null;
|
||||
}
|
||||
sanitized[key] = value;
|
||||
}
|
||||
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
||||
}
|
||||
|
||||
function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
|
||||
winner: T,
|
||||
donor: T,
|
||||
): T {
|
||||
const winnerMetadata = jsonRecord(winner.metadata);
|
||||
const donorZones = sanitizedWhoopZoneDurations(donor.metadata);
|
||||
const needsZones =
|
||||
donorZones != null && winnerMetadata?.zoneDurations == null;
|
||||
const needsAvg = winner.avgHeartRate == null && donor.avgHeartRate != null;
|
||||
const needsMax = winner.maxHeartRate == null && donor.maxHeartRate != null;
|
||||
|
||||
if (!needsAvg && !needsMax && !needsZones) return winner;
|
||||
|
||||
return {
|
||||
...winner,
|
||||
...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}),
|
||||
...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}),
|
||||
...(needsZones
|
||||
? {
|
||||
metadata: {
|
||||
...(winnerMetadata ?? {}),
|
||||
zoneDurations: donorZones,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
|
||||
* donor can never enrich two same-source occurrences that happen to share a
|
||||
* fixed slot. Stable input indexes break equal-distance ties.
|
||||
*/
|
||||
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
|
||||
winners: readonly T[],
|
||||
donors: readonly T[],
|
||||
): T[] {
|
||||
if (winners.length === 0 || donors.length === 0) return [...winners];
|
||||
|
||||
const candidates: Array<{
|
||||
winnerIndex: number;
|
||||
donorIndex: number;
|
||||
deltaMs: number;
|
||||
}> = [];
|
||||
winners.forEach((winner, winnerIndex) => {
|
||||
donors.forEach((donor, donorIndex) => {
|
||||
candidates.push({
|
||||
winnerIndex,
|
||||
donorIndex,
|
||||
deltaMs: Math.abs(
|
||||
winner.startedAt.getTime() - donor.startedAt.getTime(),
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
candidates.sort(
|
||||
(left, right) =>
|
||||
left.deltaMs - right.deltaMs ||
|
||||
left.winnerIndex - right.winnerIndex ||
|
||||
left.donorIndex - right.donorIndex,
|
||||
);
|
||||
|
||||
const enriched = [...winners];
|
||||
const matchedWinners = new Set<number>();
|
||||
const matchedDonors = new Set<number>();
|
||||
for (const candidate of candidates) {
|
||||
if (
|
||||
matchedWinners.has(candidate.winnerIndex) ||
|
||||
matchedDonors.has(candidate.donorIndex)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
enriched[candidate.winnerIndex] = enrichFromWhoopTwin(
|
||||
winners[candidate.winnerIndex],
|
||||
donors[candidate.donorIndex],
|
||||
);
|
||||
matchedWinners.add(candidate.winnerIndex);
|
||||
matchedDonors.add(candidate.donorIndex);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/**
|
||||
* The source order for workouts: the `steps` metric's ladder, the
|
||||
* canonical-activity bucket the Sources settings configure, falling back to
|
||||
* its default when the user saved none or it does not parse.
|
||||
*/
|
||||
function workoutSourceLadder(
|
||||
userPriorityJson: unknown,
|
||||
): readonly MeasurementSource[] {
|
||||
return getSourceLadder(parseSourcePriority(userPriorityJson), "steps");
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick canonical rows across sources for a user's workouts.
|
||||
*
|
||||
* @param rows input rows (deterministic order required)
|
||||
* @param userPriorityJson the user's `sourcePriorityJson` blob — same
|
||||
* shape the measurement picker consults.
|
||||
* Falls back to the canonical default ladder
|
||||
* when null / unparseable.
|
||||
* @returns the subset of input rows that survives the picker. Insertion
|
||||
* order preserved.
|
||||
*/
|
||||
export function legacyPickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
|
||||
rows: readonly T[],
|
||||
userPriorityJson: unknown = null,
|
||||
): T[] {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const sourceLadder = workoutSourceLadder(userPriorityJson);
|
||||
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
rows: T[];
|
||||
sources: Set<MeasurementSource>;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const key = bucketKey(row);
|
||||
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
|
||||
slot.rows.push(row);
|
||||
slot.sources.add(row.source);
|
||||
buckets.set(key, slot);
|
||||
}
|
||||
|
||||
const canonical: T[] = [];
|
||||
for (const [, slot] of buckets) {
|
||||
if (slot.rows.length === 1) {
|
||||
// No cross-source overlap → keep the single row regardless of
|
||||
// ladder membership.
|
||||
canonical.push(slot.rows[0]);
|
||||
continue;
|
||||
}
|
||||
|
||||
let picked: MeasurementSource | undefined;
|
||||
for (const source of sourceLadder) {
|
||||
if (slot.sources.has(source)) {
|
||||
picked = source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!picked) {
|
||||
// None of the bucket's sources are listed in the ladder — never
|
||||
// silently drop. Keep every row in the bucket; downstream
|
||||
// callers can choose to surface a "duplicate from unrecognised
|
||||
// sources" diagnostic.
|
||||
canonical.push(...slot.rows);
|
||||
continue;
|
||||
}
|
||||
|
||||
const winners = deduplicateWinningRows(
|
||||
slot.rows.filter((row) => row.source === picked),
|
||||
);
|
||||
const whoopDonors =
|
||||
picked === "WHOOP"
|
||||
? []
|
||||
: slot.rows.filter((row) => row.source === "WHOOP");
|
||||
canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors));
|
||||
}
|
||||
|
||||
// Re-sort to preserve input order — the bucket map iterates in
|
||||
// insertion order but the per-bucket fan-out can interleave rows
|
||||
// from later buckets ahead of earlier-bucket survivors. Caller
|
||||
// wants the same `startedAt ASC` order it passed in.
|
||||
canonical.sort((a, b) => {
|
||||
const delta = a.startedAt.getTime() - b.startedAt.getTime();
|
||||
if (delta !== 0) return delta;
|
||||
// Stable-tiebreak on sportType so the output is deterministic
|
||||
// even when two surviving rows share startedAt (rare; manual
|
||||
// entries on the same second).
|
||||
return a.sportType.localeCompare(b.sportType);
|
||||
});
|
||||
return canonical;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canonicalWorkoutTrimps } from "@/lib/insights/strain-score";
|
||||
|
||||
import {
|
||||
pickCanonicalWorkoutRows,
|
||||
workoutDuplicateClusterKeys,
|
||||
workoutSourceLadder,
|
||||
} from "../pick-canonical-workout-rows";
|
||||
import {
|
||||
legacyPickCanonicalWorkoutRows,
|
||||
legacyWorkoutDuplicateSlotKey,
|
||||
} from "./pick-canonical-workout-rows.legacy";
|
||||
|
||||
type Source = "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "STRAVA";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
userId?: string;
|
||||
startedAt: Date;
|
||||
durationSec?: number;
|
||||
sportType: string;
|
||||
source: Source;
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const MIN = 60_000;
|
||||
const WINDOW = 5 * MIN;
|
||||
const SOURCES: Source[] = [
|
||||
"APPLE_HEALTH",
|
||||
"WHOOP",
|
||||
"WITHINGS",
|
||||
"MANUAL",
|
||||
"STRAVA",
|
||||
];
|
||||
const SPORTS = ["running", "cycling", "walking"];
|
||||
|
||||
/** Small seeded generator, so a failure names a reproducible case. */
|
||||
function rng(seed: number) {
|
||||
let a = seed >>> 0;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
return {
|
||||
next,
|
||||
int: (lo: number, hi: number) => lo + Math.floor(next() * (hi - lo + 1)),
|
||||
pick: <T>(list: readonly T[]) => list[Math.floor(next() * list.length)]!,
|
||||
};
|
||||
}
|
||||
|
||||
function randomRows(seed: number): Row[] {
|
||||
const r = rng(seed);
|
||||
const base = Date.UTC(2026, 4, 16, 6, 0, 0) + r.int(0, 86_400_000);
|
||||
const count = r.int(1, 14);
|
||||
const rows: Row[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Mostly near each other, so clusters form; sometimes a re-send of an
|
||||
// earlier row (same start and duration) under another id.
|
||||
const twin = i > 0 && r.next() < 0.2 ? rows[r.int(0, i - 1)]! : null;
|
||||
const startedAt = twin
|
||||
? new Date(
|
||||
twin.startedAt.getTime() + (r.next() < 0.5 ? 0 : r.int(1, 90) * 1000),
|
||||
)
|
||||
: new Date(base + r.int(0, 40 * MIN));
|
||||
rows.push({
|
||||
id: `w-${seed}-${i}`,
|
||||
userId: r.next() < 0.15 ? "other" : "u1",
|
||||
startedAt,
|
||||
durationSec:
|
||||
r.next() < 0.1 ? undefined : (twin?.durationSec ?? r.int(10, 90) * 60),
|
||||
sportType: twin && r.next() < 0.8 ? twin.sportType : r.pick(SPORTS),
|
||||
source: r.pick(SOURCES),
|
||||
avgHeartRate: r.next() < 0.5 ? r.int(100, 160) : null,
|
||||
maxHeartRate: r.next() < 0.5 ? r.int(160, 190) : null,
|
||||
metadata:
|
||||
r.next() < 0.3
|
||||
? { zoneDurations: { zone_two_milli: r.int(1, 60) * MIN } }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
// Callers pass `startedAt ASC, id ASC`.
|
||||
rows.sort(
|
||||
(a, b) =>
|
||||
a.startedAt.getTime() - b.startedAt.getTime() || a.id.localeCompare(b.id),
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when some pair of one (user, sport) lies within the window but in two
|
||||
* different fixed slots: the only inputs on which the old picker and the
|
||||
* duplicate window may disagree.
|
||||
*/
|
||||
function straddlesASlot(rows: readonly Row[]): boolean {
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
for (let j = i + 1; j < rows.length; j++) {
|
||||
const a = rows[i]!;
|
||||
const b = rows[j]!;
|
||||
if (a.userId !== b.userId || a.sportType !== b.sportType) continue;
|
||||
if (Math.abs(a.startedAt.getTime() - b.startedAt.getTime()) >= WINDOW)
|
||||
continue;
|
||||
if (legacyWorkoutDuplicateSlotKey(a) !== legacyWorkoutDuplicateSlotKey(b))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const LADDERS: unknown[] = [
|
||||
null,
|
||||
{ steps: ["WITHINGS", "APPLE_HEALTH"] },
|
||||
{ steps: ["WHOOP"] },
|
||||
];
|
||||
|
||||
/** The strain fold as it stood on fixed slots, for the parity check. */
|
||||
function legacyTrimps(
|
||||
rows: readonly Row[],
|
||||
ladderJson: unknown,
|
||||
trimpOf: (r: Row) => number,
|
||||
) {
|
||||
const picked = new Set(
|
||||
legacyPickCanonicalWorkoutRows(rows, ladderJson).map((r) => r.id),
|
||||
);
|
||||
const ladder = workoutSourceLadder(ladderJson);
|
||||
const rankOf = (source: string) => {
|
||||
const index = ladder.indexOf(source as (typeof ladder)[number]);
|
||||
return index === -1 ? ladder.length : index;
|
||||
};
|
||||
const slots = new Map<
|
||||
string,
|
||||
{ picked: Map<string, number>; dropped: number }
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const key = legacyWorkoutDuplicateSlotKey(row);
|
||||
const slot = slots.get(key) ?? { picked: new Map(), dropped: 0 };
|
||||
const t = trimpOf(row);
|
||||
if (t > 0) {
|
||||
if (picked.has(row.id))
|
||||
slot.picked.set(row.source, (slot.picked.get(row.source) ?? 0) + t);
|
||||
else slot.dropped = Math.max(slot.dropped, t);
|
||||
}
|
||||
slots.set(key, slot);
|
||||
}
|
||||
const out: number[] = [];
|
||||
for (const slot of slots.values()) {
|
||||
let best: { rank: number; trimp: number } | null = null;
|
||||
for (const [source, trimp] of slot.picked) {
|
||||
const rank = rankOf(source);
|
||||
if (
|
||||
best === null ||
|
||||
rank < best.rank ||
|
||||
(rank === best.rank && trimp > best.trimp)
|
||||
) {
|
||||
best = { rank, trimp };
|
||||
}
|
||||
}
|
||||
const trimp = best !== null ? best.trimp : slot.dropped;
|
||||
if (trimp > 0) out.push(trimp);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("workout duplicate window", () => {
|
||||
it("returns exactly the old rows wherever no pair straddles a slot boundary", () => {
|
||||
let compared = 0;
|
||||
for (let seed = 1; seed <= 4000; seed++) {
|
||||
const rows = randomRows(seed);
|
||||
if (straddlesASlot(rows)) continue;
|
||||
compared++;
|
||||
for (const ladder of LADDERS) {
|
||||
expect(pickCanonicalWorkoutRows(rows, ladder), `seed ${seed}`).toEqual(
|
||||
legacyPickCanonicalWorkoutRows(rows, ladder),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The generator must actually produce comparable inputs, clusters
|
||||
// included, or the loop above proves nothing.
|
||||
expect(compared).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it("gives the strain fold the same impulses wherever no pair straddles a slot", () => {
|
||||
const trimpOf = (row: Row) => row.avgHeartRate ?? 0;
|
||||
let compared = 0;
|
||||
for (let seed = 1; seed <= 2000; seed++) {
|
||||
const rows = randomRows(seed);
|
||||
if (straddlesASlot(rows)) continue;
|
||||
compared++;
|
||||
for (const ladder of LADDERS) {
|
||||
expect(
|
||||
canonicalWorkoutTrimps(rows, ladder, trimpOf),
|
||||
`seed ${seed}`,
|
||||
).toEqual(legacyTrimps(rows, ladder, trimpOf));
|
||||
}
|
||||
}
|
||||
expect(compared).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("collapses two sources' copies of one session at any offset under five minutes", () => {
|
||||
const r = rng(7);
|
||||
let legacyMissed = 0;
|
||||
for (let i = 0; i < 3000; i++) {
|
||||
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
|
||||
const offset = r.int(0, WINDOW - 1);
|
||||
const rows: Row[] = [
|
||||
{
|
||||
id: "apple",
|
||||
userId: "u1",
|
||||
startedAt: new Date(start),
|
||||
durationSec: 1800,
|
||||
sportType: "running",
|
||||
source: "APPLE_HEALTH",
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
},
|
||||
{
|
||||
id: "withings",
|
||||
userId: "u1",
|
||||
startedAt: new Date(start + offset),
|
||||
durationSec: 1790,
|
||||
sportType: "running",
|
||||
source: "WITHINGS",
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
},
|
||||
];
|
||||
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
|
||||
"apple",
|
||||
]);
|
||||
if (legacyPickCanonicalWorkoutRows(rows).length === 2) legacyMissed++;
|
||||
}
|
||||
// The fixed slots missed a share of these; the window misses none.
|
||||
expect(legacyMissed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("never collapses two workouts five minutes or more apart", () => {
|
||||
const r = rng(11);
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const start = Date.UTC(2026, 4, 16) + r.int(0, 86_400_000);
|
||||
const gap = WINDOW + r.int(0, 30 * MIN);
|
||||
const rows: Row[] = ["APPLE_HEALTH", "WITHINGS"].map((source, k) => ({
|
||||
id: `w${k}`,
|
||||
userId: "u1",
|
||||
startedAt: new Date(start + k * gap),
|
||||
durationSec: 1200,
|
||||
sportType: "running",
|
||||
source: source as Source,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
}));
|
||||
expect(pickCanonicalWorkoutRows(rows)).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not depend on where the clock's five-minute marks fall", () => {
|
||||
// Shifting every start by the same amount keeps every gap, so it must
|
||||
// keep the picked set. The fixed slots broke this property.
|
||||
for (let seed = 1; seed <= 1500; seed++) {
|
||||
const rows = randomRows(seed);
|
||||
const shift = rng(seed * 31).int(1, WINDOW - 1);
|
||||
const shifted = rows.map((row) => ({
|
||||
...row,
|
||||
startedAt: new Date(row.startedAt.getTime() + shift),
|
||||
}));
|
||||
const ids = (list: Row[]) => list.map((row) => row.id).sort();
|
||||
expect(ids(pickCanonicalWorkoutRows(shifted)), `seed ${seed}`).toEqual(
|
||||
ids(pickCanonicalWorkoutRows(rows)),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("anchors on the first start, so a row of workouts does not chain", () => {
|
||||
const at = (m: number) => new Date(Date.UTC(2026, 4, 16, 8, m, 0));
|
||||
const rows: Row[] = [0, 4, 8, 12].map((m, k) => ({
|
||||
id: `w${m}`,
|
||||
userId: "u1",
|
||||
startedAt: at(m),
|
||||
durationSec: 600,
|
||||
sportType: "running",
|
||||
source: (k % 2 === 0 ? "APPLE_HEALTH" : "WITHINGS") as Source,
|
||||
avgHeartRate: null,
|
||||
maxHeartRate: null,
|
||||
metadata: null,
|
||||
}));
|
||||
const keys = workoutDuplicateClusterKeys(rows);
|
||||
expect(new Set(keys).size).toBe(2);
|
||||
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
|
||||
"w0",
|
||||
"w8",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -450,7 +450,12 @@ describe("pickCanonicalWorkoutRows", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not chain neighbouring workouts across fixed canonical slots", () => {
|
||||
it("does not chain neighbouring workouts past the first start's window", () => {
|
||||
// `middle` starts four minutes after `first`: inside the duplicate
|
||||
// window, so it is the same run from a lower-ranked source. `last` is
|
||||
// eight minutes after `first`, outside the window measured from the
|
||||
// cluster's first start, so it stays even though it is only four minutes
|
||||
// after `middle`.
|
||||
const rows: RowFixture[] = [
|
||||
{
|
||||
id: "first",
|
||||
@@ -474,7 +479,6 @@ describe("pickCanonicalWorkoutRows", () => {
|
||||
|
||||
expect(pickCanonicalWorkoutRows(rows).map((row) => row.id)).toEqual([
|
||||
"first",
|
||||
"middle",
|
||||
"last",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* The two reads `POST /api/measurements/batch` makes up front about rows the
|
||||
* batch may already hold, one statement each for the whole batch.
|
||||
*
|
||||
* Samples: a re-sent sample used to cost one interactive reconciler
|
||||
* transaction (about ten round trips) only for the reconciler to answer
|
||||
* `duplicate`. One read over the batch's sample ids answers it for every row
|
||||
* at once (the route's exact-duplicate prefilter).
|
||||
*
|
||||
* `stats:` rows: the phone re-sends the same daily totals on every sync, and
|
||||
* each went through the reconciler only to write the same values back, then
|
||||
* counted as `updated`, which started the whole tail: the DAY refold, rollup
|
||||
* jobs, PR detection and a cache stale for the account. Such a row is still
|
||||
* reported `updated`, exactly as before, but nothing is written and it starts
|
||||
* nothing. "Unchanged" is the reconciler's own update with nothing to change:
|
||||
* a live row holds the entry's external key AND its natural identity (both
|
||||
* indexes are unique, so that row is the only candidate), already carries the
|
||||
* HealthKit-statistics provenance, and every column the update would write
|
||||
* already has the value it would write. The route applies it only where
|
||||
* neither key occurs a second time in the batch, and never to a value the
|
||||
* plausibility gate would refuse. A concurrent writer is no different from
|
||||
* one that ran a moment later: its write lands after this no-op either way.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
/** `type::source::externalId`, the external identity of a row. */
|
||||
export function externalKeyOf(
|
||||
type: unknown,
|
||||
source: unknown,
|
||||
externalId: unknown,
|
||||
): string {
|
||||
return `${type}::${source}::${externalId}`;
|
||||
}
|
||||
|
||||
/** Stored rows (live or tombstoned) at the batch's sample ids. */
|
||||
export async function readStoredSampleIdentities(
|
||||
db: Pick<PrismaClient, "measurement">,
|
||||
userId: string,
|
||||
externalIds: readonly string[],
|
||||
): Promise<Map<string, { measuredAt: Date; sleepStage: unknown }>> {
|
||||
const out = new Map<string, { measuredAt: Date; sleepStage: unknown }>();
|
||||
if (externalIds.length === 0) return out;
|
||||
const rows = await db.measurement.findMany({
|
||||
where: { userId, externalId: { in: [...externalIds] } },
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
measuredAt: true,
|
||||
sleepStage: true,
|
||||
},
|
||||
});
|
||||
for (const row of rows) {
|
||||
out.set(externalKeyOf(row.type, row.source, row.externalId), {
|
||||
measuredAt: row.measuredAt,
|
||||
sleepStage: row.sleepStage,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The columns the reconciler's update writes, besides the identity. */
|
||||
const STATS_COLUMNS = [
|
||||
"value",
|
||||
"valueMin",
|
||||
"valueMax",
|
||||
"unit",
|
||||
"sleepStage",
|
||||
"externalSourceVersion",
|
||||
"glucoseContext",
|
||||
"rhythmClassification",
|
||||
"deviceType",
|
||||
] as const;
|
||||
|
||||
export type StoredStatsRow = Record<
|
||||
(typeof STATS_COLUMNS)[number] | "aggregationProvenance",
|
||||
unknown
|
||||
> & { measuredAt: Date };
|
||||
|
||||
/** Live stored rows at the batch's `stats:` ids, with every written column. */
|
||||
export async function readStoredStatsRows(
|
||||
db: Pick<PrismaClient, "measurement">,
|
||||
userId: string,
|
||||
externalIds: readonly string[],
|
||||
): Promise<Map<string, StoredStatsRow>> {
|
||||
const out = new Map<string, StoredStatsRow>();
|
||||
if (externalIds.length === 0) return out;
|
||||
const rows = await db.measurement.findMany({
|
||||
where: { userId, externalId: { in: [...externalIds] }, deletedAt: null },
|
||||
select: {
|
||||
type: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
measuredAt: true,
|
||||
aggregationProvenance: true,
|
||||
...Object.fromEntries(STATS_COLUMNS.map((c) => [c, true])),
|
||||
},
|
||||
});
|
||||
for (const row of rows as unknown as Array<
|
||||
StoredStatsRow & { type: string; source: string; externalId: string }
|
||||
>) {
|
||||
out.set(externalKeyOf(row.type, row.source, row.externalId), row);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the update a `stats:` entry would make to `stored` writes nothing
|
||||
* new: same instant, the statistics provenance already, every column equal.
|
||||
*/
|
||||
export function statsWriteUnchanged(
|
||||
stored: StoredStatsRow | undefined,
|
||||
desired: Record<(typeof STATS_COLUMNS)[number], unknown> & {
|
||||
measuredAt: Date;
|
||||
},
|
||||
): boolean {
|
||||
if (!stored || !(stored.measuredAt instanceof Date)) return false;
|
||||
if (stored.measuredAt.getTime() !== desired.measuredAt.getTime()) {
|
||||
return false;
|
||||
}
|
||||
if (stored.aggregationProvenance !== "HEALTHKIT_STATISTICS") return false;
|
||||
return STATS_COLUMNS.every(
|
||||
(column) => (stored[column] ?? null) === (desired[column] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
/** The identity columns of one batch entry. */
|
||||
export interface BatchKeyRow {
|
||||
type?: unknown;
|
||||
source?: unknown;
|
||||
externalId?: unknown;
|
||||
measuredAt?: unknown;
|
||||
sleepStage?: unknown;
|
||||
}
|
||||
|
||||
/** `type::source::epochMs::sleepStage`, the natural identity of a row. */
|
||||
export function naturalKeyOf(row: BatchKeyRow): string {
|
||||
return `${row.type}::${row.source}::${(row.measuredAt as Date).getTime()}::${row.sleepStage ?? "null"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an entry's external key and natural identity each occur once in
|
||||
* its batch. Only such an entry may be decided ahead of the reconciler: an
|
||||
* earlier entry sharing either key could move or retire the stored row
|
||||
* before this one is reached.
|
||||
*/
|
||||
export function onceInBatch(
|
||||
rows: readonly BatchKeyRow[],
|
||||
): (row: BatchKeyRow) => boolean {
|
||||
const external = new Map<string, number>();
|
||||
const natural = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const e = externalKeyOf(row.type, row.source, row.externalId);
|
||||
external.set(e, (external.get(e) ?? 0) + 1);
|
||||
const n = naturalKeyOf(row);
|
||||
natural.set(n, (natural.get(n) ?? 0) + 1);
|
||||
}
|
||||
return (row) =>
|
||||
external.get(externalKeyOf(row.type, row.source, row.externalId)) === 1 &&
|
||||
natural.get(naturalKeyOf(row)) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a sample entry is an exact duplicate of `stored`, the row at its
|
||||
* external key: the same instant and sleep stage, which makes that row its
|
||||
* natural match too, the reconciler's `duplicate`.
|
||||
*/
|
||||
export function sampleIsStoredDuplicate(
|
||||
stored: { measuredAt: Date; sleepStage: unknown } | undefined,
|
||||
row: BatchKeyRow,
|
||||
): boolean {
|
||||
return (
|
||||
stored !== undefined &&
|
||||
stored.measuredAt.getTime() === (row.measuredAt as Date).getTime() &&
|
||||
(stored.sleepStage ?? null) === (row.sleepStage ?? null)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* The long-window read of a sample-dense series (pulse, CGM glucose, and the
|
||||
* other dense kinds of `/api/measurements/series`): day or hour buckets in
|
||||
* the account's zone, and the strip's figures over the raw readings.
|
||||
*
|
||||
* Both used to be separate statements over the same canonical rows, so a
|
||||
* ten-year pulse read picked the canonical source per day and scanned the
|
||||
* readings twice. Now the window's rows are materialised once (`src`) and
|
||||
* both aggregates run over them in one statement, with exactly the SQL they
|
||||
* had: `tests/integration/dense-series-read-parity.test.ts` holds the two old
|
||||
* statements as the reference.
|
||||
*
|
||||
* Buckets: a day of a type with an activity-dependent sampling rate (pulse)
|
||||
* is the weighted mean of its local hours' means, so a workout hour with
|
||||
* hundreds of times the readings does not outweigh the rest of the day (see
|
||||
* `day-statistic.ts`); the hour grain is already one mean per hour.
|
||||
*
|
||||
* Figures: aggregated over the raw rows, not the buckets, so the strip's
|
||||
* mean, min, max, spread and count are what the raw path computes (`AVG`,
|
||||
* `MIN`, `MAX`, `STDDEV_POP`, the exact row count). For an hourly-mean type
|
||||
* the mean is the mean of the local days' values, each day the mean of its
|
||||
* local hours' means, so it is the mean of the day points the chart draws;
|
||||
* count, min, max and the spread stay over every reading.
|
||||
*
|
||||
* Parameters: `$1` the user (inside `seriesRows`), `$2` the grain, `$3` the
|
||||
* zone, `$4` the window start; `seriesRows` is the FROM-clause subquery of
|
||||
* `seriesRowsFrom`, its literals whitelisted there.
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
dayWeightedRows,
|
||||
hourWeightSql,
|
||||
weightedHourMeanSql,
|
||||
windowMeanSql,
|
||||
zoneDayFrame,
|
||||
} from "@/lib/measurements/day-mean";
|
||||
import { usesHourlyMeanDay } from "@/lib/measurements/day-statistic";
|
||||
|
||||
export interface DenseBucketRow {
|
||||
bucket_start: Date;
|
||||
mean: number;
|
||||
min_value: number;
|
||||
max_value: number;
|
||||
}
|
||||
|
||||
export interface DenseFigures {
|
||||
n: number;
|
||||
mean: number | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
sd: number | null;
|
||||
}
|
||||
|
||||
/** The bucket aggregate over `src`, in the original statement's SQL. */
|
||||
export function denseBucketsSql(hourWeighted: boolean, from: string): string {
|
||||
return hourWeighted
|
||||
? `
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
'hour',
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_hour
|
||||
FROM ${from}
|
||||
),
|
||||
hourly AS (
|
||||
SELECT
|
||||
date_trunc($2, local_hour) AS local_day,
|
||||
AVG("value") AS hour_mean,
|
||||
COUNT(*) AS n,
|
||||
MIN(COALESCE("value_min", "value")) AS min_value,
|
||||
MAX(COALESCE("value_max", "value")) AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_hour
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
${weightedHourMeanSql("hour_mean", hourWeightSql("n"))}::double precision AS mean,
|
||||
MIN(min_value)::double precision AS min_value,
|
||||
MAX(max_value)::double precision AS max_value
|
||||
FROM hourly
|
||||
GROUP BY local_day`
|
||||
: `
|
||||
WITH localized AS (
|
||||
SELECT
|
||||
m."value",
|
||||
m."value_min",
|
||||
m."value_max",
|
||||
date_trunc(
|
||||
$2,
|
||||
(m."measured_at" AT TIME ZONE 'UTC') AT TIME ZONE $3
|
||||
) AS local_day
|
||||
FROM ${from}
|
||||
)
|
||||
SELECT
|
||||
local_day AT TIME ZONE $3 AS bucket_start,
|
||||
AVG("value")::double precision AS mean,
|
||||
MIN(COALESCE("value_min", "value"))::double precision AS min_value,
|
||||
MAX(COALESCE("value_max", "value"))::double precision AS max_value
|
||||
FROM localized
|
||||
GROUP BY local_day`;
|
||||
}
|
||||
|
||||
/** The figures aggregate over the CTE `src`, in the original SQL. */
|
||||
export function denseFiguresSql(hourlyMean: boolean): string {
|
||||
return hourlyMean
|
||||
? `
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
${windowMeanSql({ typeColumn: 'm."type"', value: 'm."value"', weight: "m.day_weight" })}::double precision
|
||||
AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM ${dayWeightedRows("src", zoneDayFrame("$3"))} m
|
||||
GROUP BY m."type"`
|
||||
: `
|
||||
SELECT
|
||||
COUNT(*)::int AS n,
|
||||
AVG(m."value")::double precision AS mean,
|
||||
MIN(m."value")::double precision AS min,
|
||||
MAX(m."value")::double precision AS max,
|
||||
STDDEV_POP(m."value")::double precision AS sd
|
||||
FROM src m`;
|
||||
}
|
||||
|
||||
/** Buckets and figures of one dense series window, in one statement. */
|
||||
export async function readDenseSeries(args: {
|
||||
userId: string;
|
||||
type: MeasurementType;
|
||||
grain: "day" | "hour";
|
||||
userTz: string;
|
||||
since: Date;
|
||||
seriesRows: string;
|
||||
}): Promise<{
|
||||
bucketRows: DenseBucketRow[];
|
||||
rawAgg: DenseFigures | undefined;
|
||||
}> {
|
||||
const hourWeighted = args.grain === "day" && usesHourlyMeanDay(args.type);
|
||||
const rows = await prisma.$queryRawUnsafe<
|
||||
Array<
|
||||
{ kind: "bucket" | "figures" } & Partial<DenseBucketRow> &
|
||||
Partial<DenseFigures>
|
||||
>
|
||||
>(
|
||||
`
|
||||
WITH src AS MATERIALIZED (
|
||||
SELECT m.*
|
||||
FROM ${args.seriesRows}
|
||||
WHERE m."measured_at" >= $4
|
||||
),
|
||||
buckets AS (${denseBucketsSql(hourWeighted, "src m")}
|
||||
),
|
||||
figures AS (${denseFiguresSql(usesHourlyMeanDay(args.type))}
|
||||
)
|
||||
SELECT 'bucket' AS kind, b.bucket_start, b.mean, b.min_value, b.max_value,
|
||||
NULL::int AS n, NULL::double precision AS min,
|
||||
NULL::double precision AS max, NULL::double precision AS sd
|
||||
FROM buckets b
|
||||
UNION ALL
|
||||
SELECT 'figures', NULL, f.mean, NULL, NULL, f.n, f.min, f.max, f.sd
|
||||
FROM figures f
|
||||
ORDER BY kind ASC, bucket_start ASC
|
||||
`,
|
||||
args.userId,
|
||||
args.grain,
|
||||
args.userTz,
|
||||
args.since,
|
||||
);
|
||||
const bucketRows: DenseBucketRow[] = [];
|
||||
let rawAgg: DenseFigures | undefined;
|
||||
for (const row of rows) {
|
||||
if (row.kind === "bucket") {
|
||||
bucketRows.push({
|
||||
bucket_start: row.bucket_start!,
|
||||
mean: row.mean!,
|
||||
min_value: row.min_value!,
|
||||
max_value: row.max_value!,
|
||||
});
|
||||
} else {
|
||||
rawAgg = {
|
||||
n: row.n!,
|
||||
mean: row.mean ?? null,
|
||||
min: row.min ?? null,
|
||||
max: row.max ?? null,
|
||||
sd: row.sd ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { bucketRows, rawAgg };
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
*
|
||||
* Locks per `.planning/research/v1434-r-1-xml-import.md` §6.
|
||||
*/
|
||||
import {
|
||||
adoptNaturalKeyTwin,
|
||||
storedUnchangedKeys,
|
||||
} from "@/lib/measurements/import-unchanged-rows";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
@@ -856,6 +860,15 @@ export async function streamParseExportXml(
|
||||
else createdByKey.set(key, [created]);
|
||||
}
|
||||
|
||||
// Rows already stored as they are: counted, not written (see module).
|
||||
const unchanged = await storedUnchangedKeys(
|
||||
prisma,
|
||||
userId,
|
||||
chunk,
|
||||
(r, i) =>
|
||||
failedInsertIndexes.has(i) ||
|
||||
createdByKey.has(`${r.type}::${r.externalId}`),
|
||||
);
|
||||
for (let index = 0; index < chunk.length; index += 1) {
|
||||
const row = chunk[index];
|
||||
const stat = bumpStat(row.type);
|
||||
@@ -868,6 +881,11 @@ export async function streamParseExportXml(
|
||||
}
|
||||
|
||||
const key = `${row.type}::${row.externalId}`;
|
||||
if (unchanged.has(key)) {
|
||||
stat.updated += 1;
|
||||
rowsUpserted += 1;
|
||||
continue;
|
||||
}
|
||||
const inserted = createdByKey.get(key)?.shift();
|
||||
if (inserted) {
|
||||
stat.inserted += 1;
|
||||
@@ -906,38 +924,13 @@ export async function streamParseExportXml(
|
||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(err.code === "P2002" || err.code === "P2025")
|
||||
) {
|
||||
try {
|
||||
const twin = await prisma.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: row.type,
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: row.measuredAt,
|
||||
sleepStage: row.sleepStage ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (twin) {
|
||||
await prisma.measurement.update({
|
||||
where: { id: twin.id },
|
||||
data: {
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
externalId: row.externalId,
|
||||
externalSourceVersion: row.externalSourceVersion,
|
||||
deviceType: row.deviceType,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
stat.updated += 1;
|
||||
rowsUpserted += 1;
|
||||
} else {
|
||||
unknown[`${row.type}::natural_key_unresolved`] =
|
||||
(unknown[`${row.type}::natural_key_unresolved`] ?? 0) + 1;
|
||||
}
|
||||
} catch {
|
||||
unknown[`${row.type}::natural_key_rescue_failed`] =
|
||||
(unknown[`${row.type}::natural_key_rescue_failed`] ?? 0) + 1;
|
||||
const adopted = await adoptNaturalKeyTwin(prisma, userId, row);
|
||||
if (adopted === "updated") {
|
||||
stat.updated += 1;
|
||||
rowsUpserted += 1;
|
||||
} else {
|
||||
unknown[`${row.type}::${adopted}`] =
|
||||
(unknown[`${row.type}::${adopted}`] ?? 0) + 1;
|
||||
}
|
||||
} else {
|
||||
unknown[`${row.type}::upsert_failed`] =
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Rows of an Apple Health import chunk that the account already holds with
|
||||
* exactly the values the import's update would write.
|
||||
*
|
||||
* A re-import of an export the account already holds sent one UPDATE per
|
||||
* row the insert skipped, about two million statements for a large export,
|
||||
* nearly all of them writing back what was stored. The import reads the
|
||||
* stored rows of a chunk once and leaves those out; it still counts them as
|
||||
* updated. A key that occurs twice in the chunk is never left out: its rows
|
||||
* are written in order, as before, so the last one stands.
|
||||
*/
|
||||
import type {
|
||||
MeasurementType,
|
||||
PrismaClient,
|
||||
SleepStage,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
export interface ImportUpdateRow {
|
||||
type: MeasurementType;
|
||||
externalId: string;
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
externalSourceVersion: string | null;
|
||||
sleepStage?: SleepStage | null;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
/** `type::externalId` of every row whose update would change nothing. */
|
||||
export async function storedUnchangedKeys(
|
||||
db: Pick<PrismaClient, "measurement">,
|
||||
userId: string,
|
||||
chunk: readonly ImportUpdateRow[],
|
||||
/** Rows the chunk already settled (inserted, or failed to insert). */
|
||||
settled: (row: ImportUpdateRow, index: number) => boolean = () => false,
|
||||
): Promise<Set<string>> {
|
||||
const out = new Set<string>();
|
||||
const rows = chunk.filter((row, index) => !settled(row, index));
|
||||
if (rows.length === 0) return out;
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const key = `${row.type}::${row.externalId}`;
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
const single = rows.filter(
|
||||
(row) => counts.get(`${row.type}::${row.externalId}`) === 1,
|
||||
);
|
||||
if (single.length === 0) return out;
|
||||
const stored = await db.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: { in: single.map((row) => row.externalId) },
|
||||
},
|
||||
select: {
|
||||
type: true,
|
||||
externalId: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
externalSourceVersion: true,
|
||||
sleepStage: true,
|
||||
deviceType: true,
|
||||
},
|
||||
});
|
||||
const byKey = new Map<string, (typeof stored)[number]>(
|
||||
stored.map((row) => [`${row.type}::${row.externalId}`, row]),
|
||||
);
|
||||
for (const row of single) {
|
||||
const key = `${row.type}::${row.externalId}`;
|
||||
const was = byKey.get(key);
|
||||
if (
|
||||
was &&
|
||||
was.value === row.value &&
|
||||
was.measuredAt.getTime() === row.measuredAt.getTime() &&
|
||||
(was.externalSourceVersion ?? null) ===
|
||||
(row.externalSourceVersion ?? null) &&
|
||||
(was.sleepStage ?? null) === (row.sleepStage ?? null) &&
|
||||
(was.deviceType ?? null) === (row.deviceType ?? null)
|
||||
) {
|
||||
out.add(key);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rescue when an imported row's update finds no row at its external id,
|
||||
* or collides on the natural identity: a skipped insert can mean another
|
||||
* external id holds the row's `(type, measuredAt, source, sleepStage)`.
|
||||
* Adopt that row in place, as the old upsert rescue did. Answers what
|
||||
* happened, as the import's per-type tally names it.
|
||||
*/
|
||||
export async function adoptNaturalKeyTwin(
|
||||
db: Pick<PrismaClient, "measurement">,
|
||||
userId: string,
|
||||
row: ImportUpdateRow & { unit: string },
|
||||
): Promise<"updated" | "natural_key_unresolved" | "natural_key_rescue_failed"> {
|
||||
try {
|
||||
const twin = await db.measurement.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
type: row.type,
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: row.measuredAt,
|
||||
sleepStage: row.sleepStage ?? null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!twin) return "natural_key_unresolved";
|
||||
await db.measurement.update({
|
||||
where: { id: twin.id },
|
||||
data: {
|
||||
value: row.value,
|
||||
unit: row.unit,
|
||||
externalId: row.externalId,
|
||||
externalSourceVersion: row.externalSourceVersion,
|
||||
deviceType: row.deviceType,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
return "updated";
|
||||
} catch {
|
||||
return "natural_key_rescue_failed";
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,14 @@
|
||||
*
|
||||
* Algorithm (v1.4.30 — matches the measurement ladder per R-F open
|
||||
* question #4):
|
||||
* 1. Bucket workouts by the rounded start timestamp + sport-type
|
||||
* (`<5-minute slot>:<sport>`). Two HKWorkout entries for the same
|
||||
* logical run start within a minute of each other on both
|
||||
* sources; the rounding gives the cross-source matcher slack
|
||||
* without collapsing distinct back-to-back workouts.
|
||||
* 1. Cluster workouts per user and sport-type by start time: sorted by
|
||||
* start, a workout joins the open cluster when it starts less than
|
||||
* five minutes after the cluster's first workout, and opens a new
|
||||
* one otherwise. Two HKWorkout entries for the same logical run
|
||||
* start within a minute of each other on both sources; the window
|
||||
* gives the cross-source matcher slack without collapsing distinct
|
||||
* back-to-back workouts, and anchoring on the first start keeps a
|
||||
* row of workouts a few minutes apart from chaining into one.
|
||||
* 2. For each bucket, walk the source ladder (`workoutSourceLadder`)
|
||||
* and pick the FIRST source that has any row in that bucket. Keep
|
||||
* every row from the winning source; drop the rest.
|
||||
@@ -115,30 +118,58 @@ function deduplicateWinningRows<T extends WorkoutPickerRow>(rows: T[]): T[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket-slot length. Two workouts whose `startedAt` falls in the
|
||||
* same 5-minute slot AND share the same sportType are treated as the
|
||||
* "same" workout from different sources. Tightens further than the
|
||||
* 1-minute clock skew between HK + Withings would suggest because
|
||||
* iOS posts HK `startDate` rounded to the second but Withings stamps
|
||||
* its `startdate` independently — five minutes gives both heads
|
||||
* enough slack on a heavy-traffic morning sync without crossing the
|
||||
* "two back-to-back runs" boundary (a user does NOT do two ten-minute
|
||||
* runs five minutes apart in practice).
|
||||
* The cross-source duplicate window. Two workouts of the same sport whose
|
||||
* starts lie less than five minutes apart are treated as the "same" workout
|
||||
* from different sources. iOS posts the HealthKit `startDate` rounded to the
|
||||
* second while Withings stamps its `startdate` independently; five minutes
|
||||
* gives both heads enough slack on a heavy-traffic morning sync without
|
||||
* crossing the "two back-to-back runs" boundary (a user does NOT do two
|
||||
* ten-minute runs five minutes apart in practice).
|
||||
*
|
||||
* This used to be a fixed five-minute clock slot (`floor(start / 5 min)`),
|
||||
* so two copies of one session thirty seconds apart that straddled a slot
|
||||
* boundary (08:04:50 and 08:05:20) were never compared. The window is now
|
||||
* measured from the cluster's first start. Where no two workouts of one sport
|
||||
* within the window straddle a slot boundary, the clusters are exactly the
|
||||
* old slots: every pair inside one slot is less than five minutes apart, and
|
||||
* a cluster cannot reach past its slot without such a pair.
|
||||
*/
|
||||
const BUCKET_SLOT_MS = 5 * 60 * 1000;
|
||||
const DUPLICATE_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The cross-source duplicate slot a workout falls in. Exported for the one
|
||||
* reader that has to look past the picked row: the strain engine, whose winner
|
||||
* may carry no heart-rate series while its dropped twin does.
|
||||
* The duplicate cluster of every row, aligned with `rows`. Rows of different
|
||||
* users or sports never share a cluster. Within one (user, sport), rows are
|
||||
* taken in start order (input order on equal starts) and a row joins the open
|
||||
* cluster while it starts less than {@link DUPLICATE_WINDOW_MS} after the
|
||||
* cluster's first row. The key names the cluster by that first row's start,
|
||||
* so it is stable for a given set of rows. Exported for the one reader that
|
||||
* has to look past the picked row: the strain engine, whose winner may carry
|
||||
* no heart-rate series while its dropped twin does.
|
||||
*/
|
||||
export function workoutDuplicateSlotKey(row: WorkoutPickerRow): string {
|
||||
return bucketKey(row);
|
||||
}
|
||||
|
||||
function bucketKey(row: WorkoutPickerRow): string {
|
||||
const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS);
|
||||
return `${row.userId ?? ""}:${slot}:${row.sportType}`;
|
||||
export function workoutDuplicateClusterKeys(
|
||||
rows: readonly WorkoutPickerRow[],
|
||||
): string[] {
|
||||
const groups = new Map<string, number[]>();
|
||||
rows.forEach((row, index) => {
|
||||
const group = `${row.userId ?? ""}:${row.sportType}`;
|
||||
const list = groups.get(group);
|
||||
if (list) list.push(index);
|
||||
else groups.set(group, [index]);
|
||||
});
|
||||
const keys = new Array<string>(rows.length);
|
||||
for (const [group, indexes] of groups) {
|
||||
indexes.sort(
|
||||
(a, b) =>
|
||||
rows[a]!.startedAt.getTime() - rows[b]!.startedAt.getTime() || a - b,
|
||||
);
|
||||
let anchor = Number.NaN;
|
||||
for (const index of indexes) {
|
||||
const start = rows[index]!.startedAt.getTime();
|
||||
if (!(start - anchor < DUPLICATE_WINDOW_MS)) anchor = start;
|
||||
keys[index] = `${group}:${anchor}`;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, unknown> | null {
|
||||
@@ -205,7 +236,7 @@ function enrichFromWhoopTwin<T extends WorkoutPickerRow>(
|
||||
/**
|
||||
* Pair winners and WHOOP donors one-to-one by nearest start time. A single
|
||||
* donor can never enrich two same-source occurrences that happen to share a
|
||||
* fixed slot. Stable input indexes break equal-distance ties.
|
||||
* duplicate cluster. Stable input indexes break equal-distance ties.
|
||||
*/
|
||||
function enrichWinnersFromWhoop<T extends WorkoutPickerRow>(
|
||||
winners: readonly T[],
|
||||
@@ -293,8 +324,9 @@ export function pickCanonicalWorkoutRows<T extends WorkoutPickerRow>(
|
||||
sources: Set<MeasurementSource>;
|
||||
}
|
||||
>();
|
||||
for (const row of rows) {
|
||||
const key = bucketKey(row);
|
||||
const clusterKeys = workoutDuplicateClusterKeys(rows);
|
||||
for (const [index, row] of rows.entries()) {
|
||||
const key = clusterKeys[index]!;
|
||||
const slot = buckets.get(key) ?? { rows: [], sources: new Set() };
|
||||
slot.rows.push(row);
|
||||
slot.sources.add(row.source);
|
||||
|
||||
@@ -64,7 +64,14 @@ function buildIndex(): Map<string, Glp1SideEffectTag> {
|
||||
return index;
|
||||
}
|
||||
|
||||
const TAG_INDEX = buildIndex();
|
||||
let tagIndexCache: ReturnType<typeof tagIndexBuild> | undefined;
|
||||
/** Built on first use, not at module load: it reads every shipped catalog. */
|
||||
function tagIndex(): ReturnType<typeof tagIndexBuild> {
|
||||
return (tagIndexCache ??= tagIndexBuild());
|
||||
}
|
||||
function tagIndexBuild() {
|
||||
return buildIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: the guard suite asserts the index covers every locale and that no
|
||||
@@ -74,7 +81,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
|
||||
string,
|
||||
Glp1SideEffectTag
|
||||
> {
|
||||
return TAG_INDEX;
|
||||
return tagIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,7 +96,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
|
||||
export function resolveGlp1SideEffectTag(
|
||||
raw: string,
|
||||
): Glp1SideEffectTag | null {
|
||||
return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null;
|
||||
return tagIndex().get(normaliseSideEffectTag(raw)) ?? null;
|
||||
}
|
||||
|
||||
/** What one `MoodEntry.tags` column contributed. */
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
*/
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { findDrugIdByBrand } from "@/lib/medications/glp1-knowledge";
|
||||
import { foldLabel } from "@/lib/i18n/localised-label-index";
|
||||
import { foldLabel } from "@/lib/i18n/fold-label";
|
||||
|
||||
/** The medication classes the storyline can reason about (closed set). */
|
||||
export type MedTargetClass = "antihypertensive" | "antidiabetic" | "glp1";
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
* 0343 folded the blob's `coach` key into the switch and removed it, and a
|
||||
* `coach` key that still turns up in the blob is ignored.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { readOperatorRow } from "@/lib/settings/operator-row";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
@@ -84,10 +84,7 @@ export function resolveOperatorAvailability(
|
||||
/** The raw availability blob; a read error reads as "nothing disabled". */
|
||||
async function loadAvailabilityBlob(): Promise<unknown> {
|
||||
try {
|
||||
const settings = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { moduleAvailabilityJson: true },
|
||||
});
|
||||
const settings = await readOperatorRow();
|
||||
return settings?.moduleAvailabilityJson ?? null;
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { InFlightRegistry } from "../in-flight";
|
||||
import {
|
||||
CONTAINER_BACKOFF_MAX_MS,
|
||||
CONTAINER_MEMORY_TASK,
|
||||
ContainerMemoryGuard,
|
||||
memoryPressureActive,
|
||||
readContainerMemory,
|
||||
type ContainerMemoryReading,
|
||||
} from "../container-memory";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function files(map: Record<string, string>) {
|
||||
return (path: string) => {
|
||||
if (!(path in map)) throw new Error(`ENOENT ${path}`);
|
||||
return map[path]!;
|
||||
};
|
||||
}
|
||||
|
||||
describe("readContainerMemory", () => {
|
||||
it("reads cgroup v2 and subtracts the reclaimable page cache", () => {
|
||||
const reading = readContainerMemory(
|
||||
files({
|
||||
"/sys/fs/cgroup/memory.current": `${900 * MB}\n`,
|
||||
"/sys/fs/cgroup/memory.max": `${1536 * MB}\n`,
|
||||
"/sys/fs/cgroup/memory.stat": `anon 1\ninactive_file ${100 * MB}\nactive_file 5\n`,
|
||||
}),
|
||||
);
|
||||
expect(reading).toEqual({ workingSet: 800 * MB, limit: 1536 * MB });
|
||||
});
|
||||
|
||||
it("falls back to cgroup v1", () => {
|
||||
const reading = readContainerMemory(
|
||||
files({
|
||||
"/sys/fs/cgroup/memory/memory.usage_in_bytes": `${500 * MB}`,
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes": `${1024 * MB}`,
|
||||
"/sys/fs/cgroup/memory/memory.stat": `total_inactive_file ${20 * MB}`,
|
||||
}),
|
||||
);
|
||||
expect(reading).toEqual({ workingSet: 480 * MB, limit: 1024 * MB });
|
||||
});
|
||||
|
||||
it("reads nothing without a limit or outside a cgroup", () => {
|
||||
expect(
|
||||
readContainerMemory(
|
||||
files({
|
||||
"/sys/fs/cgroup/memory.current": "123",
|
||||
"/sys/fs/cgroup/memory.max": "max",
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(readContainerMemory(files({}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ContainerMemoryGuard", () => {
|
||||
function guard(readings: Array<ContainerMemoryReading | null>) {
|
||||
let at = 0;
|
||||
let i = 0;
|
||||
const lines: Array<{ task: string; meta: Record<string, unknown> }> = [];
|
||||
const g = new ContainerMemoryGuard({
|
||||
now: () => at,
|
||||
read: () => readings[Math.min(i++, readings.length - 1)] ?? null,
|
||||
registry: new InFlightRegistry(),
|
||||
emit: (task, _level, meta) => lines.push({ task, meta }),
|
||||
figures: () => ({ heap_used_mb: 1 }),
|
||||
});
|
||||
return {
|
||||
tick: (ms = 5_000) => {
|
||||
at += ms;
|
||||
g.check();
|
||||
},
|
||||
lines,
|
||||
};
|
||||
}
|
||||
const reading = (ratio: number) => ({
|
||||
workingSet: Math.round(ratio * 1000 * MB),
|
||||
limit: 1000 * MB,
|
||||
});
|
||||
|
||||
it("reports once at 85 % and holds heavy jobs back while it lasts", () => {
|
||||
const g = guard([reading(0.5), reading(0.9), reading(0.9), reading(0.6)]);
|
||||
g.tick();
|
||||
expect(memoryPressureActive()).toBe(false);
|
||||
g.tick();
|
||||
expect(memoryPressureActive()).toBe(true);
|
||||
expect(g.lines).toHaveLength(1);
|
||||
expect(g.lines[0]!.task).toBe(CONTAINER_MEMORY_TASK);
|
||||
expect(g.lines[0]!.meta).toMatchObject({
|
||||
container_working_set_mb: 900,
|
||||
container_limit_mb: 1000,
|
||||
heap_used_mb: 1,
|
||||
});
|
||||
g.tick();
|
||||
expect(g.lines).toHaveLength(1);
|
||||
g.tick();
|
||||
expect(memoryPressureActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("lets heavy jobs through once the pressure has lasted fifteen minutes", () => {
|
||||
const g = guard([reading(0.9)]);
|
||||
g.tick();
|
||||
expect(memoryPressureActive()).toBe(true);
|
||||
g.tick(CONTAINER_BACKOFF_MAX_MS);
|
||||
expect(memoryPressureActive()).toBe(false);
|
||||
});
|
||||
|
||||
it("never raises the flag outside a cgroup", () => {
|
||||
const g = guard([null]);
|
||||
g.tick();
|
||||
expect(memoryPressureActive()).toBe(false);
|
||||
expect(g.lines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { megabytes } from "@/lib/support/heap-limit";
|
||||
|
||||
import type { InFlightRegistry } from "./in-flight";
|
||||
|
||||
/**
|
||||
* Container memory guard: the process's whole footprint against the cgroup
|
||||
* limit, not just the V8 heap.
|
||||
*
|
||||
* The heap monitor sees the V8 heap only. A large share of this process's
|
||||
* memory lives outside it: the GeoLite2 databases read into Buffers, the
|
||||
* query compiler's WebAssembly memory, canvas and pdf.js for document
|
||||
* thumbnails, zlib streams of a backup. When the cgroup runs out the kernel
|
||||
* kills the process with SIGKILL and nothing is logged at all, however calm
|
||||
* the heap line looked.
|
||||
*
|
||||
* Every five seconds (the heap monitor's tick) the guard reads the cgroup's
|
||||
* working set: `memory.current` minus the reclaimable `inactive_file` page
|
||||
* cache, the figure `docker stats` and the kubelet report. At 85 % of the
|
||||
* limit it writes one `runtime.memory.pressure` line at `warn` with the work
|
||||
* in flight, re-armed once the working set is back under 70 % and thirty
|
||||
* minutes have passed, and raises a flag the heavy job lane reads to send
|
||||
* new heavy jobs back to the queue for a minute instead of starting them
|
||||
* (`memoryPressureActive`). A pressure that does not ease within fifteen
|
||||
* minutes is taken as the container's normal size rather than a peak: the
|
||||
* flag drops, so a container sized at its limit is never starved of its
|
||||
* nightly jobs, and the pressure line has said so.
|
||||
*
|
||||
* Outside a cgroup with a limit (a developer machine, a host without one)
|
||||
* the guard reads nothing and never raises the flag.
|
||||
*/
|
||||
|
||||
export const CONTAINER_MEMORY_TASK = "runtime.memory.pressure";
|
||||
export const CONTAINER_PRESSURE_RATIO = 0.85;
|
||||
export const CONTAINER_REARM_RATIO = 0.7;
|
||||
export const CONTAINER_PRESSURE_COOLDOWN_MS = 30 * 60_000;
|
||||
/** How long new heavy jobs are held back before the pressure counts as normal. */
|
||||
export const CONTAINER_BACKOFF_MAX_MS = 15 * 60_000;
|
||||
|
||||
export interface ContainerMemoryReading {
|
||||
/** Working set: usage minus reclaimable page cache, bytes. */
|
||||
workingSet: number;
|
||||
/** The cgroup's limit, bytes. */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
const UNLIMITED_BYTES = 1024 ** 4;
|
||||
|
||||
function readNumber(raw: string): number | null {
|
||||
const text = raw.trim();
|
||||
if (!/^\d+$/.test(text)) return null;
|
||||
const n = Number(text);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function statField(stat: string, field: string): number {
|
||||
const line = stat.split("\n").find((l) => l.startsWith(`${field} `));
|
||||
return line ? (readNumber(line.slice(field.length + 1)) ?? 0) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cgroup working set and limit, cgroup v2 first, then v1. `null` when
|
||||
* there is no limit or the files cannot be read.
|
||||
*/
|
||||
export function readContainerMemory(
|
||||
read: (path: string) => string = (path) => readFileSync(path, "utf8"),
|
||||
): ContainerMemoryReading | null {
|
||||
const layouts = [
|
||||
{
|
||||
usage: "/sys/fs/cgroup/memory.current",
|
||||
limit: "/sys/fs/cgroup/memory.max",
|
||||
stat: "/sys/fs/cgroup/memory.stat",
|
||||
inactive: "inactive_file",
|
||||
},
|
||||
{
|
||||
usage: "/sys/fs/cgroup/memory/memory.usage_in_bytes",
|
||||
limit: "/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
stat: "/sys/fs/cgroup/memory/memory.stat",
|
||||
inactive: "total_inactive_file",
|
||||
},
|
||||
];
|
||||
for (const layout of layouts) {
|
||||
let usage: number | null;
|
||||
let limit: number | null;
|
||||
try {
|
||||
usage = readNumber(read(layout.usage));
|
||||
limit = readNumber(read(layout.limit));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (usage === null || limit === null) return null;
|
||||
if (limit <= 0 || limit >= UNLIMITED_BYTES) return null;
|
||||
let inactive = 0;
|
||||
try {
|
||||
inactive = statField(read(layout.stat), layout.inactive);
|
||||
} catch {
|
||||
// Without the stat file the usage stands as it is.
|
||||
}
|
||||
return { workingSet: Math.max(0, usage - inactive), limit };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const PRESSURE_FLAG = Symbol.for("healthlog.containerMemory.pressure");
|
||||
|
||||
type FlagSlot = { [PRESSURE_FLAG]?: boolean };
|
||||
|
||||
/**
|
||||
* Whether new heavy jobs should wait: the working set is at or above 85 % of
|
||||
* the limit and has not stayed there for fifteen minutes yet. Kept on
|
||||
* `globalThis` because the lane and the guard can sit in different server
|
||||
* bundles of one process.
|
||||
*/
|
||||
export function memoryPressureActive(): boolean {
|
||||
return (globalThis as FlagSlot)[PRESSURE_FLAG] === true;
|
||||
}
|
||||
|
||||
export interface ContainerMemoryDeps {
|
||||
now: () => number;
|
||||
read: () => ContainerMemoryReading | null;
|
||||
registry: InFlightRegistry;
|
||||
emit: (
|
||||
taskName: string,
|
||||
level: "info" | "warn",
|
||||
meta: Record<string, unknown>,
|
||||
) => void;
|
||||
/** Extra figures for the line (the heap's). */
|
||||
figures: () => Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** The guard's decisions, one `check()` per tick; separate for the tests. */
|
||||
export class ContainerMemoryGuard {
|
||||
private armed = true;
|
||||
private lastReportAt: number | null = null;
|
||||
private pressureSince: number | null = null;
|
||||
|
||||
constructor(private readonly deps: ContainerMemoryDeps) {}
|
||||
|
||||
/** The latest reading, for the heap line. */
|
||||
last: ContainerMemoryReading | null = null;
|
||||
|
||||
check(): void {
|
||||
const { now, read } = this.deps;
|
||||
const at = now();
|
||||
const reading = read();
|
||||
this.last = reading;
|
||||
const slot = globalThis as FlagSlot;
|
||||
if (!reading) {
|
||||
slot[PRESSURE_FLAG] = false;
|
||||
return;
|
||||
}
|
||||
const ratio = reading.workingSet / reading.limit;
|
||||
|
||||
if (ratio >= CONTAINER_PRESSURE_RATIO) {
|
||||
this.pressureSince ??= at;
|
||||
slot[PRESSURE_FLAG] = at - this.pressureSince < CONTAINER_BACKOFF_MAX_MS;
|
||||
} else {
|
||||
this.pressureSince = null;
|
||||
slot[PRESSURE_FLAG] = false;
|
||||
}
|
||||
|
||||
if (
|
||||
!this.armed &&
|
||||
ratio < CONTAINER_REARM_RATIO &&
|
||||
this.lastReportAt !== null &&
|
||||
at - this.lastReportAt >= CONTAINER_PRESSURE_COOLDOWN_MS
|
||||
) {
|
||||
this.armed = true;
|
||||
}
|
||||
if (this.armed && ratio >= CONTAINER_PRESSURE_RATIO) {
|
||||
this.armed = false;
|
||||
this.lastReportAt = at;
|
||||
const inFlight = this.deps.registry.report(at, 50);
|
||||
this.deps.emit(CONTAINER_MEMORY_TASK, "warn", {
|
||||
...this.deps.figures(),
|
||||
container_working_set_mb: megabytes(reading.workingSet),
|
||||
container_limit_mb: megabytes(reading.limit),
|
||||
container_used_ratio: Math.round(ratio * 1000) / 1000,
|
||||
pressure_ratio: CONTAINER_PRESSURE_RATIO,
|
||||
heavy_jobs_held_back_max_minutes: CONTAINER_BACKOFF_MAX_MS / 60_000,
|
||||
in_flight_oldest: inFlight.oldest,
|
||||
in_flight_by_route: inFlight.by_route,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The working set and limit as heap-line fields, or nothing outside a cgroup. */
|
||||
export function containerMemoryFields(
|
||||
reading: ContainerMemoryReading | null,
|
||||
): Record<string, number> {
|
||||
if (!reading) return {};
|
||||
return {
|
||||
container_working_set_mb: megabytes(reading.workingSet),
|
||||
container_limit_mb: megabytes(reading.limit),
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { currentBootStep } from "@/lib/boot/boot-steps";
|
||||
import { startupPhase } from "@/lib/boot/startup-state";
|
||||
import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
import { emitIfSampled } from "@/lib/logging/transports";
|
||||
|
||||
import { inFlightRegistry } from "./in-flight";
|
||||
|
||||
/**
|
||||
* Event-loop lag monitor — the instance-level "was the PROCESS stalled"
|
||||
* signal that per-request durations cannot carry.
|
||||
@@ -24,6 +28,13 @@ import { emitIfSampled } from "@/lib/logging/transports";
|
||||
* same timers phase, and the stall sample can vanish into the window
|
||||
* that was just reset (reproduced under vitest).
|
||||
*
|
||||
* Attribution: a stall line says what the process was doing. It carries the
|
||||
* process uptime, the background role's startup phase, the boot step that
|
||||
* was running (`boot-steps.ts`), and the oldest requests and jobs in flight
|
||||
* with the routes most of them share, the same report the heap pressure line
|
||||
* writes. The monitor is the first thing `register()` starts, so the loading
|
||||
* of the database client and of the worker graph is inside its view.
|
||||
*
|
||||
* The snapshot is published on a `globalThis` slot rather than imported
|
||||
* by the event builder: the builder is bundled into the Edge-runtime
|
||||
* proxy, and reading a global keeps the dependency one-directional
|
||||
@@ -56,19 +67,52 @@ type GlobalSlots = {
|
||||
[STARTED]?: boolean;
|
||||
};
|
||||
|
||||
/** What a stall line says. */
|
||||
export interface EventLoopStall {
|
||||
lag_ms: number;
|
||||
tick_interval_ms: number;
|
||||
uptime_s: number;
|
||||
startup_phase: string | null;
|
||||
boot_step: string | null;
|
||||
in_flight_oldest: unknown[];
|
||||
in_flight_by_route: unknown[];
|
||||
}
|
||||
|
||||
export interface EventLoopLagMonitorOptions {
|
||||
tickIntervalMs?: number;
|
||||
stallThresholdMs?: number;
|
||||
/** Test seam — replaces the wide-event emit. */
|
||||
emitStall?: (stall: { lag_ms: number; tick_interval_ms: number }) => void;
|
||||
emitStall?: (stall: EventLoopStall) => void;
|
||||
}
|
||||
|
||||
function defaultEmitStall(stall: {
|
||||
lag_ms: number;
|
||||
tick_interval_ms: number;
|
||||
}): void {
|
||||
/** The attribution a stall carries; never throws. */
|
||||
export function stallAttribution(
|
||||
now: number = Date.now(),
|
||||
): Omit<EventLoopStall, "lag_ms" | "tick_interval_ms"> {
|
||||
let inFlight: { oldest: unknown[]; by_route: unknown[] } = {
|
||||
oldest: [],
|
||||
by_route: [],
|
||||
};
|
||||
try {
|
||||
inFlight = inFlightRegistry().report(now, 5);
|
||||
} catch {
|
||||
// The report is a garnish.
|
||||
}
|
||||
return {
|
||||
uptime_s: Math.round(process.uptime() * 10) / 10,
|
||||
startup_phase: startupPhase(now),
|
||||
boot_step: currentBootStep(),
|
||||
in_flight_oldest: inFlight.oldest,
|
||||
in_flight_by_route: inFlight.by_route,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultEmitStall(stall: EventLoopStall): void {
|
||||
const evt = new WideEventBuilder("background");
|
||||
evt.setBackground({ task_name: "event_loop_stall", result: stall });
|
||||
evt.setBackground({
|
||||
task_name: "event_loop_stall",
|
||||
result: stall as unknown as Record<string, unknown>,
|
||||
});
|
||||
evt.elevateLevel("warn");
|
||||
evt.finish();
|
||||
emitIfSampled(evt.toJSON());
|
||||
@@ -110,6 +154,7 @@ export function startEventLoopLagMonitor(
|
||||
emitStall({
|
||||
lag_ms: Math.round(lagMs),
|
||||
tick_interval_ms: tickIntervalMs,
|
||||
...stallAttribution(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ import { WideEventBuilder } from "@/lib/logging/event-builder";
|
||||
import { emitIfSampled } from "@/lib/logging/transports";
|
||||
import { megabytes } from "@/lib/support/heap-limit";
|
||||
|
||||
import {
|
||||
ContainerMemoryGuard,
|
||||
containerMemoryFields,
|
||||
readContainerMemory,
|
||||
} from "./container-memory";
|
||||
import { inFlightRegistry, type InFlightRegistry } from "./in-flight";
|
||||
|
||||
/**
|
||||
@@ -351,10 +356,22 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
|
||||
slots[STARTED] = true;
|
||||
|
||||
const registry = overrides.registry ?? inFlightRegistry();
|
||||
// The container guard rides the same tick, and its latest reading rides
|
||||
// every heap line, so the trend shows the cgroup beside the heap.
|
||||
const guard = new ContainerMemoryGuard({
|
||||
now: Date.now,
|
||||
read: () => readContainerMemory(),
|
||||
registry,
|
||||
emit: emitHeapLine,
|
||||
figures: () => ({ ...defaultReadFigures(registry) }),
|
||||
});
|
||||
const monitor = new HeapMonitor({
|
||||
now: Date.now,
|
||||
readHeap: defaultReadHeap,
|
||||
readFigures: () => defaultReadFigures(registry),
|
||||
readFigures: () => ({
|
||||
...defaultReadFigures(registry),
|
||||
...containerMemoryFields(guard.last),
|
||||
}),
|
||||
registry,
|
||||
emit: emitHeapLine,
|
||||
snapshotEnabled: envFlag("HEALTHLOG_HEAP_SNAPSHOT"),
|
||||
@@ -363,6 +380,11 @@ export function startHeapMonitor(overrides: Partial<HeapMonitorDeps> = {}) {
|
||||
});
|
||||
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
guard.check();
|
||||
} catch {
|
||||
// A diagnostic must never take the process down.
|
||||
}
|
||||
try {
|
||||
monitor.check();
|
||||
} catch {
|
||||
|
||||
@@ -115,10 +115,10 @@ describe("the provenance query orders like the canonical pick", () => {
|
||||
`ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`,
|
||||
),
|
||||
);
|
||||
// The shared pick groups to (type, day, source) first and orders the
|
||||
// groups by the same keys, the UTC day named `d`.
|
||||
expect(squash(canonicalMeasurementsCte(rank))).toContain(
|
||||
squash(
|
||||
`ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`,
|
||||
),
|
||||
squash(`ORDER BY "type", d, (${rank}), "source"::text`),
|
||||
);
|
||||
// Values are bound, never spliced.
|
||||
expect(userId).toBe("u1");
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The operator's settings that every app boot reads, in one statement per
|
||||
* request.
|
||||
*
|
||||
* `/api/auth/me` resolved six answers out of the `AppSettings` singleton (the
|
||||
* MFA policy, the Assistant switches, the module availability, the provider
|
||||
* presence, the AI usage bounds and the reasoning controls), each with its own
|
||||
* `SELECT` of a few columns, so one client boot read the same row six times.
|
||||
* Each reader now takes its columns from this one read, memoised for the
|
||||
* request (and read afresh for every call inside a background job, whose one
|
||||
* event spans a whole multi-user pass, like the readers it serves).
|
||||
*
|
||||
* A reader keeps its own error handling: a failed read rejects here, and the
|
||||
* memo forgets a rejected read so the next caller tries again.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
|
||||
export const OPERATOR_ROW_SELECT = {
|
||||
mfaRequired: true,
|
||||
assistantEnabled: true,
|
||||
assistantCoachEnabled: true,
|
||||
assistantBriefingEnabled: true,
|
||||
assistantInsightStatusEnabled: true,
|
||||
assistantDocumentAiEnabled: true,
|
||||
moduleAvailabilityJson: true,
|
||||
adminAiKeyEncrypted: true,
|
||||
adminAiModel: true,
|
||||
adminAiBaseUrl: true,
|
||||
adminCodexConnectionStatus: true,
|
||||
adminCodexAccessTokenEncrypted: true,
|
||||
adminCodexRefreshTokenEncrypted: true,
|
||||
adminCodexAccountIdEncrypted: true,
|
||||
aiUsageMaxLevel: true,
|
||||
aiUsageDefaultLevel: true,
|
||||
aiSyncRegenGapHours: true,
|
||||
aiReasoningEnabled: true,
|
||||
aiReasoningMaxEffort: true,
|
||||
} as const satisfies Prisma.AppSettingsSelect;
|
||||
|
||||
export type OperatorRow = Prisma.AppSettingsGetPayload<{
|
||||
select: typeof OPERATOR_ROW_SELECT;
|
||||
}>;
|
||||
|
||||
export function readOperatorRow(): Promise<OperatorRow | null> {
|
||||
return memoizePerRequest(
|
||||
"operator-settings-row",
|
||||
() =>
|
||||
prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: OPERATOR_ROW_SELECT,
|
||||
}),
|
||||
{ freshInBackground: true },
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user