Files
HealthLog/src/lib/measurements/batch-stored-rows.ts
T
Marc-André Bombeck f5d6710670 perf(analytics): keep closed blocks of past readings cached across syncs
Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.

A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.

The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
2026-10-22 07:24:59 +02:00

178 lines
6.2 KiB
TypeScript

/**
* 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)
);
}