mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
The daily-mean consolidation and the dense hourly fold cut at `now - threshold` instead of a local day boundary. A day was folded in two runs and the second run overwrote the stored mean with the mean of the later part alone: every mean-type day, and the boundary hour of every heart-rate, HRV and SpO2 day, plus the derived resting figure and the DAY rollup of such a day. The Health Connect importer split its raw window at the same unaligned instant. - Fold up to the start of the account's local day the threshold ends in (`foldBoundary`), for every consolidation pass and the Health Connect importer's raw/hourly split. - A window that already has a live `stats:` row is refolded from every sample of it, live rows plus the fold's own tombstones; with none left the stored mean stays. - `folded_window` refuses a raw sample only when its local day lies before the aligned boundary and a live `stats:` row of its source covers it, also within reach of the sample after a timezone change. - A deletion inside a day the fold had not finished is class B. - Add a one-time, resumable fold repair per account that recomputes each hourly and daily mean from its remaining samples and writes only values that differ, recorded in `measurement_fold_repairs` (migration 0389). - The compaction-tombstone purge waits for the repair per account, and the boot sends no purge run before one account is repaired. - The restore keys live `stats:` ids by source, and writes compaction tombstones back from a file older than the account's repair, resetting the repair.
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
/**
|
|
* The `folded_window` ingest guard and the compaction-tombstone class (v1.42).
|
|
*
|
|
* The dense-intraday consolidation folds the raw samples of a DENSE type into one `stats:` row per local hour once they are older than
|
|
* the retention window, and a MEAN type into one `stats:` row per local day
|
|
* once the consolidation grace has passed. Since v1.42 the folded raw rows are
|
|
* deleted outright rather than left as tombstones, so a client that re-uploads
|
|
* an old sample after losing its anchor would put it back beside the hour or
|
|
* day that already accounts for it. A raw (non-`stats:`) value of such a source
|
|
* and type, old enough to have been folded, is therefore a duplicate when a
|
|
* live `stats:` row covers its hour or day. The batch route and the ZIP import
|
|
* apply the same rule.
|
|
*
|
|
* The same question, asked of a tombstone, names the compaction tombstones
|
|
* ("class A"): a soft-deleted raw sample that was already old enough to fold
|
|
* when it was deleted, and whose hour or day a live `stats:` row covers. The
|
|
* backlog purge, the sync feed and the restore all classify through
|
|
* {@link findCompactionTombstones}, so the three can never disagree about
|
|
* which rows are compaction leftovers and which are a person's deletions.
|
|
*
|
|
* "Folded" means the fold has been able to reach the sample's whole window.
|
|
* The folds stop at the start of the local day the threshold ends in
|
|
* (`foldBoundary` in `consolidation-tz.ts`), so a window counts as folded only
|
|
* when its local day lies entirely before that boundary, read in the account's
|
|
* zone at the instant in question. A sample of a day the fold has not finished
|
|
* is never refused, even if a `stats:` row for the day exists (a day an older
|
|
* release folded in part): refusing it would lose a genuine late sample.
|
|
*
|
|
* Which `stats:` row covers a sample is decided by its externalId, built from
|
|
* the account's current timezone exactly as the fold builds it
|
|
* (`stats:<HK>:<YYYY-MM-DD>T<HH>` for a dense hour, `stats:<HK>:<YYYY-MM-DD>`
|
|
* for a mean day). A dense day still folded at the pre-v1.28.31 daily grain is
|
|
* deliberately NOT covered: the one-shot hourly rebuild reads those days'
|
|
* tombstones, so they are neither refused at ingest by this rule nor purged.
|
|
*
|
|
* An account whose timezone changed after a fold keys its old windows in the
|
|
* other zone, so the externalId alone misses them and an old sample would
|
|
* re-enter beside its own mean. The ingest guard therefore also counts a
|
|
* window as covered when a live `stats:` row of the same source, type and
|
|
* grain sits within reach of the sample: the anchor of the window containing
|
|
* an instant is at most 13 hours from it for a day (local noon, a 25-hour DST
|
|
* day included; the reach allows 14) and 30 minutes for an hour (HH:30), in
|
|
* any zone. Near local midnight the reach of a day also takes in the
|
|
* neighbouring day's row, so a sample there whose own day has no row but
|
|
* whose neighbour has one is refused too; the price of a check that cannot
|
|
* know the zone the fold ran in. The classifier
|
|
* of tombstones does not use that reach; a tombstone it misses stays class B,
|
|
* which keeps a row longer, never loses one.
|
|
*
|
|
* Health Connect folds its raw heart-rate history the same way under its own
|
|
* source (the importer writes samples of the last 90 days raw, and the
|
|
* nightly pass folds them once they age out), so the hour rule covers both
|
|
* sources and a covering row is always looked up under the sample's own
|
|
* source. The day rule stays Apple Health only: the nightly mean
|
|
* consolidation reads no other source.
|
|
*/
|
|
import type {
|
|
MeasurementSource,
|
|
MeasurementType,
|
|
Prisma,
|
|
} from "@/generated/prisma/client";
|
|
import {
|
|
HIGH_FREQUENCY_MEAN_TYPES,
|
|
dailyStatsExternalId,
|
|
hkIdentifierForType,
|
|
} from "@/lib/measurements/apple-health-mapping";
|
|
import { resolveUserTimezone } from "@/lib/measurements/consolidation-base";
|
|
import {
|
|
CONSOLIDATION_GRACE_CUTOFF_HOURS,
|
|
dayKeyForUserTz,
|
|
hourOfDayForUserTz,
|
|
} from "@/lib/measurements/consolidation-tz";
|
|
import {
|
|
DENSE_INTRADAY_FOLD_SOURCES,
|
|
DENSE_INTRADAY_RETENTION_DAYS,
|
|
DENSE_INTRADAY_RETENTION_TYPES,
|
|
hourlyStatsExternalId,
|
|
} from "@/lib/measurements/dense-intraday-retention";
|
|
|
|
/** The per-entry status reason the batch result carries for such a row. */
|
|
export type FoldedWindowReason = "folded_window";
|
|
export const FOLDED_WINDOW_REASON: FoldedWindowReason = "folded_window";
|
|
|
|
const HOUR_MS = 3_600_000;
|
|
|
|
/**
|
|
* Which sources and types fold into which window, and from what age.
|
|
* `reachMs` is how far from a sample the anchor of its window can sit in any
|
|
* zone (see the module comment), for the timezone-change check.
|
|
*/
|
|
export const FOLDED_WINDOW_RULES = [
|
|
{
|
|
window: "hour",
|
|
sources: DENSE_INTRADAY_FOLD_SOURCES,
|
|
types: DENSE_INTRADAY_RETENTION_TYPES,
|
|
foldedAfterMs: DENSE_INTRADAY_RETENTION_DAYS * 24 * HOUR_MS,
|
|
reachMs: HOUR_MS / 2,
|
|
},
|
|
{
|
|
window: "day",
|
|
sources: ["APPLE_HEALTH"] as const,
|
|
types: HIGH_FREQUENCY_MEAN_TYPES,
|
|
foldedAfterMs: CONSOLIDATION_GRACE_CUTOFF_HOURS * HOUR_MS,
|
|
reachMs: 14 * HOUR_MS,
|
|
},
|
|
] as const;
|
|
|
|
type FoldedWindowRule = (typeof FOLDED_WINDOW_RULES)[number];
|
|
|
|
function ruleFor(
|
|
row: Pick<FoldedWindowCandidateRow, "type" | "source">,
|
|
): FoldedWindowRule | null {
|
|
return (
|
|
FOLDED_WINDOW_RULES.find(
|
|
(rule) =>
|
|
(rule.sources as readonly MeasurementSource[]).includes(row.source) &&
|
|
rule.types.has(row.type),
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Whether the fold had reached a row's whole window at instant `at`: its
|
|
* local day (in `tz`) lies entirely before the fold boundary of that instant.
|
|
* Day keys compare as strings. Pure.
|
|
*/
|
|
export function isWindowFoldedAt(
|
|
row: Pick<FoldedWindowCandidateRow, "type" | "source" | "measuredAt">,
|
|
at: Date,
|
|
tz: string,
|
|
): boolean {
|
|
const rule = ruleFor(row);
|
|
if (!rule) return false;
|
|
const firstOpenDay = dayKeyForUserTz(
|
|
new Date(at.getTime() - rule.foldedAfterMs),
|
|
tz,
|
|
);
|
|
return dayKeyForUserTz(row.measuredAt, tz) < firstOpenDay;
|
|
}
|
|
|
|
/** Every type either rule folds. */
|
|
export const FOLDED_TYPES: readonly MeasurementType[] = [
|
|
...DENSE_INTRADAY_RETENTION_TYPES,
|
|
...HIGH_FREQUENCY_MEAN_TYPES,
|
|
];
|
|
|
|
/** The fields of an incoming row the guard reads. */
|
|
export interface FoldedWindowCandidateRow {
|
|
type: MeasurementType;
|
|
source: MeasurementSource;
|
|
externalId: string | null;
|
|
measuredAt: Date;
|
|
}
|
|
|
|
/**
|
|
* Whether a row is old enough, and of the right kind, for its hour or day
|
|
* possibly to have been folded already. A cheap prefilter that needs no
|
|
* timezone: every row {@link isWindowFoldedAt} accepts passes it, because a
|
|
* day before the boundary ended before `now - threshold`. Pure: the aligned
|
|
* check and whether a live `stats:` row actually covers the row are the
|
|
* lookup's questions. `now` is the instant the age is measured at:
|
|
* the request time for an upload, the deletion time for a tombstone.
|
|
*/
|
|
export function isFoldedWindowCandidate(
|
|
row: FoldedWindowCandidateRow,
|
|
now: Date = new Date(),
|
|
): boolean {
|
|
if (row.externalId === null) return false;
|
|
if (row.externalId.startsWith("stats:")) return false;
|
|
// A collision retirement (`reconcile-external-measurement.ts`) parks the
|
|
// losing row at the epoch under a `retired:` id. It is not a folded sample.
|
|
if (row.externalId.startsWith("retired:")) return false;
|
|
const rule = ruleFor(row);
|
|
return (
|
|
rule !== null &&
|
|
now.getTime() - row.measuredAt.getTime() > rule.foldedAfterMs
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The externalId of the `stats:` row the fold writes for a sample's hour
|
|
* (dense types) or day (mean types), in `tz`. Null for a type outside both
|
|
* rules or without an HK identifier. Pure.
|
|
*/
|
|
export function coveringStatsExternalId(
|
|
row: Pick<FoldedWindowCandidateRow, "type" | "measuredAt">,
|
|
tz: string,
|
|
): string | null {
|
|
const hk = hkIdentifierForType(row.type);
|
|
if (!hk) return null;
|
|
const dateKey = dayKeyForUserTz(row.measuredAt, tz);
|
|
if (DENSE_INTRADAY_RETENTION_TYPES.has(row.type)) {
|
|
return hourlyStatsExternalId(
|
|
hk,
|
|
dateKey,
|
|
hourOfDayForUserTz(row.measuredAt, tz),
|
|
);
|
|
}
|
|
if (HIGH_FREQUENCY_MEAN_TYPES.has(row.type)) {
|
|
return dailyStatsExternalId(hk, dateKey);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
type LookupClient = {
|
|
measurement: Pick<Prisma.TransactionClient["measurement"], "findMany">;
|
|
user: Pick<Prisma.TransactionClient["user"], "findUnique">;
|
|
};
|
|
|
|
/** The account's timezone, resolved the way the fold resolves it. */
|
|
export async function loadFoldTimezone(
|
|
client: Pick<LookupClient, "user">,
|
|
userId: string,
|
|
): Promise<string> {
|
|
const user = await client.user.findUnique({
|
|
where: { id: userId },
|
|
select: { timezone: true },
|
|
});
|
|
return resolveUserTimezone(user?.timezone ?? null);
|
|
}
|
|
|
|
/** A live `stats:` row the coverage check reads. */
|
|
type LiveStatsRow = {
|
|
type: MeasurementType;
|
|
externalId: string | null;
|
|
measuredAt: Date;
|
|
};
|
|
|
|
const HOURLY_KEY_RE = /:\d{4}-\d{2}-\d{2}T\d{2}$/;
|
|
const DAILY_KEY_RE = /:\d{4}-\d{2}-\d{2}$/;
|
|
|
|
/** Whether a `stats:` id is the grain the rule folds into, for that type. */
|
|
function isRuleGrain(
|
|
rule: FoldedWindowRule,
|
|
type: MeasurementType,
|
|
externalId: string | null,
|
|
): boolean {
|
|
const hk = hkIdentifierForType(type);
|
|
if (!hk || externalId === null) return false;
|
|
if (!externalId.startsWith(`stats:${hk}:`)) return false;
|
|
return rule.window === "hour"
|
|
? HOURLY_KEY_RE.test(externalId)
|
|
: DAILY_KEY_RE.test(externalId);
|
|
}
|
|
|
|
/** Candidates whose time span per query stays within this many days. */
|
|
const REACH_QUERY_SPAN_MS = 31 * 24 * HOUR_MS;
|
|
|
|
/**
|
|
* Indexes (into `rows`) whose covering `stats:` row is live under the row's
|
|
* own source. One query per source present, each over the unique
|
|
* `(user_id, type, source, external_id)` index for the whole set. With
|
|
* `reach`, a row is also covered by a live `stats:` row of the same source,
|
|
* type and grain anchored within the rule's reach of it (the timezone-change
|
|
* check), read in spans of at most a month so the read stays bounded.
|
|
*/
|
|
async function coveredIndexes(
|
|
client: Pick<LookupClient, "measurement">,
|
|
userId: string,
|
|
tz: string,
|
|
rows: ReadonlyArray<{
|
|
index: number;
|
|
type: MeasurementType;
|
|
source: MeasurementSource;
|
|
measuredAt: Date;
|
|
}>,
|
|
options: { reach: boolean },
|
|
): Promise<Set<number>> {
|
|
const covered = new Set<number>();
|
|
if (rows.length === 0) return covered;
|
|
const bySource = new Map<
|
|
MeasurementSource,
|
|
{
|
|
keyOf: Map<number, string>;
|
|
ids: Set<string>;
|
|
types: Set<MeasurementType>;
|
|
}
|
|
>();
|
|
for (const row of rows) {
|
|
const id = coveringStatsExternalId(row, tz);
|
|
if (!id) continue;
|
|
let group = bySource.get(row.source);
|
|
if (!group) {
|
|
group = { keyOf: new Map(), ids: new Set(), types: new Set() };
|
|
bySource.set(row.source, group);
|
|
}
|
|
group.keyOf.set(row.index, `${row.type}|${id}`);
|
|
group.ids.add(id);
|
|
group.types.add(row.type);
|
|
}
|
|
for (const [source, group] of bySource) {
|
|
const live = await client.measurement.findMany({
|
|
where: {
|
|
userId,
|
|
type: { in: [...group.types] },
|
|
source,
|
|
externalId: { in: [...group.ids] },
|
|
deletedAt: null,
|
|
},
|
|
select: { type: true, externalId: true },
|
|
});
|
|
const liveKeys = new Set(
|
|
live.map((row) => `${row.type}|${row.externalId}`),
|
|
);
|
|
for (const [index, key] of group.keyOf) {
|
|
if (liveKeys.has(key)) covered.add(index);
|
|
}
|
|
}
|
|
if (!options.reach) return covered;
|
|
|
|
// The timezone-change check, for the rows the externalId did not cover.
|
|
const open = rows
|
|
.filter((row) => !covered.has(row.index) && ruleFor(row) !== null)
|
|
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
|
const bySourceOpen = new Map<MeasurementSource, typeof open>();
|
|
for (const row of open) {
|
|
const list = bySourceOpen.get(row.source) ?? [];
|
|
list.push(row);
|
|
bySourceOpen.set(row.source, list);
|
|
}
|
|
const maxReach = Math.max(...FOLDED_WINDOW_RULES.map((r) => r.reachMs));
|
|
for (const [source, list] of bySourceOpen) {
|
|
let start = 0;
|
|
while (start < list.length) {
|
|
const spanStart = list[start].measuredAt.getTime();
|
|
let end = start;
|
|
while (
|
|
end + 1 < list.length &&
|
|
list[end + 1].measuredAt.getTime() - spanStart <= REACH_QUERY_SPAN_MS
|
|
) {
|
|
end += 1;
|
|
}
|
|
const span = list.slice(start, end + 1);
|
|
start = end + 1;
|
|
const types = [...new Set(span.map((row) => row.type))];
|
|
const nearby: LiveStatsRow[] = await client.measurement.findMany({
|
|
where: {
|
|
userId,
|
|
type: { in: types },
|
|
source,
|
|
externalId: { startsWith: "stats:" },
|
|
deletedAt: null,
|
|
measuredAt: {
|
|
gte: new Date(spanStart - maxReach),
|
|
lte: new Date(
|
|
span[span.length - 1].measuredAt.getTime() + maxReach,
|
|
),
|
|
},
|
|
},
|
|
select: { type: true, externalId: true, measuredAt: true },
|
|
orderBy: { measuredAt: "asc" },
|
|
});
|
|
for (const row of span) {
|
|
const rule = ruleFor(row);
|
|
if (!rule) continue;
|
|
const t = row.measuredAt.getTime();
|
|
const hit = nearby.some(
|
|
(stats) =>
|
|
stats.type === row.type &&
|
|
isRuleGrain(rule, row.type, stats.externalId) &&
|
|
Math.abs(stats.measuredAt.getTime() - t) <= rule.reachMs,
|
|
);
|
|
if (hit) covered.add(row.index);
|
|
}
|
|
}
|
|
}
|
|
return covered;
|
|
}
|
|
|
|
/**
|
|
* Indexes (into `rows`) of the incoming rows whose hour or day the fold has
|
|
* finished and a live `stats:` row covers (by its externalId, or within reach
|
|
* after a timezone change). Rows too young to have been folded are never
|
|
* looked up, so a batch of current samples costs no query at all.
|
|
*/
|
|
export async function findFoldedWindowDuplicates(
|
|
client: Pick<LookupClient, "measurement"> &
|
|
Partial<Pick<LookupClient, "user">>,
|
|
userId: string,
|
|
rows: readonly FoldedWindowCandidateRow[],
|
|
options: { now?: Date; tz?: string } = {},
|
|
): Promise<Set<number>> {
|
|
const now = options.now ?? new Date();
|
|
const candidates = rows.flatMap((row, index) =>
|
|
isFoldedWindowCandidate(row, now)
|
|
? [
|
|
{
|
|
index,
|
|
type: row.type,
|
|
source: row.source,
|
|
measuredAt: row.measuredAt,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
if (candidates.length === 0) return new Set();
|
|
let tz = options.tz;
|
|
if (tz === undefined) {
|
|
if (!client.user) throw new Error("folded_window lookup needs a timezone");
|
|
tz = await loadFoldTimezone({ user: client.user }, userId);
|
|
}
|
|
const zone = tz;
|
|
return coveredIndexes(
|
|
client,
|
|
userId,
|
|
zone,
|
|
candidates.filter((row) => isWindowFoldedAt(row, now, zone)),
|
|
{ reach: true },
|
|
);
|
|
}
|
|
|
|
/** A tombstone as the classifier reads it. */
|
|
export interface TombstoneCandidateRow extends FoldedWindowCandidateRow {
|
|
deletedAt: Date | null;
|
|
}
|
|
|
|
/**
|
|
* The covering `stats:` id of a tombstone that has the shape of a compaction
|
|
* tombstone, or null: a soft-deleted raw sample of a folded source and type
|
|
* whose whole window the fold had reached when it was deleted. Whether that
|
|
* `stats:` row is live is the caller's question. Pure; the restore asks it of
|
|
* a backup file's rows.
|
|
*
|
|
* The window must have been complete at deletion, not merely the sample old
|
|
* enough: an older release folded a day in two runs, and a person who
|
|
* deleted a sample of such a half-folded day deleted something the fold had
|
|
* not taken. That deletion is class B.
|
|
*/
|
|
export function compactionTombstoneCoverId(
|
|
row: TombstoneCandidateRow,
|
|
tz: string,
|
|
): string | null {
|
|
if (row.deletedAt === null) return null;
|
|
if (!isFoldedWindowCandidate(row, row.deletedAt)) return null;
|
|
if (!isWindowFoldedAt(row, row.deletedAt, tz)) return null;
|
|
return coveringStatsExternalId(row, tz);
|
|
}
|
|
|
|
/**
|
|
* Indexes (into `rows`) of the compaction tombstones ("class A"): rows that
|
|
* {@link compactionTombstoneCoverId} accepts and whose covering `stats:` row
|
|
* is live under the row's own source. Everything else, in particular a
|
|
* person's own deletions, is class B and keeps the 75-day tombstone retention.
|
|
*/
|
|
export async function findCompactionTombstones(
|
|
client: Pick<LookupClient, "measurement">,
|
|
userId: string,
|
|
tz: string,
|
|
rows: readonly TombstoneCandidateRow[],
|
|
): Promise<Set<number>> {
|
|
const candidates = rows.flatMap((row, index) =>
|
|
compactionTombstoneCoverId(row, tz) !== null
|
|
? [
|
|
{
|
|
index,
|
|
type: row.type,
|
|
source: row.source,
|
|
measuredAt: row.measuredAt,
|
|
},
|
|
]
|
|
: [],
|
|
);
|
|
return coveredIndexes(client, userId, tz, candidates, { reach: false });
|
|
}
|