mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Oura, Polar, WHOOP and Strava each keep the transport and endpoints in `client.ts` and gain `wire-types.ts` (the record shapes) and `map.ts` (the pure record-to-row mappers, no I/O). Google Health's 1,600-line client becomes `budget.ts` (slice budget and outcome tally), `request.ts` (error detail, retry, rate limits), `data-points.ts` (the paginated walker) and a small `client.ts` for the profile and roll-up reads; its `export * from "./mappers"` facade is gone and its importers name the module that defines what they use. The code moved verbatim; the mapper goldens written before the move and the provider integration tests pass unchanged.
249 lines
8.9 KiB
TypeScript
249 lines
8.9 KiB
TypeScript
/**
|
|
* #1023 — a full-history Google Health backfill over a dense heart-rate
|
|
* stream, with its live memory measured rather than assumed.
|
|
*
|
|
* What this pins. The backfill read every heart-rate point of the account's
|
|
* history into one array, mapped all of it into a second array, and wrote the
|
|
* lot in one call, so the raw points, the readings and the write's working
|
|
* sets were resident together and all of them scaled with the history. A
|
|
* watch that records a reading a minute passes a million points in two years:
|
|
* reproduced on 1.3 M readings under `--max-old-space-size=1024`, the backfill
|
|
* reaches `FATAL ERROR: Reached heap limit` within seconds, and because the
|
|
* backfill lane shares the app process, the whole instance restarts. The walk
|
|
* now writes each page as it arrives, and the cycle's deferred-rollup ledger
|
|
* keeps one key per touched day instead of one per reading.
|
|
*
|
|
* How it measures. Every heap reading is taken after a forced collection, so
|
|
* what is compared is what the code HOLDS, not how much garbage V8 chose to
|
|
* leave lying about. And what is compared is GROWTH: the heap once a quarter
|
|
* of the pages have been read, against the most it reaches afterwards. The
|
|
* first pages carry a fixed cost that has nothing to do with the stream
|
|
* (modules loaded on first use, the ORM's compiled queries), and that cost is
|
|
* about the size of this fixture, so an absolute figure could not tell a
|
|
* streaming walk from one that holds everything. Growth can: a walk that
|
|
* holds one page stays flat, a walk that holds what it has read climbs with
|
|
* every page.
|
|
*
|
|
* The two halves are the point: the backfill stays inside the budget, and
|
|
* collecting the same collection into one array (what the backfill used to do
|
|
* before anything else) does not. Without the second half the budget could
|
|
* be any number and the first would still pass.
|
|
*
|
|
* Google's API is replaced at the `safeFetch` seam by a generator that builds
|
|
* each page on request, so the fixture itself never sits in memory.
|
|
*/
|
|
import v8 from "node:v8";
|
|
import vm from "node:vm";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
|
|
const USER = "gh-dense-backfill-owner";
|
|
const POINTS = 300_000;
|
|
const NOW = Date.parse("2026-09-27T17:00:00.000Z");
|
|
|
|
/**
|
|
* How much the heap may grow between the first quarter of the walk and its
|
|
* end. Measured, not chosen: the paged walk grows by a few MB on this fixture
|
|
* (noise around a flat line), and collecting the same pages into one array
|
|
* grows by about 80 MB over the same stretch.
|
|
*/
|
|
const GROWTH_BUDGET_BYTES = 24 * 1024 * 1024;
|
|
|
|
const fake = vi.hoisted(() => ({
|
|
total: 0,
|
|
now: 0,
|
|
heartRatePages: 0,
|
|
/** Called as each heart-rate page is requested (the sampler's hook). */
|
|
onPage: null as (() => void) | null,
|
|
}));
|
|
|
|
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
|
|
return {
|
|
...actual,
|
|
// A plain function, not `vi.fn`: a mock records every call's result, and
|
|
// here each result is a whole page's response, so the recorder alone
|
|
// would hold the collection this file exists to keep out of memory.
|
|
safeFetch: async (url: string) => {
|
|
const u = new URL(url);
|
|
let body: unknown = {};
|
|
if (u.pathname.endsWith("/dataTypes/heart-rate/dataPoints")) {
|
|
const pageSize = Number(u.searchParams.get("pageSize") ?? 1000);
|
|
const page = Number(u.searchParams.get("pageToken") ?? 0);
|
|
const end = Math.min(fake.total, (page + 1) * pageSize);
|
|
const dataPoints = [];
|
|
for (let g = page * pageSize; g < end; g++) {
|
|
dataPoints.push({
|
|
name: `users/me/dataTypes/heart-rate/dataPoints/${9_000_000_000_000 + g}`,
|
|
dataSource: {
|
|
recordingMethod: "PASSIVE_MEASUREMENT",
|
|
device: { manufacturer: "Google", displayName: "Watch" },
|
|
platform: "FITBIT",
|
|
},
|
|
heartRate: {
|
|
beatsPerMinute: String(55 + (g % 50)),
|
|
sampleTime: {
|
|
physicalTime: new Date(fake.now - g * 60_000).toISOString(),
|
|
},
|
|
},
|
|
});
|
|
}
|
|
fake.heartRatePages++;
|
|
fake.onPage?.();
|
|
body = {
|
|
dataPoints,
|
|
...(end < fake.total ? { nextPageToken: String(page + 1) } : {}),
|
|
};
|
|
}
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/lib/db-compat", () => ({
|
|
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const forceGc = ((): (() => void) => {
|
|
v8.setFlagsFromString("--expose-gc");
|
|
const gc = vm.runInNewContext("gc") as () => void;
|
|
v8.setFlagsFromString("--no-expose-gc");
|
|
return gc;
|
|
})();
|
|
|
|
function liveHeapBytes(): number {
|
|
forceGc();
|
|
forceGc();
|
|
return process.memoryUsage().heapUsed;
|
|
}
|
|
|
|
const mb = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1);
|
|
|
|
function report(line: string): void {
|
|
process.stderr.write(`[gh-dense-backfill] ${line}\n`);
|
|
}
|
|
|
|
/**
|
|
* Run `fn` while sampling the collected heap every few pages, from inside the
|
|
* page request, so each sample lands between two pages of the work being
|
|
* measured: exactly where anything it holds across a page is visible. (A
|
|
* timer is not enough; a walk whose pages resolve without touching the event
|
|
* loop never lets it fire.) Returns how far the heap rose after the walk
|
|
* passed a quarter of its pages.
|
|
*/
|
|
async function growthPastFirstQuarter<T>(
|
|
fn: () => Promise<T>,
|
|
): Promise<{ result: T; growth: number; samples: number }> {
|
|
const quarter = Math.ceil(POINTS / 1000 / 4);
|
|
const startPages = fake.heartRatePages;
|
|
let anchor: number | null = null;
|
|
let peak = 0;
|
|
let samples = 0;
|
|
const sample = (): void => {
|
|
const live = liveHeapBytes();
|
|
if (anchor === null) {
|
|
if (fake.heartRatePages - startPages >= quarter) anchor = peak = live;
|
|
return;
|
|
}
|
|
samples++;
|
|
peak = Math.max(peak, live);
|
|
};
|
|
fake.onPage = () => {
|
|
if ((fake.heartRatePages - startPages) % 5 === 0) sample();
|
|
};
|
|
try {
|
|
const result = await fn();
|
|
sample();
|
|
return {
|
|
result,
|
|
growth: anchor === null ? Number.NaN : peak - anchor,
|
|
samples,
|
|
};
|
|
} finally {
|
|
fake.onPage = null;
|
|
}
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
const { encrypt } = await import("@/lib/crypto");
|
|
await prisma.user.create({
|
|
data: { id: USER, username: USER, timezone: "Europe/Berlin" },
|
|
});
|
|
await prisma.googleHealthConnection.create({
|
|
data: {
|
|
userId: USER,
|
|
googleUserId: `google-${USER}`,
|
|
accessToken: encrypt("access"),
|
|
refreshToken: encrypt("refresh"),
|
|
tokenExpiresAt: new Date(Date.now() + 86_400_000),
|
|
},
|
|
});
|
|
fake.total = POINTS;
|
|
fake.now = NOW;
|
|
}, 120_000);
|
|
|
|
afterAll(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
});
|
|
|
|
describe("Google Health backfill over a dense heart-rate stream (#1023)", () => {
|
|
it("writes the whole history without growing with it", async () => {
|
|
const { runGoogleHealthBackfillForUser } =
|
|
await import("@/lib/jobs/google-health-backfill");
|
|
|
|
const { result, growth, samples } = await growthPastFirstQuarter(() =>
|
|
runGoogleHealthBackfillForUser(USER),
|
|
);
|
|
report(
|
|
`backfill: imported=${result.imported} growth=${mb(growth)} MB ` +
|
|
`over ${samples} samples (budget ${mb(GROWTH_BUDGET_BYTES)} MB)`,
|
|
);
|
|
|
|
const prisma = getPrismaClient();
|
|
expect(
|
|
await prisma.measurement.count({
|
|
where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" },
|
|
}),
|
|
).toBe(POINTS);
|
|
const connection = await prisma.googleHealthConnection.findUniqueOrThrow({
|
|
where: { userId: USER },
|
|
select: { backfillCompletedAt: true },
|
|
});
|
|
expect(connection.backfillCompletedAt).not.toBeNull();
|
|
// The deferred fold reached the rollup tier for the whole span.
|
|
const days = await prisma.measurementRollup.count({
|
|
where: { userId: USER, type: "PULSE", granularity: "DAY" },
|
|
});
|
|
expect(days).toBeGreaterThanOrEqual(Math.floor(POINTS / 1440));
|
|
|
|
expect(samples).toBeGreaterThan(5);
|
|
expect(growth).toBeLessThan(GROWTH_BUDGET_BYTES);
|
|
}, 900_000);
|
|
|
|
it("the same pages collected into one array outgrow the budget", async () => {
|
|
const { fetchDataPoints } = await import("@/lib/google-health/data-points");
|
|
const { GOOGLE_HEALTH_DATA_TYPES } =
|
|
await import("@/lib/google-health/mappers");
|
|
let held: unknown[] = [];
|
|
const { growth, samples } = await growthPastFirstQuarter(async () => {
|
|
held = await fetchDataPoints(
|
|
GOOGLE_HEALTH_DATA_TYPES.heartRate,
|
|
"access",
|
|
"fetchHeartRate",
|
|
);
|
|
});
|
|
report(
|
|
`collected: points=${held.length} growth=${mb(growth)} MB over ${samples} samples`,
|
|
);
|
|
expect(held).toHaveLength(POINTS);
|
|
expect(growth).toBeGreaterThan(GROWTH_BUDGET_BYTES);
|
|
}, 900_000);
|
|
});
|