mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
The fold repair and the regular folds rewrote an existing daily or hourly stats: mean from the samples still in the table. Fold tombstones are purged 75 days after they were written, so for an older window those samples can be a fragment: a re-upload after an anchor reset, the rows of one later fold run. A mean over them replaced a stored mean that had been taken from the whole day, and on old days it moved a lot. A stored mean is now recomputed only for a day that starts at or after the tombstone horizon (foldConstituentHorizon): every fold tombstone of such a day was written after dayStart + the fold threshold and is still inside the retention, so the live samples plus the fold leftovers are the whole day. That is the last 75 days for daily means and, since dense days fold 90 days late, the 75 days before the raw window for hourly means. A window also needs at least one fold leftover; without one the rows in hand are not known to be all of it. Beyond the horizon: - The repair leaves the day as it is and counts it per type as windowsSkippedBeyondHorizon. - The daily-mean consolidation and the dense hourly fold leave a day or hour that already has a live stats: row exactly as it is, the stored mean and its live samples alike (daysLeftAsStored, hoursLeftAsStored). Up to v1.41 the fold overwrote such a row with the mean of the live rows alone, and the first v1.42 cut with the mean of those plus whatever tombstones were left; both are a mean of part of a day. Dropping the live rows instead would lose samples that may not be in the stored mean, so they stay as synced. They are few, the folded_window guard refuses new ones, and the nightly pass walks over them without writing. A day or hour without a stored mean still folds as before. Inside the horizon, a pass that recomputes a stored mean soft-deletes the live samples it took in as fold leftovers instead of deleting them, so a second pass computes the same mean from the same samples. Before, a fold that deleted them left the repair a fragment, and the repair then rewrote the mean from the tombstones alone. The repair, the mean consolidation and the dense fold now take a per-account advisory lock after the restore lock in every day transaction, and the repair reads the day's samples inside it, so the two never compute the same window from two views of its samples or write the same row at once. The repair also stops at the fold boundary: a day the fold has not finished is the fold's to complete.
184 lines
8.2 KiB
TypeScript
184 lines
8.2 KiB
TypeScript
/**
|
|
* Every place that writes `Measurement` rows either runs the shared
|
|
* post-write tail or says here why it does not.
|
|
*
|
|
* Two readers hang off every measurement write: the rollup buckets the write
|
|
* dirtied, and the cached status assessments of the types it touched. The
|
|
* shared tail (`afterMeasurementMutation`) runs both. Several write paths
|
|
* refreshed the rollups by hand and never re-warmed the assessments (the MCP
|
|
* writes, the Telegram reply, both importers), so a reading logged there kept
|
|
* the day's old assessment until the nightly pass. This guard freezes the set
|
|
* of write sites: a new one fails until it is listed with its tail.
|
|
*
|
|
* A tripwire, not a proof: it cannot tell whether a listed reason is still
|
|
* true, only that the set did not change without someone editing this file.
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { walkSourceFiles } from "./helpers/source-files";
|
|
|
|
const SRC = join(process.cwd(), "src");
|
|
|
|
/**
|
|
* `prisma.measurement.create(`, `tx.measurement.deleteMany(`, … Whitespace
|
|
* and line breaks between the parts are tolerated so a formatter reflow does
|
|
* not hide a site.
|
|
*/
|
|
const WRITE_RE =
|
|
/\.\s*measurement\s*\.\s*(?:create|createMany|createManyAndReturn|upsert|update|updateMany|delete|deleteMany)\s*\(/g;
|
|
|
|
/** Runs `afterMeasurementMutation` on the rows it wrote. */
|
|
const TAIL = "runs afterMeasurementMutation";
|
|
|
|
/**
|
|
* Every file with a measurement write, and how it keeps the two readers
|
|
* current. A file listed here must still contain a write (a stale entry
|
|
* fails too).
|
|
*/
|
|
const WRITE_SITES: Record<string, string> = {
|
|
"app/api/import/csv/route.ts": TAIL,
|
|
"app/api/import/route.ts": TAIL,
|
|
"app/api/measurements/[id]/route.ts": TAIL,
|
|
"app/api/measurements/bulk-delete/route.ts": TAIL,
|
|
"app/api/measurements/by-external-ids/route.ts": TAIL,
|
|
"app/api/measurements/restore/route.ts": TAIL,
|
|
"app/api/measurements/route.ts": TAIL,
|
|
"app/api/mental-health/assessments/route.ts": TAIL,
|
|
"lib/insights/score-row.ts": TAIL,
|
|
"lib/mcp/writes.ts": TAIL,
|
|
"lib/measurements/create-from-telegram.ts": TAIL,
|
|
"lib/whoop/sync-body.ts": TAIL,
|
|
"lib/whoop/webhook-handler.ts": TAIL,
|
|
// Integration syncs: each collects the (type, day) pairs it touched and
|
|
// runs both legs itself at the end of the sync.
|
|
"lib/fitbit/sync-core.ts":
|
|
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
|
"lib/google-health/sync-core.ts":
|
|
"refolds the touched days and re-warms the touched types at the end of the upsert",
|
|
"lib/nightscout/sync.ts":
|
|
"refolds the touched days and re-warms the touched types at the end of the sync",
|
|
"lib/withings/sync.ts":
|
|
"refolds the touched days and re-warms the touched types at the end of the sync",
|
|
"lib/withings/sync-activity.ts":
|
|
"refolds the touched days (and the day a moved row left) and re-warms the touched types",
|
|
"lib/withings/sync-ecg.ts":
|
|
"refolds the touched days and re-warms the touched types at the end of the sync",
|
|
// The reconcile primitive writes one row inside the caller's transaction;
|
|
// its callers (Polar, Oura, WHOOP, the Apple export import) collect the
|
|
// verdicts' dirty identities and run the tail once per batch.
|
|
"lib/measurements/reconcile-external-measurement.ts":
|
|
"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/import/health-connect/import.ts":
|
|
"the Health Connect import worker refolds the import's span and re-warms every imported type",
|
|
"lib/export/restore-backup.ts":
|
|
"a restore replaces the whole record: it refolds the whole window and drops every per-user cache",
|
|
// Value-preserving reshapes: the same readings folded into daily or hourly
|
|
// rows. The rollups are refolded; the figures an assessment reads do not
|
|
// change, so re-warming would only re-bill the same text.
|
|
"lib/measurements/consolidate-daily-mean.ts":
|
|
"value-preserving consolidation; refolds the touched days",
|
|
"lib/measurements/consolidate-legacy-steps.ts":
|
|
"value-preserving consolidation; refolds the touched days",
|
|
"lib/measurements/drain-per-sample-cumulative.ts":
|
|
"value-preserving consolidation; refolds the touched days",
|
|
"lib/measurements/dense-intraday-hourly-rebuild.ts":
|
|
"value-preserving hourly fold; refolds the touched spans",
|
|
"lib/measurements/dense-intraday-retention.ts":
|
|
"value-preserving retention fold; refolds the touched days",
|
|
"lib/jobs/sleep-timeline-backfill.ts":
|
|
"one-shot re-stamp of stored sleep rows onto their timeline; refolds the touched days",
|
|
"lib/jobs/step-consolidation-repair.ts":
|
|
"one-shot restore of step rows an old consolidation removed; refolds the touched days",
|
|
"lib/sleep/sweep-stale-segments.ts":
|
|
"tombstones re-scored sleep segments inside a sync; the calling sync refolds the night",
|
|
// Writes that do not change a reading.
|
|
"lib/jobs/note-encryption-backfill.ts":
|
|
"encrypts the note column in place; no value, instant or type changes",
|
|
"lib/jobs/measurement-tombstone-cleanup.ts":
|
|
"hard-deletes rows that were already soft-deleted and read nowhere",
|
|
"lib/jobs/compaction-tombstone-purge.ts":
|
|
"hard-deletes compaction tombstones, rows already soft-deleted and read nowhere",
|
|
"lib/jobs/measurement-fold-repair.ts":
|
|
"corrects the value of a folded stats: mean in place, takes the window's live samples into it, and refolds the DAY rollup of a touched daily mean itself; no instant or type changes",
|
|
"lib/measurements/fold-constituents.ts":
|
|
"soft-deletes the samples a fold or the fold repair has just taken into a recomputed stats: mean, which accounts for them from then on",
|
|
};
|
|
|
|
function sourceFiles(): string[] {
|
|
return walkSourceFiles(SRC, { floor: 3000 })
|
|
.filter((p) => !p.startsWith("generated/"))
|
|
.filter((p) => !p.includes("__tests__"))
|
|
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
|
.sort();
|
|
}
|
|
|
|
function writeCounts(): Map<string, number> {
|
|
const out = new Map<string, number>();
|
|
for (const rel of sourceFiles()) {
|
|
const matches = readFileSync(join(SRC, rel), "utf8").match(WRITE_RE);
|
|
if (matches && matches.length > 0) out.set(rel, matches.length);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("measurement write tail", () => {
|
|
const counts = writeCounts();
|
|
|
|
it("finds the write sites at all", () => {
|
|
// An empty match set would agree with any allowlist.
|
|
expect(counts.size).toBeGreaterThanOrEqual(30);
|
|
});
|
|
|
|
it("lists every file that writes measurements", () => {
|
|
const unlisted = [...counts.keys()].filter((f) => !(f in WRITE_SITES));
|
|
expect(
|
|
unlisted,
|
|
"a new measurement write site: run afterMeasurementMutation on the rows it writes, or list it here with the reason it does not",
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("has no stale entries", () => {
|
|
const stale = Object.keys(WRITE_SITES).filter((f) => !counts.has(f));
|
|
expect(stale).toEqual([]);
|
|
});
|
|
|
|
it("the files that claim the shared tail call it", () => {
|
|
const missing = Object.entries(WRITE_SITES)
|
|
.filter(([, reason]) => reason === TAIL)
|
|
.map(([file]) => file)
|
|
.filter(
|
|
(file) =>
|
|
!/afterMeasurementMutation\s*\(/.test(
|
|
readFileSync(join(SRC, file), "utf8"),
|
|
),
|
|
);
|
|
expect(missing).toEqual([]);
|
|
});
|
|
|
|
it("the syncs that run both legs by hand call both", () => {
|
|
const handRolled = Object.entries(WRITE_SITES)
|
|
.filter(([, reason]) => reason.includes("re-warms"))
|
|
.map(([file]) => file)
|
|
// The two export importers leave both legs to their workers, which
|
|
// run them once over the whole import.
|
|
.filter(
|
|
(file) =>
|
|
file !== "lib/measurements/import-apple-health-export.ts" &&
|
|
file !== "lib/import/health-connect/import.ts",
|
|
);
|
|
expect(handRolled.length).toBeGreaterThan(0);
|
|
const missing = handRolled.filter((file) => {
|
|
const src = readFileSync(join(SRC, file), "utf8");
|
|
return !(
|
|
/recomputeBucketsForMeasurement|afterMeasurementMutation/.test(src) &&
|
|
/invalidateStatusInsightsForTypes|afterMeasurementMutation/.test(src)
|
|
);
|
|
});
|
|
expect(missing).toEqual([]);
|
|
});
|
|
});
|