Merge branch 'v1424/gh' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 11:45:45 +02:00
commit b96c114a45
23 files changed
+2046 -168

No files matched your search

+74 -9
View File
@@ -122,22 +122,87 @@ The Google Cloud client id/secret are per-user (Settings), not env vars.
## Backfill, the hourly sync and Google's rate limit
Only one sync runs per account at a time. While the history backfill that
follows a connect is running, the hourly sync leaves that account alone, and
**Sync now** reports that a sync is already in progress and shows its progress
instead of starting a second one.
Only one sync runs per account at a time. **Sync now** reports that a sync is
already in progress while the history backfill that follows a connect is
running, and shows its progress instead of starting a second one.
The backfill reads the account's whole history, and for a watch that records
heart rate every minute that is a thousand-odd pages per year. It runs in
slices: a slice reads for up to five minutes or 500 pages, writes what it
read, notes how far it got and lets go of the account. The next slice starts
a minute later where the last one stopped. Two kinds of note are kept: a data
type read to the end is not read again, and a data type read page by page
(heart rate, weight, body fat, blood glucose, core temperature) keeps the
time of the oldest reading it has written, so the next slice asks Google only
for what is older. A restart or an update in the middle loses at most the page
that was being read.
The hourly sync waits for the slice that is running (up to eight minutes)
instead of skipping the account, so recent data keeps arriving while an old
history is still coming in.
Google limits how many requests one account may make per minute. When a sync
reaches that limit, Google answers `429`, and HealthLog waits and repeats the
request; the connection is never marked as needing a reconnect because of it.
Access tokens last an hour, and a long backfill renews its token as it goes.
A backfill that stops part-way (a restart, the job's time budget) picks up
with the data types it has not finished yet on its next attempt.
reaches that limit Google answers `429`, and HealthLog waits and repeats the
request. If Google asks for a longer wait than a minute, the slice ends there
and the next one starts five minutes later. A server error (`500`, `502`,
`503`, `504`), a request that times out and a dropped connection are repeated
three times before they count as a failure. None of these mark the connection
as needing a reconnect. Access tokens last an hour, and a long backfill renews
its token as it goes.
A slice that moved nothing forward (no data type finished, no reading older
than before) is a real failure: the job fails with the reason, pg-boss retries
it a few times, and after that the next restart offers the backfill again.
Everything already written stays.
If an older version left your Google Health card asking for a reconnect after
a backfill, the first sync after updating checks the stored grant and resumes
on its own when the grant is still valid.
### Reading the backfill in the logs
Every slice writes one log line when it ends, with
`"background":{"task_name":"job.integration_backfill"}`. The parts worth
reading:
- `meta.kind` is `google-health-backfill`, `meta.slice` the slice number
(0 for the first), `meta.job_id` the pg-boss job.
- `external_calls` lists the requests the slice made to Google, one entry per
request: `method` names the data type and page (`fetchHeartRate(page=212)`),
`status` the HTTP status, `duration_ms` how long it took. A request that was
repeated carries a `note` (`rate_limited_retrying_in_ms=4000`,
`server_error_retrying_in_ms=2000`, `no_response_retrying_in_ms=2000`,
`token_rejected_retrying_with_fresh_token`); one that failed for good
carries an `error`. Successful requests are listed up to 60 per line and
everything up to 200; `external_calls_omitted` counts the rest.
- `meta["googleHealth.backfill.slice"]` sums the slice up:
- `pages`, `elapsedMs` and `imported`: what the slice read, how long it
took, how many readings it wrote.
- `stopReason`: why it ended early. `time_budget` and `page_budget` are the
normal end of a slice, `rate_limited` means Google would not take more
requests for now, `job_budget` that the job itself ran out of time.
`null` means the slice read everything it started.
- `collectionsDone` of `collectionsTotal`: data types read to the end so
far.
- `collections`: one entry per data type the slice touched, with its
`status` (`done`, `stopped` short on a budget or a rate limit,
`deferred` to the next slice, or `failed`), its `pages`, and for the
page-by-page types
`edgeFrom` and `edgeTo`, the oldest reading written before and after the
slice.
- `edges`: where every unfinished page-by-page data type stands now.
- `action.name` is `google_health.backfill.continued` for a slice that made
progress and queued the next one, `google_health.backfill.complete` for the
one that finished the history, and `google_health.backfill.stuck` for one
that moved nothing. A stuck slice also fails the job with a message that
names each unfinished data type and its status, for example
`no progress in slice 4, stopped on rate_limited; fetchHeartRate:stopped`.
While a slice runs, the usual `job.progress` line for the
`integration-backfill-admission` queue reports
`google_health_backfill_slice`, `google_health_backfill_pages` and
`google_health_backfill_collections_done` every two minutes.
## The 7-day re-consent caveat
While the OAuth consent screen stays in **Testing** publishing mode, Google
+4 -2
View File
@@ -4411,8 +4411,10 @@ model GoogleHealthConnection {
/// pre-fix externalId (a re-scored night minted parallel copies). Null =
/// repair pending (or the connection predates the fix).
sleepRepairedAt DateTime? @map("sleep_repaired_at")
/// The collections the in-flight history backfill has already walked to
/// the end (`{ done: string[] }`), so a retry resumes with the rest
/// How far the in-flight history backfill has got
/// (`{ done: string[], cursors: { [collection]: { at, order, pages } } }`):
/// the collections it walked to the end, and for each collection read page
/// by page the edge it has written up to, so the next slice resumes there
/// instead of starting over. Null once the backfill completes, and reset
/// on every reconnect.
backfillProgress Json? @map("backfill_progress")
@@ -20,9 +20,17 @@ vi.mock("@/lib/logging/context", () => ({
}));
import {
readGoogleHealthBackfillCursors,
recordGoogleHealthBackfillPage,
runGoogleHealthCollection,
runWithGoogleHealthBackfillResume,
type GoogleHealthBackfillCursor,
} from "../sync-core";
import {
googleHealthSliceBudget,
runWithGoogleHealthClientOutcome,
runWithGoogleHealthStop,
} from "../client";
beforeEach(() => {
vi.clearAllMocks();
@@ -41,7 +49,9 @@ describe("runGoogleHealthCollection under a backfill", () => {
expect([...done]).toEqual(["fetchSteps", "sleep"]);
expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({
where: { userId: "u1" },
data: { backfillProgress: { done: ["fetchSteps", "sleep"] } },
data: {
backfillProgress: { done: ["fetchSteps", "sleep"], cursors: {} },
},
});
});
@@ -63,3 +73,183 @@ describe("runGoogleHealthCollection under a backfill", () => {
);
});
});
/**
* #1220 — a collection read page by page keeps its place inside the
* collection, not only once it is finished. A heart-rate history of years
* that one attempt could not finish used to start over from its newest page
* on every attempt.
*/
describe("the resume edge of a page-by-page collection", () => {
const at = (iso: string) => new Date(iso);
it("moves the edge to the oldest sample of each page and persists it", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const cursors = new Map<string, GoogleHealthBackfillCursor>();
const { advanced } = await runWithGoogleHealthBackfillResume(
"u1",
new Set(["sleep"]),
async () => {
await recordGoogleHealthBackfillPage("fetchHeartRate", {
first: at("2026-10-01T12:00:00Z"),
last: at("2026-10-01T00:00:00Z"),
});
await recordGoogleHealthBackfillPage("fetchHeartRate", {
first: at("2026-09-30T23:59:00Z"),
last: at("2026-09-30T08:00:00Z"),
});
},
cursors,
);
expect(cursors.get("fetchHeartRate")).toEqual({
at: "2026-09-30T08:00:00.000Z",
order: "desc",
pages: 2,
});
expect(advanced.get("fetchHeartRate")).toBeNull();
const last = prismaMock.googleHealthConnection.update.mock.calls.at(
-1,
)![0] as { data: { backfillProgress: unknown } };
expect(last.data.backfillProgress).toEqual({
done: ["sleep"],
cursors: {
fetchHeartRate: {
at: "2026-09-30T08:00:00.000Z",
order: "desc",
pages: 2,
},
},
});
// And it reads back the way it was written.
expect(
readGoogleHealthBackfillCursors(last.data.backfillProgress).get(
"fetchHeartRate",
),
).toEqual(cursors.get("fetchHeartRate"));
});
it("follows a walk that runs oldest first", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const cursors = new Map<string, GoogleHealthBackfillCursor>();
await runWithGoogleHealthBackfillResume(
"u1",
new Set(),
() =>
recordGoogleHealthBackfillPage("fetchWeight", {
first: at("2024-01-01T00:00:00Z"),
last: at("2024-03-01T00:00:00Z"),
}),
cursors,
);
expect(cursors.get("fetchWeight")).toMatchObject({
at: "2024-03-01T00:00:00.000Z",
order: "asc",
});
});
it("does not count a page that moved nothing as progress", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const cursors = new Map<string, GoogleHealthBackfillCursor>([
[
"fetchHeartRate",
{ at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 },
],
]);
const { advanced } = await runWithGoogleHealthBackfillResume(
"u1",
new Set(),
() =>
// The edge sample itself, read again: no further than before.
recordGoogleHealthBackfillPage("fetchHeartRate", {
first: at("2026-01-01T00:00:00Z"),
last: at("2026-01-01T00:00:00Z"),
}),
cursors,
);
expect(advanced.size).toBe(0);
expect(cursors.get("fetchHeartRate")).toMatchObject({
at: "2026-01-01T00:00:00.000Z",
pages: 8,
});
});
it("counts no progress when the edge cannot be written", async () => {
prismaMock.googleHealthConnection.update.mockRejectedValue(
new Error("connection reset"),
);
const cursors = new Map<string, GoogleHealthBackfillCursor>();
const { advanced } = await runWithGoogleHealthBackfillResume(
"u1",
new Set(),
() =>
recordGoogleHealthBackfillPage("fetchHeartRate", {
first: at("2026-10-01T12:00:00Z"),
last: at("2026-10-01T00:00:00Z"),
}),
cursors,
);
expect(advanced.size).toBe(0);
expect(cursors.size).toBe(0);
expect(warnings.some((w) => w.includes("progress write failed"))).toBe(
true,
);
});
it("clears a finished collection's edge with the same write that marks it done", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const cursors = new Map<string, GoogleHealthBackfillCursor>([
[
"fetchHeartRate",
{ at: "2026-01-01T00:00:00.000Z", order: "desc", pages: 7 },
],
]);
const done = new Set<string>();
await runWithGoogleHealthBackfillResume(
"u1",
done,
() => runGoogleHealthCollection("fetchHeartRate", async () => 1),
cursors,
);
expect([...done]).toEqual(["fetchHeartRate"]);
expect(cursors.size).toBe(0);
expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({
where: { userId: "u1" },
data: { backfillProgress: { done: ["fetchHeartRate"], cursors: {} } },
});
});
it("leaves a collection for the next slice once the slice has no budget left", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const walk = vi.fn(async () => 5);
const budget = googleHealthSliceBudget(0, 100);
const { result } = await runWithGoogleHealthClientOutcome(() =>
runWithGoogleHealthBackfillResume("u1", new Set(), () =>
runWithGoogleHealthStop(
() => false,
() => runGoogleHealthCollection("fetchHeartRate", walk),
budget,
),
),
);
expect(walk).not.toHaveBeenCalled();
expect(result.collections).toEqual([
{ key: "fetchHeartRate", status: "deferred", pages: 0, durationMs: 0 },
]);
expect(budget.stopReason).toBe("time_budget");
});
it("marks the cycle incomplete when it leaves a collection for later", async () => {
const { outcome } = await runWithGoogleHealthClientOutcome(() =>
runWithGoogleHealthBackfillResume("u1", new Set(), () =>
runWithGoogleHealthStop(
() => false,
() => runGoogleHealthCollection("fetchWeight", async () => 0),
googleHealthSliceBudget(0, 100),
),
),
);
expect(outcome.truncated).toBe(true);
});
});
@@ -208,7 +208,8 @@ describe("fetchDailyRollUp — chunk walk + conservative fallback", () => {
});
it("propagates a non-400 first-request failure without a fallback walk", async () => {
safeFetchMock.mockResolvedValueOnce(jsonResponse(500, {}));
// A 403 is neither the fallback's 400 nor a server error worth repeating.
safeFetchMock.mockResolvedValueOnce(jsonResponse(403, {}));
await expect(
fetchDailyRollUp(GOOGLE_HEALTH_DATA_TYPES.steps, "token", "fetchSteps", {
start: new Date("2026-06-01T00:00:00Z"),
@@ -32,6 +32,10 @@ vi.mock("../sync-core", () => ({
(_userId: string, info: { accessToken: string }) => async () =>
info.accessToken,
runGoogleHealthCollection: (_key: string, fn: () => Promise<number>) => fn(),
// Outside a backfill: no resume edge, nothing to record.
googleHealthBackfillCursor: () => null,
googleHealthHardFailureCount: () => 0,
recordGoogleHealthBackfillPage: vi.fn(async () => {}),
}));
vi.mock("@/lib/db", () => ({
@@ -58,6 +58,10 @@ vi.mock("../sync-core", () => ({
(_userId: string, info: { accessToken: string }) => async () =>
info.accessToken,
runGoogleHealthCollection: (_key: string, fn: () => Promise<number>) => fn(),
// Outside a backfill: no resume edge, nothing to record.
googleHealthBackfillCursor: () => null,
googleHealthHardFailureCount: () => 0,
recordGoogleHealthBackfillPage: vi.fn(async () => {}),
upsertGoogleHealthMeasurements: vi.fn(async () => ({
imported: 0,
touched: [],
@@ -0,0 +1,192 @@
/**
* #1220 — the list walk under a backfill slice.
*
* - A server error or a request that got no answer is repeated a few times
* instead of ending a walk of thousands of pages.
* - A resumable walk asks only for the samples beyond the edge an earlier
* attempt reached.
* - Inside a slice, a resumable walk stops on the slice's budget and on a
* rate limit it cannot wait out, and reports itself truncated rather than
* failing; a walk that is not resumable runs on under the job's limit.
*/
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const { safeFetchMock } = vi.hoisted(() => ({ safeFetchMock: vi.fn() }));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return { ...actual, safeFetch: safeFetchMock };
});
vi.mock("@/lib/logging/context", () => ({
annotate: () => {},
getEvent: () => null,
}));
import { SafeFetchError } from "@/lib/safe-fetch";
import {
GOOGLE_HEALTH_DATA_TYPES,
GOOGLE_HEALTH_TRANSIENT_RETRY,
forEachDataPointPage,
googleHealthSliceBudget,
runWithGoogleHealthClientOutcome,
runWithGoogleHealthStop,
} from "../client";
import { GoogleHealthApiError } from "../response-classifier";
function page(n: number, next: boolean, status = 200, headers = {}): Response {
return new Response(
JSON.stringify(
status === 200
? {
dataPoints: [{ n }],
...(next ? { nextPageToken: String(n + 1) } : {}),
}
: { error: { code: status } },
),
{ status, headers: { "content-type": "application/json", ...headers } },
);
}
const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs;
beforeEach(() => {
safeFetchMock.mockReset();
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0;
});
afterAll(() => {
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay;
});
const walk = (query: Parameters<typeof forEachDataPointPage>[3] = {}) =>
runWithGoogleHealthClientOutcome(() =>
forEachDataPointPage(
GOOGLE_HEALTH_DATA_TYPES.heartRate,
"token",
"fetchHeartRate",
query,
() => {},
),
);
describe("a server error or a lost request mid-walk", () => {
it("is repeated and the walk goes on", async () => {
safeFetchMock
.mockResolvedValueOnce(page(0, true))
.mockResolvedValueOnce(page(0, false, 503))
.mockRejectedValueOnce(new SafeFetchError("aborted", "timeout"))
.mockResolvedValueOnce(page(1, false));
const { outcome } = await walk();
expect(safeFetchMock).toHaveBeenCalledTimes(4);
expect(outcome).toMatchObject({ pages: 2, truncated: false });
});
it("fails the walk once the retries are spent", async () => {
safeFetchMock.mockResolvedValue(page(0, false, 500));
await expect(walk()).rejects.toBeInstanceOf(GoogleHealthApiError);
expect(safeFetchMock).toHaveBeenCalledTimes(
GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries + 1,
);
});
it("does not repeat an error that is not about the server", async () => {
safeFetchMock.mockRejectedValue(new TypeError("bad request init"));
await expect(walk()).rejects.toBeInstanceOf(TypeError);
expect(safeFetchMock).toHaveBeenCalledTimes(1);
});
});
describe("a resumable walk", () => {
it("asks only for the samples at or beyond the recorded edge", async () => {
safeFetchMock.mockResolvedValue(page(0, false));
await walk({
resumable: true,
edge: { at: new Date("2025-03-01T08:00:00Z"), order: "desc" },
});
const url = new URL(safeFetchMock.mock.calls[0]![0] as string);
expect(url.searchParams.get("filter")).toBe(
'heart_rate.sample_time.physical_time <= "2025-03-01T08:00:00.000Z"',
);
});
it("stops on the slice's page budget and reports itself truncated", async () => {
safeFetchMock.mockImplementation(async (url: string) =>
page(Number(new URL(url).searchParams.get("pageToken") ?? 0), true),
);
const budget = googleHealthSliceBudget(60_000, 3);
const { outcome } = await runWithGoogleHealthStop(
() => false,
() => walk({ resumable: true }),
budget,
);
expect(safeFetchMock).toHaveBeenCalledTimes(3);
expect(outcome.truncated).toBe(true);
expect(budget.stopReason).toBe("page_budget");
});
it("starts the slice's clock at the first walk, not when the slice was set up", async () => {
safeFetchMock.mockResolvedValue(page(0, false));
let clock = 0;
const budget = googleHealthSliceBudget(1_000, 500, () => clock);
// The slice waited ten minutes for the account's lock before walking.
clock = 600_000;
const { outcome } = await runWithGoogleHealthStop(
() => false,
() => walk({ resumable: true }),
budget,
);
expect(outcome).toMatchObject({ pages: 1, truncated: false });
expect(budget.deadline).toBe(601_000);
});
it("ends the slice on a rate limit it cannot wait out, without failing", async () => {
safeFetchMock
.mockResolvedValueOnce(page(0, true))
.mockResolvedValueOnce(page(1, true, 429, { "retry-after": "3600" }));
const budget = googleHealthSliceBudget(60_000, 500);
const { outcome } = await runWithGoogleHealthStop(
() => false,
() => walk({ resumable: true }),
budget,
);
expect(outcome).toMatchObject({ pages: 1, truncated: true });
expect(budget.stopReason).toBe("rate_limited");
});
it("still fails on a rate limit outside a slice", async () => {
safeFetchMock.mockResolvedValue(
page(0, true, 429, { "retry-after": "3600" }),
);
await expect(walk({ resumable: true })).rejects.toBeInstanceOf(
GoogleHealthApiError,
);
});
});
describe("a walk that is not resumable", () => {
it("runs past the slice's budget to its end", async () => {
safeFetchMock.mockImplementation(async (url: string) => {
const n = Number(new URL(url).searchParams.get("pageToken") ?? 0);
return page(n, n < 5);
});
const budget = googleHealthSliceBudget(0, 1);
const { outcome } = await runWithGoogleHealthStop(
() => false,
() => walk(),
budget,
);
expect(safeFetchMock).toHaveBeenCalledTimes(6);
expect(outcome.truncated).toBe(false);
});
it("still stops on the job's own budget", async () => {
safeFetchMock.mockImplementation(async () => page(0, true));
let asked = 0;
const { outcome } = await runWithGoogleHealthStop(
() => ++asked > 2,
() => walk(),
googleHealthSliceBudget(60_000, 500),
);
expect(safeFetchMock).toHaveBeenCalledTimes(2);
expect(outcome.truncated).toBe(true);
});
});
+247 -20
View File
@@ -36,7 +36,7 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { createHash, randomBytes } from "node:crypto";
import { annotate, getEvent } from "@/lib/logging/context";
import { safeFetch } from "@/lib/safe-fetch";
import { SafeFetchError, safeFetch } from "@/lib/safe-fetch";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import {
GoogleHealthApiError,
@@ -134,18 +134,140 @@ export async function runWithGoogleHealthClientOutcome<T>(
* reaching the page ceiling: the resource is incomplete, the cycle is not
* stamped, and the reason shown is that only part of the history was
* processed. Outside such a scope the walks never stop early.
*
* A backfill slice adds a second, tighter limit (`GoogleHealthSliceBudget`):
* minutes and pages rather than the job's hours. Only a walk that keeps its
* place across attempts (`resumable`) honours it, because only such a walk
* loses nothing by stopping; a walk that would start over from its first page
* runs on to the end under the job's limit alone.
*/
const stopStorage = new AsyncLocalStorage<() => boolean>();
interface GoogleHealthWalkLimits {
shouldStop: () => boolean;
slice?: GoogleHealthSliceBudget;
}
function stopRequested(): boolean {
return stopStorage.getStore()?.() === true;
/** Why a backfill slice stopped walking. */
export type GoogleHealthSliceStopReason =
"time_budget" | "page_budget" | "rate_limited" | "job_budget";
/**
* One backfill slice's share of the work: a span of wall-clock time and a
* page count, both well inside the job's own expiry. The clock starts the
* first time a walk asks, which is after the slice has the account's lock: a
* slice that waited for an hourly sync to finish does not start out with its
* time already spent. `pages` counts every page the slice's walks have read;
* `stopReason` is set the first time a limit is reached and stays set, so
* every walk after it stops too.
*/
export interface GoogleHealthSliceBudget {
ms: number;
/** When the slice's time is up; null until the first walk asks. */
deadline: number | null;
maxPages: number;
pages: number;
stopReason: GoogleHealthSliceStopReason | null;
now: () => number;
}
/** A fresh slice budget of `ms` milliseconds and `maxPages` pages. */
export function googleHealthSliceBudget(
ms: number,
maxPages: number,
now: () => number = Date.now,
): GoogleHealthSliceBudget {
return {
ms,
deadline: null,
maxPages,
pages: 0,
stopReason: null,
now,
};
}
const stopStorage = new AsyncLocalStorage<GoogleHealthWalkLimits>();
/** Whether the slice in scope has used its budget (and why, once it has). */
function sliceExhausted(slice: GoogleHealthSliceBudget): boolean {
if (slice.stopReason) return true;
slice.deadline ??= slice.now() + slice.ms;
if (slice.now() >= slice.deadline) slice.stopReason = "time_budget";
else if (slice.pages >= slice.maxPages) slice.stopReason = "page_budget";
return slice.stopReason !== null;
}
/**
* Whether a walk should stop before its next request. Every walk stops on the
* job's limit; a resumable one also stops on the slice's.
*/
function stopRequested(resumable = false): boolean {
const limits = stopStorage.getStore();
if (!limits) return false;
if (limits.shouldStop()) {
if (limits.slice && !limits.slice.stopReason) {
limits.slice.stopReason = "job_budget";
}
return true;
}
return (
resumable && limits.slice !== undefined && sliceExhausted(limits.slice)
);
}
/**
* Whether the backfill slice in scope has used its budget. A collection that
* has not started yet waits for the next slice rather than starting now.
* False outside a slice.
*/
export function googleHealthSliceExhausted(): boolean {
const limits = stopStorage.getStore();
if (!limits) return false;
if (limits.shouldStop()) {
if (limits.slice && !limits.slice.stopReason) {
limits.slice.stopReason = "job_budget";
}
return true;
}
return limits.slice !== undefined && sliceExhausted(limits.slice);
}
/** Pages the slice in scope has read so far (0 outside a slice). */
export function googleHealthSlicePagesRead(): number {
return stopStorage.getStore()?.slice?.pages ?? 0;
}
/** Count one page read against the slice in scope, if any. */
function noteSlicePage(): void {
const slice = stopStorage.getStore()?.slice;
if (slice) slice.pages += 1;
}
/** End the slice in scope for `reason` (no-op outside a slice). */
function endSlice(reason: GoogleHealthSliceStopReason): void {
const slice = stopStorage.getStore()?.slice;
if (slice && !slice.stopReason) slice.stopReason = reason;
}
/** Whether a resumable walk can stop and keep its place here. */
function inSlice(): boolean {
return stopStorage.getStore()?.slice !== undefined;
}
export async function runWithGoogleHealthStop<T>(
shouldStop: () => boolean,
fn: () => Promise<T>,
slice?: GoogleHealthSliceBudget,
): Promise<T> {
return stopStorage.run(shouldStop, fn);
return stopStorage.run({ shouldStop, slice }, fn);
}
/**
* Mark the resource in scope truncated without a walk: a collection a slice
* left for the next one. The cycle then reports itself incomplete, so the
* backfill does not mistake a skipped collection for an empty one.
*/
export function noteGoogleHealthDeferred(): void {
notePagination(0, 0, true);
}
export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4";
@@ -527,6 +649,7 @@ export function resolveGoogleHealthUserId(
// camelCase nested objects (`sampleTime.physicalTime`,
// `interval.startTime`, `civilStartTime.date`).
//
// proto3 int64 fields arrive as JSON **strings** (`"12345"`) — every numeric
// extractor coerces numeric strings before the finite check.
@@ -553,6 +676,9 @@ export const GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE = 25;
*/
export const GOOGLE_HEALTH_DENSE_MAX_PAGES = 10_000;
/** Page ceiling for every other list walk. */
export const GOOGLE_HEALTH_DEFAULT_MAX_PAGES = 1000;
/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */
interface GoogleHealthDataPointPage {
dataPoints?: GoogleHealthDataPoint[];
@@ -578,6 +704,20 @@ interface DataPointQuery {
* surfaces it so a live account reports which grammar Google accepted.
*/
onDateFilterStyle?: (style: GoogleHealthDateFilterStyle) => void;
/**
* The walk keeps its place across attempts (a backfill's resume cursor), so
* it may stop on the backfill slice's budget and on a rate limit it cannot
* wait out, and report itself truncated instead of failing: the next slice
* picks up from the edge it recorded.
*/
resumable?: boolean;
/**
* Where an earlier attempt of this walk got to: the oldest sample it wrote
* when the walk ran newest first (`desc`), the newest when it ran oldest
* first (`asc`). The walk asks only for the samples at or beyond it; the
* one sample on the edge itself is read again and overwrites in place.
*/
edge?: { at: Date; order: "desc" | "asc" };
}
const pad2 = (n: number): string => String(n).padStart(2, "0");
@@ -797,6 +937,21 @@ export function googleHealthRetryDelayMs(
return undefined;
}
/**
* How a data request rides out a server that did not answer properly: a
* 500, 502, 503 or 504, a timeout, or a broken connection. A short history
* reads a handful of pages and rarely meets one; a full-history heart-rate
* walk reads thousands, and a single one used to end the walk and throw away
* everything it had read. Three tries more, 2, 4 and 8 s apart (or the
* `Retry-After` a 503 names, up to a minute), before the request fails.
*/
export const GOOGLE_HEALTH_TRANSIENT_RETRY = {
maxRetries: 3,
baseDelayMs: 2_000,
};
const GOOGLE_HEALTH_TRANSIENT_STATUSES = new Set([500, 502, 503, 504]);
function waitMs(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -811,10 +966,12 @@ interface GoogleHealthDataResponse<T> {
/**
* Send one data request and answer with the response it finally got.
*
* Two answers are handled here rather than handed back:
* Three answers are handled here rather than handed back:
* - 429: the request waits (see `GOOGLE_HEALTH_RATE_LIMIT`) and is sent
* again, up to `maxRetries` times, unless the caller's stop condition
* says to end the walk.
* - a server error or no answer at all (see
* `GOOGLE_HEALTH_TRANSIENT_RETRY`): the same, with its own count.
* - 401 with a token source: the token expired or was revoked between
* the source's last check and this request. The source is asked once for
* a fresh token and the request is repeated. A second 401, or a 401 on a
@@ -830,6 +987,7 @@ async function googleHealthDataRequest<T>(
isExpectedFallback: (status: number) => boolean = () => false,
): Promise<GoogleHealthDataResponse<T>> {
let rateLimited = 0;
let transient = 0;
let refreshed = false;
let forceRefresh = false;
for (;;) {
@@ -837,7 +995,33 @@ async function googleHealthDataRequest<T>(
typeof token === "string" ? token : await token({ forceRefresh });
forceRefresh = false;
const started = performance.now();
const res = await send(accessToken);
let res: Response;
try {
res = await send(accessToken);
} catch (err) {
// No answer at all: the request timed out or the connection broke.
// On a walk of a thousand pages one of those is a matter of time, and
// it cost the whole walk.
const retry =
err instanceof SafeFetchError &&
(err.kind === "timeout" || err.kind === "network") &&
transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries &&
!stopRequested();
const delayMs =
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient;
getEvent()?.addExternalCall({
service: "google-health",
method: label,
duration_ms: Math.round(performance.now() - started),
...(retry
? { note: `no_response_retrying_in_ms=${delayMs}` }
: { error: err instanceof Error ? err.message : String(err) }),
});
if (!retry) throw err;
transient += 1;
await waitMs(delayMs);
continue;
}
const json = (await res.json().catch(() => null)) as T | null;
const verdict = classifyGoogleHealthResponse(res.status);
const apiErrorDetail =
@@ -859,6 +1043,17 @@ async function googleHealthDataRequest<T>(
!stopRequested();
const willRefresh =
res.status === 401 && typeof token !== "string" && !refreshed;
const transientDelayMs = GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status)
? Math.min(
googleHealthRetryDelayMs(res, json) ??
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs * 2 ** transient,
GOOGLE_HEALTH_RATE_LIMIT.maxDelayMs,
)
: 0;
const willRetryTransient =
GOOGLE_HEALTH_TRANSIENT_STATUSES.has(res.status) &&
transient < GOOGLE_HEALTH_TRANSIENT_RETRY.maxRetries &&
!stopRequested();
getEvent()?.addExternalCall({
service: "google-health",
@@ -871,14 +1066,16 @@ async function googleHealthDataRequest<T>(
? { note: `rate_limited_retrying_in_ms=${backoffMs}` }
: willRefresh
? { note: "token_rejected_retrying_with_fresh_token" }
: {
error:
verdict.classification === "success"
? undefined
: apiErrorDetail
? `${verdict.reason} ${apiErrorDetail}`
: verdict.reason,
}),
: willRetryTransient
? { note: `server_error_retrying_in_ms=${transientDelayMs}` }
: {
error:
verdict.classification === "success"
? undefined
: apiErrorDetail
? `${verdict.reason} ${apiErrorDetail}`
: verdict.reason,
}),
});
if (willWait) {
@@ -886,6 +1083,11 @@ async function googleHealthDataRequest<T>(
await waitMs(backoffMs);
continue;
}
if (willRetryTransient) {
transient += 1;
await waitMs(transientDelayMs);
continue;
}
if (willRefresh) {
refreshed = true;
forceRefresh = true;
@@ -947,8 +1149,9 @@ export async function forEachDataPointPage(
query: DataPointQuery,
onPage: (points: GoogleHealthDataPoint[]) => void | Promise<void>,
): Promise<void> {
const maxPages = query.maxPages ?? 1000;
const maxPages = query.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES;
const pageSize = query.pageSize ?? GOOGLE_HEALTH_PAGE_SIZE;
const resumable = query.resumable === true;
let requestCount = 0;
@@ -961,11 +1164,12 @@ export async function forEachDataPointPage(
let stopped = false;
do {
if (stopRequested()) {
if (stopRequested(resumable)) {
stopped = true;
break;
}
const params = new URLSearchParams({ pageSize: String(pageSize) });
const filters: string[] = [];
if (query.start) {
const { field, bound } = incrementalFilter(
dataType,
@@ -973,8 +1177,20 @@ export async function forEachDataPointPage(
query.tz,
dateStyle,
);
params.set("filter", `${field} >= "${bound}"`);
filters.push(`${field} >= "${bound}"`);
}
if (query.edge) {
const { field, bound } = incrementalFilter(
dataType,
query.edge.at,
query.tz,
dateStyle,
);
filters.push(
`${field} ${query.edge.order === "desc" ? "<=" : ">="} "${bound}"`,
);
}
if (filters.length > 0) params.set("filter", filters.join(" AND "));
if (pageToken) params.set("pageToken", pageToken);
requestCount += 1;
@@ -985,7 +1201,7 @@ export async function forEachDataPointPage(
// an error next to the 200 that followed it. It is recorded as a note
// now; any other 400 still carries its error.
const firstRequest = requestCount === 1;
const { json, verdict, apiErrorDetail } =
const { status, json, verdict, apiErrorDetail } =
await googleHealthDataRequest<GoogleHealthDataPointPage>(
accessToken,
`${verb}(page=${pageCount})`,
@@ -1003,6 +1219,15 @@ export async function forEachDataPointPage(
firstRequest &&
status === 400,
);
// A rate limit the request could not wait out ends a resumable walk
// inside a backfill slice where it is: the pages before it are written
// and their edge recorded, so this is a pause, not a failure. The slice
// ends with it, since every other walk would meet the same quota.
if (status === 429 && resumable && inSlice()) {
endSlice("rate_limited");
stopped = true;
break;
}
if (verdict.classification !== "success") {
throw new GoogleHealthApiError({
verb,
@@ -1017,6 +1242,7 @@ export async function forEachDataPointPage(
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
fetched += page.length;
noteSlicePage();
await onPage(page);
} while (pageToken && pageCount < maxPages);
@@ -1245,7 +1471,7 @@ export async function fetchDailyRollUp(
let totalPages = 0;
let truncated = false;
for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) {
if (stopRequested()) {
if (stopRequested(false)) {
truncated = true;
break;
}
@@ -1295,6 +1521,7 @@ export async function fetchDailyRollUp(
for (const p of json?.rollupDataPoints ?? []) points.push(p);
pageToken = json?.nextPageToken ?? null;
pageCount += 1;
noteSlicePage();
} while (pageToken && pageCount < 100);
totalPages += pageCount;
truncated ||= Boolean(pageToken);
+15
View File
@@ -387,6 +387,21 @@ function resolveMeasuredAt(
return fallback;
}
/**
* The sample instant of a spot-reading `DataPoint` (`sample` data types), or
* null for any other shape or a point without a parseable time. A backfill
* reads it to record how far a walk has got.
*/
export function googleHealthSampleInstant(
point: GoogleHealthDataPoint,
dataType: GoogleHealthDataType,
tz?: string,
): Date | null {
if (dataType.timeField !== "sample") return null;
const t = readPath(point, `${dataType.key}.sampleTime.physicalTime`);
return typeof t === "string" ? parseLocalInstant(t, tz) : null;
}
/**
* Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its
* sample time; a daily summary on its civil date. Combined with a type-specific
+273 -34
View File
@@ -37,6 +37,9 @@ import {
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
import {
googleHealthOutcomeTruncated,
googleHealthSliceExhausted,
googleHealthSlicePagesRead,
noteGoogleHealthDeferred,
noteGoogleHealthMapped,
noteGoogleHealthOutcomeFailure,
noteGoogleHealthWritten,
@@ -402,33 +405,75 @@ export async function runWithGoogleHealthSyncCycle<T>(
}
/**
* Collections a full-history backfill already walked to the end, carried from
* one attempt of the backfill to the next.
* How far a full-history backfill has got, carried from one attempt of the
* backfill to the next.
*
* A backfill that ended early (its job budget ran out, a collection failed)
* used to start the next attempt from the first page of the first collection,
* so the dense heart-rate walk, thousands of pages, was paid for again on
* every retry, and an account whose walk never fit into one attempt never
* finished at all. Each collection that completes cleanly is now written to
* `backfillProgress`, and the next attempt skips it and goes on with the
* collections it did not reach. A collection is the unit because Google
* offers no cursor that survives between attempts; its page tokens are not
* documented to outlive the walk that received them.
* Two grains. A collection walked to the end is noted in `done`, and the next
* attempt skips it. A collection read page by page (the spot readings: heart
* rate, weight, glucose, ...) also notes, after every page it writes, the
* edge it has reached: the oldest sample written so far when the walk runs
* newest first, as Google's list does. The next attempt asks Google only for
* the samples at or beyond that edge, so a collection that does not fit into
* one attempt is worked through over several instead of being read from its
* first page every time.
*
* The edge is a sample time, not Google's page token: page tokens are not
* documented to outlive the walk that received them, a sample time is a
* filter Google documents. The one sample on the edge itself is read twice
* and overwrites its own row.
*
* Before the edge existed only `done` was kept, and a heart-rate history of
* years that one attempt could not finish (its budget, a rate limit, one
* failed page among thousands) started again from the newest page on every
* attempt and never reached the end (#1220).
*/
export interface GoogleHealthBackfillCursor {
/** The edge: the sample instant (ISO 8601) the walk has written up to. */
at: string;
/** The order the walk returned samples in: newest first, or oldest first. */
order: "desc" | "asc";
/** Pages this collection has read across every attempt so far. */
pages: number;
}
/** What one collection came to in one backfill attempt. */
export interface GoogleHealthBackfillCollectionReport {
key: string;
/**
* `done`: walked to the end. `stopped`: ended short on a budget, a page
* ceiling or a rate limit, with nothing failed. `deferred`: not started,
* left for the next slice. `failed`: a request or a write failed, or the
* collection answered 403.
*/
status: "done" | "stopped" | "deferred" | "failed";
pages: number;
durationMs: number;
/** The edge before and after this attempt, for a resumable collection. */
edgeFrom?: string | null;
edgeTo?: string | null;
}
interface BackfillResumeTracker {
userId: string;
done: Set<string>;
cursors: Map<string, GoogleHealthBackfillCursor>;
/** Collections this attempt skipped because an earlier one finished them. */
skipped: string[];
/** Collections whose edge this attempt moved, with the edge they started from. */
advanced: Map<string, string | null>;
collections: GoogleHealthBackfillCollectionReport[];
}
const backfillResumeStorage = new AsyncLocalStorage<BackfillResumeTracker>();
function progressRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
/** Read the `backfillProgress` column into the set of finished collections. */
export function readGoogleHealthBackfillDone(value: unknown): Set<string> {
const done =
value && typeof value === "object" && !Array.isArray(value)
? (value as { done?: unknown }).done
: undefined;
const done = progressRecord(value).done;
return new Set(
Array.isArray(done)
? done.filter(
@@ -438,20 +483,188 @@ export function readGoogleHealthBackfillDone(value: unknown): Set<string> {
);
}
/** Read the `backfillProgress` column into the per-collection edges. */
export function readGoogleHealthBackfillCursors(
value: unknown,
): Map<string, GoogleHealthBackfillCursor> {
const out = new Map<string, GoogleHealthBackfillCursor>();
const cursors = progressRecord(progressRecord(value).cursors);
for (const [key, raw] of Object.entries(cursors)) {
if (key.length > 64) continue;
const c = progressRecord(raw);
const at = typeof c.at === "string" ? c.at : null;
if (!at || Number.isNaN(Date.parse(at))) continue;
out.set(key, {
at: new Date(at).toISOString(),
order: c.order === "asc" ? "asc" : "desc",
pages:
typeof c.pages === "number" && Number.isFinite(c.pages) && c.pages > 0
? Math.trunc(c.pages)
: 0,
});
}
return out;
}
/**
* Write the tracker's progress with one change applied, and apply it to the
* tracker only once the write has landed: progress that is not on disk is
* progress the next attempt cannot see, so it must not count in this one.
*/
async function persistBackfillProgress(
tracker: BackfillResumeTracker,
next: {
done: Set<string>;
cursors: Map<string, GoogleHealthBackfillCursor>;
},
): Promise<void> {
await prisma.googleHealthConnection.update({
where: { userId: tracker.userId },
data: {
backfillProgress: {
done: [...next.done],
cursors: Object.fromEntries(
[...next.cursors].map(([key, c]) => [
key,
{ at: c.at, order: c.order, pages: c.pages },
]),
),
},
},
});
// `next` may be the tracker's own collections: copy before clearing.
const done = [...next.done];
const cursors = [...next.cursors];
tracker.done.clear();
for (const key of done) tracker.done.add(key);
tracker.cursors.clear();
for (const [key, c] of cursors) tracker.cursors.set(key, c);
}
/** What one backfill attempt did, as `runWithGoogleHealthBackfillResume` reports it. */
export interface GoogleHealthBackfillAttempt<T> {
result: T;
skipped: string[];
/** Collections whose edge moved, each with the edge it started from. */
advanced: Map<string, string | null>;
collections: GoogleHealthBackfillCollectionReport[];
}
/**
* Run a full-history backfill attempt that resumes: collections in `done` are
* skipped, and each collection finished cleanly during this attempt is added
* to it and persisted at once, so an attempt that dies part-way keeps what it
* finished.
* skipped, a collection with an edge in `cursors` starts from it, and each
* collection finished or page written during this attempt is persisted at
* once, so an attempt that dies part-way keeps what it did.
*/
export async function runWithGoogleHealthBackfillResume<T>(
userId: string,
done: Set<string>,
fn: () => Promise<T>,
): Promise<{ result: T; skipped: string[] }> {
const tracker: BackfillResumeTracker = { userId, done, skipped: [] };
cursors: Map<string, GoogleHealthBackfillCursor> = new Map(),
): Promise<GoogleHealthBackfillAttempt<T>> {
const tracker: BackfillResumeTracker = {
userId,
done,
cursors,
skipped: [],
advanced: new Map(),
collections: [],
};
const result = await backfillResumeStorage.run(tracker, fn);
return { result, skipped: tracker.skipped };
return {
result,
skipped: tracker.skipped,
advanced: tracker.advanced,
collections: tracker.collections,
};
}
/**
* Where a resumable collection's walk starts in this backfill attempt: the
* edge an earlier attempt recorded (if any) and the pages it has already
* read. Null outside a backfill, where every walk reads its whole window.
*/
export function googleHealthBackfillCursor(key: GoogleHealthCollectionKey): {
edge?: { at: Date; order: "desc" | "asc" };
pagesSoFar: number;
} | null {
const resume = backfillResumeStorage.getStore();
if (!resume) return null;
const cursor = resume.cursors.get(key);
return cursor
? {
edge: { at: new Date(cursor.at), order: cursor.order },
pagesSoFar: cursor.pages,
}
: { pagesSoFar: 0 };
}
/**
* Record that a resumable collection wrote one more page, holding samples
* from `first` to `last` in the order Google returned them. Moves the
* collection's edge to the far end of the page and persists it. Outside a
* backfill this does nothing.
*
* The order is read off the first page whose two ends differ and kept for
* the rest of the backfill; Google returns its lists newest first, which is
* also what a page that cannot tell is taken to mean.
*/
export async function recordGoogleHealthBackfillPage(
key: GoogleHealthCollectionKey,
page: { first: Date | null; last: Date | null },
): Promise<void> {
const resume = backfillResumeStorage.getStore();
if (!resume) return;
const current = resume.cursors.get(key);
const order: "desc" | "asc" =
current?.order ??
(page.first && page.last && page.first.getTime() < page.last.getTime()
? "asc"
: "desc");
const ends = [page.first, page.last].filter((d): d is Date => d !== null);
const far =
ends.length === 0
? null
: new Date(
order === "desc"
? Math.min(...ends.map((d) => d.getTime()))
: Math.max(...ends.map((d) => d.getTime())),
);
const currentAt = current ? Date.parse(current.at) : null;
const moved =
far !== null &&
(currentAt === null ||
(order === "desc"
? far.getTime() < currentAt
: far.getTime() > currentAt));
if (!current && !moved) return;
const nextCursor: GoogleHealthBackfillCursor = {
at: moved ? far.toISOString() : current!.at,
order,
pages: (current?.pages ?? 0) + 1,
};
const cursors = new Map(resume.cursors);
cursors.set(key, nextCursor);
try {
await persistBackfillProgress(resume, { done: resume.done, cursors });
if (moved && !resume.advanced.has(key)) {
resume.advanced.set(key, current?.at ?? null);
}
} catch (err) {
// The page's rows are written; only the note of how far the walk got is
// missing, so the next attempt reads this page again. Not a failure.
getEvent()?.addWarning(
`google-health: backfill progress write failed for ${key}: ${err}`,
);
annotate({
meta: { "googleHealth.backfill.progressWriteFailed": key },
});
}
}
/** Hard failures the cycle in scope has recorded so far. */
export function googleHealthHardFailureCount(): number {
return hardFailStorage.getStore()?.failures.length ?? 0;
}
/**
@@ -459,10 +672,12 @@ export async function runWithGoogleHealthBackfillResume<T>(
* one in scope. Outside a backfill this is just `fn()`.
*
* Inside one, a collection already walked to the end by an earlier attempt is
* skipped. Otherwise it runs, and it counts as finished only when nothing
* went wrong while it ran: no hard failure on the cycle's ledger, no 403
* soft-skip, and no walk that ended short (its page ceiling, or the job's
* budget asking it to stop). Anything less is walked again next attempt.
* skipped, and so is one the slice has no budget left to start: it is left
* for the next slice, and the cycle reports itself incomplete. Otherwise it
* runs, and it counts as finished only when nothing went wrong while it ran:
* no hard failure on the cycle's ledger, no 403 soft-skip, and no walk that
* ended short (its page ceiling, or a budget asking it to stop). Anything
* less is walked again next attempt, from its edge when it has one.
*/
export async function runGoogleHealthCollection(
key: GoogleHealthCollectionKey,
@@ -474,15 +689,29 @@ export async function runGoogleHealthCollection(
resume.skipped.push(key);
return 0;
}
if (googleHealthSliceExhausted()) {
noteGoogleHealthDeferred();
resume.collections.push({
key,
status: "deferred",
pages: 0,
durationMs: 0,
});
return 0;
}
const startedAt = performance.now();
const pagesBefore = googleHealthSlicePagesRead();
const edgeFrom = resume.cursors.get(key)?.at ?? null;
const hardBefore = hardFailStorage.getStore()?.failures.length ?? 0;
const softBefore = softSkipStorage.getStore()?.count ?? 0;
const truncatedBefore = googleHealthOutcomeTruncated();
const imported = await fn();
const clean =
(hardFailStorage.getStore()?.failures.length ?? 0) === hardBefore &&
(softSkipStorage.getStore()?.count ?? 0) === softBefore &&
!truncatedBefore &&
!googleHealthOutcomeTruncated();
const failedHard =
(hardFailStorage.getStore()?.failures.length ?? 0) !== hardBefore ||
(softSkipStorage.getStore()?.count ?? 0) !== softBefore;
const truncated = truncatedBefore || googleHealthOutcomeTruncated();
const clean = !failedHard && !truncated;
let finished = false;
if (clean) {
// The collection counts as finished only once the note says so. The job
// reads the size of `done` as this attempt's progress and sends another
@@ -490,11 +719,13 @@ export async function runGoogleHealthCollection(
// progress nobody can see next attempt, and a chain of slices could keep
// "progressing" over the same collection forever.
try {
await prisma.googleHealthConnection.update({
where: { userId: resume.userId },
data: { backfillProgress: { done: [...resume.done, key] } },
const cursors = new Map(resume.cursors);
cursors.delete(key);
await persistBackfillProgress(resume, {
done: new Set([...resume.done, key]),
cursors,
});
resume.done.add(key);
finished = true;
} catch (err) {
// Not finished, then: the collection is walked again next attempt. It
// must not fail a collection whose rows are written.
@@ -506,6 +737,14 @@ export async function runGoogleHealthCollection(
});
}
}
const edgeTo = finished ? null : (resume.cursors.get(key)?.at ?? null);
resume.collections.push({
key,
status: finished ? "done" : failedHard ? "failed" : "stopped",
pages: googleHealthSlicePagesRead() - pagesBefore,
durationMs: Math.round(performance.now() - startedAt),
...(edgeFrom !== null || edgeTo !== null ? { edgeFrom, edgeTo } : {}),
});
return imported;
}
+13 -1
View File
@@ -16,7 +16,9 @@
* the hourly poll skips the account for this tick, the manual trigger answers
* that a sync is already running, and the backfill and the repair wait a
* bounded time for a short run to finish before handing their job back to
* pg-boss to retry.
* pg-boss to retry. The hourly poll waits too, for about one backfill slice:
* a backfill lets go of the account between its slices, so the hourly sync
* gets its turn instead of being skipped for as long as the import runs.
*/
import { withJobLock, type GuardedRun } from "@/lib/jobs/job-lock";
@@ -32,6 +34,16 @@ export function googleHealthSyncLockKey(userId: string): string {
*/
export const GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS = 10 * 60 * 1000;
/**
* How long the hourly poll waits for an account another run holds. A backfill
* slice walks for five minutes and then lets go
* (`GOOGLE_HEALTH_BACKFILL_SLICE_MS`); the page in flight, a rate-limit wait
* and the slice's own bookkeeping can add a minute or two, and the next slice
* starts a minute after this one ends. Eight minutes covers that, and leaves
* the hourly job well inside its fifteen-minute expiry.
*/
export const GOOGLE_HEALTH_POLL_LOCK_WAIT_MS = 8 * 60 * 1000;
/**
* Run `run` while holding the account's Google Health lock. Resolves
* `{ ran: false }` without calling `run` when another run holds it and does
+67 -8
View File
@@ -27,12 +27,14 @@
*/
import {
GOOGLE_HEALTH_DATA_TYPES,
GOOGLE_HEALTH_DEFAULT_MAX_PAGES,
GOOGLE_HEALTH_DENSE_MAX_PAGES,
type GoogleHealthDataType,
type GoogleHealthMappedMeasurement,
type GoogleHealthTokenSource,
fetchDataPoints,
forEachDataPointPage,
googleHealthSampleInstant,
mapBloodGlucose,
mapBodyFat,
mapCoreBodyTemperature,
@@ -44,11 +46,15 @@ import {
mapRestingHeartRate,
mapWeight,
mapWristTemperature,
noteGoogleHealthDeferred,
} from "./client";
import {
getValidToken,
googleHealthBackfillCursor,
googleHealthHardFailureCount,
googleHealthTokenSource,
handleCollectionFetchError,
recordGoogleHealthBackfillPage,
runGoogleHealthCollection,
upsertGoogleHealthMeasurements,
type GoogleHealthMeasurementUpsert,
@@ -108,13 +114,6 @@ const METRIC_RESOURCES: MetricResource[] = [
map: mapRespiratoryRate,
verb: "fetchRespiratoryRate",
},
{
dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate,
map: mapHeartRate,
verb: "fetchHeartRate",
maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES,
intraday: true,
},
{
dataType: GOOGLE_HEALTH_DATA_TYPES.bloodGlucose,
map: mapBloodGlucose,
@@ -133,8 +132,34 @@ const METRIC_RESOURCES: MetricResource[] = [
map: mapWristTemperature,
verb: "fetchSleepTemperature",
},
// Last: the dense stream. A backfill finishes every light collection in
// its first slice and then works through this one slice by slice; ahead
// of them it held them back until the whole stream was in.
{
dataType: GOOGLE_HEALTH_DATA_TYPES.heartRate,
map: mapHeartRate,
verb: "fetchHeartRate",
maxPages: GOOGLE_HEALTH_DENSE_MAX_PAGES,
intraday: true,
},
];
/**
* Whether a metric is walked resumably by a backfill: written page by page,
* and a spot reading whose sample time can mark how far the walk got.
*/
function isResumable(resource: MetricResource): boolean {
return resource.dataType.timeField === "sample";
}
/**
* The page ceilings of every resumable collection, summed: the most pages a
* backfill can ever spend on them across all of its attempts.
*/
export const GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING = METRIC_RESOURCES.filter(
isResumable,
).reduce((sum, r) => sum + (r.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES), 0);
export async function syncUserMetrics(
userId: string,
opts: GoogleHealthResourceSyncOptions = {},
@@ -245,6 +270,27 @@ async function syncMetricCollection(
opts: GoogleHealthResourceSyncOptions,
): Promise<number> {
let imported = 0;
// Inside a backfill a spot-reading collection keeps its place: it starts
// from the edge an earlier attempt reached and records its new edge after
// every page it writes (see `GoogleHealthBackfillCursor`). Its page ceiling
// then counts across attempts, so a cursor that never ends is still bounded.
const cursor = isResumable(resource)
? googleHealthBackfillCursor(resource.verb)
: null;
const maxPages =
(resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES) -
(cursor?.pagesSoFar ?? 0);
if (cursor && maxPages <= 0) {
getEvent()?.addWarning(
`google-health ${resource.verb}: backfill reached its page ceiling of ${resource.maxPages ?? GOOGLE_HEALTH_DEFAULT_MAX_PAGES} pages for ${userId}`,
);
noteGoogleHealthDeferred();
return 0;
}
// Once one page's write fails, the edge stays where it is for the rest of
// the walk: moving it past the failed page would leave that page's rows
// behind it for good.
let edgeHeld = false;
try {
await forEachDataPointPage(
resource.dataType,
@@ -254,7 +300,8 @@ async function syncMetricCollection(
start: resource.intraday
? (opts.intradayStart ?? opts.start)
: opts.start,
maxPages: resource.maxPages,
maxPages,
...(cursor ? { resumable: true, edge: cursor.edge } : {}),
},
async (points) => {
const readings: GoogleHealthMeasurementUpsert[] = [];
@@ -269,11 +316,23 @@ async function syncMetricCollection(
});
}
}
const failuresBefore = googleHealthHardFailureCount();
imported += (
await upsertGoogleHealthMeasurements(userId, readings, {
deferRollup: opts.deferRollup,
})
).imported;
if (!cursor || points.length === 0) return;
if (googleHealthHardFailureCount() !== failuresBefore) edgeHeld = true;
if (edgeHeld) return;
await recordGoogleHealthBackfillPage(resource.verb, {
first: googleHealthSampleInstant(points[0]!, resource.dataType, tz),
last: googleHealthSampleInstant(
points[points.length - 1]!,
resource.dataType,
tz,
),
});
},
);
} catch (err) {
@@ -31,7 +31,13 @@ const {
bossSend: vi.fn(),
// Collections the mocked walk "finishes" in this attempt, added to the
// resume set the way `runGoogleHealthCollection` adds them.
resumeState: { finishes: [] as string[], doneBefore: [] as string[] },
resumeState: {
finishes: [] as string[],
doneBefore: [] as string[],
/** Collections whose resume edge the mocked walk moves. */
advances: [] as string[],
collections: [] as Array<{ key: string; status: string }>,
},
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
@@ -54,6 +60,7 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
vi.mock("@/lib/google-health/sync-core", () => ({
GOOGLE_HEALTH_INTEGRATION_KEY: "google-health",
readGoogleHealthBackfillDone: () => new Set<string>(resumeState.doneBefore),
readGoogleHealthBackfillCursors: () => new Map(),
runWithGoogleHealthBackfillResume: async (
_userId: string,
done: Set<string>,
@@ -61,7 +68,12 @@ vi.mock("@/lib/google-health/sync-core", () => ({
) => {
const result = await fn();
for (const key of resumeState.finishes) done.add(key);
return { result, skipped: [] };
return {
result,
skipped: [],
advanced: new Map(resumeState.advances.map((key) => [key, null])),
collections: resumeState.collections,
};
},
}));
vi.mock("@/lib/google-health/sync", () => ({
@@ -71,8 +83,11 @@ vi.mock("@/lib/google-health/sync", () => ({
import {
GOOGLE_HEALTH_BACKFILL_MAX_SLICE,
GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS,
runGoogleHealthBackfillForUser,
} from "../google-health-backfill";
import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics";
import { INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS } from "@/lib/jobs/integration-backfill-admission";
import {
GOOGLE_HEALTH_DATA_TYPES,
forEachDataPointPage,
@@ -87,6 +102,8 @@ beforeEach(() => {
bossSend.mockResolvedValue("job-next");
resumeState.finishes = [];
resumeState.doneBefore = [];
resumeState.advances = [];
resumeState.collections = [];
});
describe("runGoogleHealthBackfillForUser — verdict-gated marker", () => {
@@ -261,8 +278,71 @@ describe("runGoogleHealthBackfillForUser — slices", () => {
expect(bossSend).not.toHaveBeenCalled();
});
it("caps the chain at the number of collections plus two", () => {
expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(17 + 2);
it("derives the chain's ceiling from the work a backfill can do", () => {
// Every collection once, plus every page the resumable collections may
// ever read (heart rate's 10 000 and four others' 1 000), plus slack.
expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(
17 + GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING + 2,
);
expect(GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING).toBe(10_000 + 4 * 1000);
});
it("continues a slice that only moved a collection's edge", async () => {
resumeState.advances = ["fetchHeartRate"];
syncUserGoogleHealthMock.mockResolvedValue({ imported: 900, failed: true });
await expect(
runGoogleHealthBackfillForUser("u1", undefined, 7),
).resolves.toEqual({ imported: 900, continued: true });
expect(bossSend).toHaveBeenCalledTimes(1);
expect(bossSend.mock.calls[0]![1]).toMatchObject({
data: { userId: "u1", slice: 8 },
});
expect(bossSend.mock.calls[0]![2]).toMatchObject({
startAfter: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
});
});
it("waits longer before the next slice when this one ended on a rate limit", async () => {
resumeState.advances = ["fetchHeartRate"];
syncUserGoogleHealthMock.mockResolvedValue({ imported: 10, failed: true });
// The slice ended the way a resumable walk ends it when Google keeps
// answering 429 past every wait.
const client = await import("@/lib/google-health/client");
const spy = vi
.spyOn(client, "googleHealthSliceBudget")
.mockImplementation(() => ({
ms: 300_000,
deadline: null,
maxPages: 500,
pages: 3,
stopReason: "rate_limited",
now: Date.now,
}));
try {
await runGoogleHealthBackfillForUser("u1");
} finally {
spy.mockRestore();
}
expect(bossSend.mock.calls[0]![2]).toMatchObject({
startAfter: GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS,
});
});
it("names where it is stuck when a slice moved nothing", async () => {
resumeState.collections = [
{ key: "fetchWeight", status: "done" },
{ key: "fetchHeartRate", status: "failed" },
{ key: "fetchBloodGlucose", status: "deferred" },
];
syncUserGoogleHealthMock.mockResolvedValue({ imported: 0, failed: true });
await expect(
runGoogleHealthBackfillForUser("u1", undefined, 3),
).rejects.toThrow(
/marker not stamped \(no progress in slice 3; fetchHeartRate:failed,fetchBloodGlucose:deferred\)/,
);
expect(bossSend).not.toHaveBeenCalled();
});
it("stamps the marker on the slice that finishes the history", async () => {
@@ -0,0 +1,117 @@
/**
* #1220 — every admitted full-history import runs inside its own background
* event, so what the provider walk records on the ambient event (each
* request with its status, every warning, every annotation) reaches the log.
* Before, the drain ran the import with no event in scope, every one of those
* calls was a no-op, and a failing backfill left nothing but its error
* message behind.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Job } from "pg-boss";
import type { WideEvent } from "@/lib/logging/types";
const { emitted, runGoogleHealthBackfillMock } = vi.hoisted(() => ({
emitted: [] as WideEvent[],
runGoogleHealthBackfillMock: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: (event: WideEvent) => emitted.push(event),
}));
vi.mock("@/lib/jobs/integration-backfill-lock", () => ({
withIntegrationBackfillLock: async (_id: string, run: () => unknown) => ({
ran: true,
result: await run(),
}),
}));
vi.mock("@/lib/jobs/google-health-backfill", () => ({
runGoogleHealthBackfillForUser: runGoogleHealthBackfillMock,
}));
vi.mock("@/lib/jobs/reminder/shared", () => ({ workerLog: () => {} }));
import { drainIntegrationBackfillAdmission } from "../integration-backfill-drain";
import type { IntegrationBackfillAdmissionPayload } from "../integration-backfill-admission";
import { annotate, getEvent } from "@/lib/logging/context";
function job(
data: IntegrationBackfillAdmissionPayload,
): Job<IntegrationBackfillAdmissionPayload> {
return {
id: "job-1",
data,
expireInSeconds: 3600,
} as unknown as Job<IntegrationBackfillAdmissionPayload>;
}
const payload: IntegrationBackfillAdmissionPayload = {
kind: "google-health-backfill",
data: { userId: "u1", enqueuedAt: "2026-10-14T11:00:00Z", slice: 2 },
};
beforeEach(() => {
emitted.length = 0;
runGoogleHealthBackfillMock.mockReset();
});
describe("drainIntegrationBackfillAdmission — one event per admitted run", () => {
it("emits what the import recorded, with the job it belongs to", async () => {
runGoogleHealthBackfillMock.mockImplementation(async () => {
getEvent()?.addExternalCall({
service: "google-health",
method: "fetchHeartRate(page=0)",
duration_ms: 120,
status: 200,
});
annotate({ meta: { "googleHealth.backfill.slice": { pages: 1 } } });
return { imported: 1000, continued: true };
});
await drainIntegrationBackfillAdmission([job(payload)]);
expect(emitted).toHaveLength(1);
const [event] = emitted;
expect(event!.background).toMatchObject({
task_name: "job.integration_backfill",
result: { kind: "google-health-backfill", imported: 1000, continued: 1 },
});
expect(event!.meta).toMatchObject({
kind: "google-health-backfill",
identity: "google-health-backfill|u1",
job_id: "job-1",
slice: 2,
"googleHealth.backfill.slice": { pages: 1 },
});
expect(event!.external_calls).toEqual([
expect.objectContaining({
method: "fetchHeartRate(page=0)",
status: 200,
}),
]);
});
it("emits the calls of a run that throws, with its error", async () => {
runGoogleHealthBackfillMock.mockImplementation(async () => {
getEvent()?.addExternalCall({
service: "google-health",
method: "fetchHeartRate(page=812)",
duration_ms: 15000,
status: 503,
error: "transient HTTP 503",
});
throw new Error("google-health backfill incomplete for user u1");
});
await expect(
drainIntegrationBackfillAdmission([job(payload)]),
).rejects.toThrow(/incomplete/);
expect(emitted).toHaveLength(1);
expect(emitted[0]!.level).toBe("error");
expect(emitted[0]!.error?.message).toMatch(/incomplete/);
expect(emitted[0]!.external_calls?.[0]).toMatchObject({
method: "fetchHeartRate(page=812)",
status: 503,
});
});
});
+151 -60
View File
@@ -21,6 +21,7 @@ import {
integrationBackfillSourceOptions,
} from "@/lib/jobs/integration-backfill-admission";
import {
readGoogleHealthBackfillCursors,
readGoogleHealthBackfillDone,
runWithGoogleHealthBackfillResume,
} from "@/lib/google-health/sync-core";
@@ -29,7 +30,12 @@ import {
syncUserGoogleHealth,
} from "@/lib/google-health/sync";
import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock";
import { runWithGoogleHealthStop } from "@/lib/google-health/client";
import {
googleHealthSliceBudget,
runWithGoogleHealthStop,
} from "@/lib/google-health/client";
import { GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING } from "@/lib/google-health/sync-metrics";
import { reportJobProgress } from "@/lib/jobs/job-observer";
import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys";
export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill";
@@ -42,15 +48,52 @@ export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill";
export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1;
/**
* The highest slice a backfill chain may reach. Every continued slice has
* finished at least one more collection than the one before it, so a healthy
* chain is at most one slice per collection long; the two extra slices are
* slack for the first one and the one that stamps the marker. A slice past
* this is a chain that counts progress it does not make, and it fails rather
* than queueing another.
* One slice of a backfill: how long it walks and how many pages it reads
* before it lets go of the account and hands the rest to the next slice.
*
* Far inside the admission job's expiry (sixteen hours) on purpose. A slice
* holds the account's sync lock while it walks, and the hourly sync of that
* account waits for the lock (`GOOGLE_HEALTH_POLL_LOCK_WAIT_MS`), so a slice
* has to be short enough for the hourly sync to get its turn: before slices,
* one attempt held the lock for as long as the import ran and every hourly
* sync of the account in that time was skipped. The page budget keeps a
* slice on a fast link from spending a whole quota window in one go.
*/
export const GOOGLE_HEALTH_BACKFILL_SLICE_MS = 5 * 60 * 1000;
export const GOOGLE_HEALTH_BACKFILL_SLICE_PAGES = 500;
/**
* The pause before the next slice when this one ended on a rate limit it
* could not wait out: long enough for Google's per-minute window to refill
* several times over, so the next slice does not open on the same 429.
*/
export const GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS = 5 * 60;
/**
* The highest slice a backfill chain may reach, derived from the work a
* backfill can do rather than guessed.
*
* A slice is continued only when it made progress that is on disk: it
* finished a collection, or it moved a resumable collection's edge after
* writing at least one page. Collections finish once each, and a resumable
* collection's pages are capped across all attempts by its page ceiling
* (`GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING`, the sum of them). So no chain of
* progressing slices is longer than the collections plus those pages; the two
* extra are slack for the first slice and the one that stamps the marker. In
* practice a chain is a handful of slices per year of dense history. A slice
* past this is counting progress it does not make, and fails rather than
* queueing another.
*/
export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE =
GOOGLE_HEALTH_COLLECTION_KEYS.length + 2;
GOOGLE_HEALTH_COLLECTION_KEYS.length +
GOOGLE_HEALTH_RESUMABLE_PAGE_CEILING +
2;
/** Test seam: a slice's budget, when not the production one. */
export interface GoogleHealthBackfillSliceOptions {
sliceMs?: number;
slicePages?: number;
}
export interface GoogleHealthBackfillPayload {
userId: string;
@@ -58,42 +101,40 @@ export interface GoogleHealthBackfillPayload {
}
/**
* Per-user backfill handler. Runs a full-history sync for one account and
* stamps `backfillCompletedAt` ONLY on a clean run, so the discovery query
* drops it. Idempotent: the per-resource upserts are key-stable, so a re-run
* (e.g. a reboot mid-walk) overwrites rather than duplicating. Mirrors
* `runFitbitBackfillForUser`.
* Per-user backfill handler. Runs one slice of a full-history sync for one
* account and stamps `backfillCompletedAt` ONLY on a clean run, so the
* discovery query drops it. Idempotent: the per-resource upserts are
* key-stable, so a re-run (e.g. a reboot mid-walk) overwrites rather than
* duplicating. Mirrors `runFitbitBackfillForUser`.
*
* Verdict-gated: a partial hard failure (one collection 400ing, a write
* failing) THROWS so pg-boss retries the job — stamping the marker over a
* partial walk would freeze the gap in forever. A connection parked at
* `error_reauth` is NOT clean either, but throwing against a dead grant would
* just burn the retry budget on the same no-op: return WITHOUT stamping — the
* boot discovery re-enqueues the account on the next boot, and the stamp lands
* once the user reconnects and a clean walk completes.
* failing) is not complete, and stamping the marker over a partial walk would
* freeze the gap in forever. A connection parked at `error_reauth` is NOT
* clean either, but throwing against a dead grant would just burn the retry
* budget on the same no-op: return WITHOUT stamping — the boot discovery
* re-enqueues the account on the next boot, and the stamp lands once the user
* reconnects and a clean walk completes.
*
* `shouldStop` is the admission job's budget (`jobBudget`). A per-minute
* heart-rate history runs to thousands of pages, and the walk asks it before
* every page: once it says stop, the walk ends where it is, the cycle reports
* itself truncated, and this throws like any other incomplete run, without
* the stamp. The rows already written stay.
* Resumable at two grains (`GoogleHealthBackfillCursor`): every collection the
* walk finishes cleanly is noted in `backfillProgress.done` and skipped from
* then on, and every spot-reading collection notes the edge it has written up
* to after each page, so the next attempt starts there instead of at the
* newest page. The note is cleared once the stamp lands, and on every
* reconnect.
*
* Resumable: every collection the walk finishes cleanly is noted in
* `backfillProgress`, and the next attempt (the pg-boss retry, or the next
* boot's discovery) skips those and walks only what is left
* (`runWithGoogleHealthBackfillResume`). The note is cleared once the stamp
* lands, and on every reconnect.
* Runs in slices. A slice walks until its own budget is spent
* (`GOOGLE_HEALTH_BACKFILL_SLICE_MS` / `_PAGES`), Google throttles it past
* what a wait covers, or the job's budget (`shouldStop`) runs out, and then
* lets go of the account. A slice that made progress on disk (a collection
* finished, an edge moved) is not a failure: it sends the next slice
* (`slice + 1`) and returns `continued`, without the error line or the
* queue's failure count. Only a slice that moved nothing throws, with what
* each collection came to in the message, because that one is stuck: the
* pg-boss retry and, after it, the next boot take it from there. The chain is
* capped at `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`.
*
* Runs in slices: an attempt that ends before the whole history is in but
* finished at least one more collection than it started with made progress.
* It is not a failure: it sends the next slice (`slice + 1`) and returns
* `continued`, without the error line or the queue's failure count. Only an
* attempt that finished nothing new throws, because that one is stuck: the
* pg-boss retry and, after it, the next boot take it from there. Progress is
* counted only once it is persisted (`runGoogleHealthCollection` adds a
* collection to `done` after its note is written), and the chain is capped at
* `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`: a slice past it throws instead of
* running or sending another.
* What the slice did is annotated on the run's event as
* `googleHealth.backfill.slice` (see `docs/integrations/google-health.md`).
*
* Never beside another run of the same account: the sync takes the account's
* lock, waiting a bounded time for an hourly or manual run to finish. If the
@@ -104,6 +145,7 @@ export async function runGoogleHealthBackfillForUser(
userId: string,
shouldStop: () => boolean = () => false,
slice: number = 0,
sliceOptions: GoogleHealthBackfillSliceOptions = {},
): Promise<{ imported: number; continued?: boolean }> {
if (slice > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) {
annotate({
@@ -142,22 +184,56 @@ export async function runGoogleHealthBackfillForUser(
return { imported: 0 };
}
const done = readGoogleHealthBackfillDone(connection?.backfillProgress);
const cursors = readGoogleHealthBackfillCursors(connection?.backfillProgress);
const doneBefore = done.size;
const budget = googleHealthSliceBudget(
sliceOptions.sliceMs ?? GOOGLE_HEALTH_BACKFILL_SLICE_MS,
sliceOptions.slicePages ?? GOOGLE_HEALTH_BACKFILL_SLICE_PAGES,
);
const startedAt = Date.now();
const {
result: { imported, failed, busy },
skipped,
} = await runWithGoogleHealthBackfillResume(userId, done, () =>
runWithGoogleHealthStop(shouldStop, () =>
syncUserGoogleHealth(userId, {
fullSync: true,
waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS,
}),
),
advanced,
collections,
} = await runWithGoogleHealthBackfillResume(
userId,
done,
() =>
runWithGoogleHealthStop(
shouldStop,
() =>
syncUserGoogleHealth(userId, {
fullSync: true,
waitForLockMs: GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS,
}),
budget,
),
cursors,
);
if (skipped.length > 0) {
annotate({ meta: { "googleHealth.backfill.resumedPast": skipped } });
}
const finished = done.size - doneBefore;
const sliceReport = {
slice,
elapsedMs: Date.now() - startedAt,
pages: budget.pages,
stopReason: budget.stopReason,
imported,
collectionsDone: done.size,
collectionsTotal: GOOGLE_HEALTH_COLLECTION_KEYS.length,
collectionsFinished: finished,
resumedPast: skipped.length,
collections,
// Where each unfinished resumable collection has got to, after this slice.
edges: Object.fromEntries([...cursors].map(([key, c]) => [key, c.at])),
};
annotate({ meta: { "googleHealth.backfill.slice": sliceReport } });
reportJobProgress({
google_health_backfill_slice: slice,
google_health_backfill_pages: budget.pages,
google_health_backfill_collections_done: done.size,
});
if (busy) {
throw new Error(
@@ -166,10 +242,10 @@ export async function runGoogleHealthBackfillForUser(
}
if (failed) {
// `done` is the resume set this attempt added to as it finished
// collections; a larger set than it started with is progress.
const finished = done.size - doneBefore;
if (finished > 0 && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) {
// Progress is what this slice put on disk: collections finished, and
// edges moved. Either is enough to go on; neither means stuck.
const progressed = finished > 0 || advanced.size > 0;
if (progressed && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) {
const boss = getGlobalBoss();
const sent = boss
? await enqueueIntegrationBackfillAdmission(
@@ -182,7 +258,9 @@ export async function runGoogleHealthBackfillForUser(
slice: slice + 1,
},
},
INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
budget.stopReason === "rate_limited"
? GOOGLE_HEALTH_BACKFILL_THROTTLED_DELAY_SECONDS
: INTEGRATION_BACKFILL_CONTINUATION_DELAY_SECONDS,
)
: null;
if (sent) {
@@ -192,8 +270,10 @@ export async function runGoogleHealthBackfillForUser(
details: {
imported,
slice,
stopReason: budget.stopReason,
collectionsFinished: finished,
collectionsDone: done.size,
edgesMoved: [...advanced.keys()],
},
},
});
@@ -202,12 +282,23 @@ export async function runGoogleHealthBackfillForUser(
// No follow-up could be queued; fall through to the retry path so the
// work is not dropped.
}
// No collection finished in this attempt: the backfill is stuck, not
// slow. Surface through the pg-boss retry path (retryLimit 3, backoff).
// If the failure parked the connection at error_reauth meanwhile, the
// next attempt takes the reauth return above instead of failing again.
// Nothing moved in this slice: the backfill is stuck, not slow. Surface
// through the pg-boss retry path (retryLimit 3, backoff) with what each
// collection came to, so the log line says where it is stuck. If the
// failure parked the connection at error_reauth meanwhile, the next
// attempt takes the reauth return above instead of failing again.
annotate({
action: {
name: "google_health.backfill.stuck",
details: { slice, stopReason: budget.stopReason },
},
});
const where = collections
.filter((c) => c.status !== "done")
.map((c) => `${c.key}:${c.status}`)
.join(",");
throw new Error(
`google-health backfill incomplete for user ${userId} — marker not stamped`,
`google-health backfill incomplete for user ${userId} — marker not stamped (no progress in slice ${slice}${budget.stopReason ? `, stopped on ${budget.stopReason}` : ""}${where ? `; ${where}` : ""})`,
);
}
@@ -219,7 +310,7 @@ export async function runGoogleHealthBackfillForUser(
annotate({
action: {
name: "google_health.backfill.complete",
details: { imported },
details: { imported, slice },
},
});
return { imported };
+13 -1
View File
@@ -32,6 +32,7 @@ import { invalidateStatusInsightsForTypes } from "@/lib/insights/status-invalida
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
import { trackBackgroundTask } from "@/lib/logging/background-tasks";
import { annotate } from "@/lib/logging/context";
import { withBackgroundEvent } from "@/lib/logging/background";
import {
recomputeUserRollups,
ROLLUP_FOLD_WINDOW_MS,
@@ -102,7 +103,18 @@ export async function handleHealthConnectImport(
): Promise<JobOutcome> {
let failure: JobOutcome | null = null;
for (const job of jobs) {
const outcome = await runHealthConnectImport(job.data);
// Its own event per import, so the `annotate` calls below and anything
// the import records on the ambient event reach the log instead of
// vanishing for want of one.
const outcome = await withBackgroundEvent(
"job.health_connect_import",
async (event) => {
event.addMeta("import_job_id", job.data.importJobId);
const result = await runHealthConnectImport(job.data);
if (!result.ok) event.setError(result.cause ?? result.reason);
return result;
},
);
if (!outcome.ok && failure === null) failure = outcome;
}
return failure ?? jobDone({ jobs: jobs.length });
+44 -1
View File
@@ -143,6 +143,49 @@ async function runAdmitted(
}
}
/** The task name of the event each admitted run writes. */
export const INTEGRATION_BACKFILL_RUN_TASK = "job.integration_backfill";
/**
* Run one admitted job inside its own background event.
*
* The runners write what they do through the ambient event: every request a
* provider walk makes (`addExternalCall`, with its status, duration and the
* page it asked for), every warning, every `annotate`. Without an event in
* scope each of those is a no-op, so a full-history import that failed left
* nothing behind but the job's own error message: not the collection it was
* on, not the status the provider answered, not how far it got (#1220). The
* hourly syncs always ran inside one; the imports did not. Every kind gets
* one here, emitted when the run settles, thrown error included.
*/
async function runObserved(
job: Job<IntegrationBackfillAdmissionPayload>,
identity: string,
shouldStop: () => boolean,
totals: RunCounts,
): Promise<void> {
await withBackgroundEvent(INTEGRATION_BACKFILL_RUN_TASK, async (event) => {
event.addMeta("kind", job.data.kind);
event.addMeta("identity", identity);
event.addMeta("job_id", job.id);
event.addMeta("slice", job.data.data.slice ?? 0);
const before = { ...totals };
await runAdmitted(job.data, shouldStop, totals);
event.setBackground({
task_name: INTEGRATION_BACKFILL_RUN_TASK,
result: {
kind: job.data.kind,
imported: totals.imported - before.imported,
continued: totals.continued - before.continued,
removed: totals.removed - before.removed,
deleted: totals.deleted - before.deleted,
markers: totals.markers - before.markers,
linked: totals.linked - before.linked,
},
});
});
}
/**
* Drain a batch of admission jobs. There is no per-job error isolation: a
* runner that throws fails the job and the admission retry policy applies. So
@@ -166,7 +209,7 @@ export async function drainIntegrationBackfillAdmission(
for (const job of jobs) {
const identity = integrationBackfillIdentity(job.data);
const run = await withIntegrationBackfillLock(identity, () =>
runAdmitted(job.data, shouldStop, totals),
runObserved(job, identity, shouldStop, totals),
);
if (!run.ran) {
alreadyRunning += 1;
+9 -2
View File
@@ -15,6 +15,7 @@ import {
isGoogleHealthParked,
syncUserGoogleHealth,
} from "@/lib/google-health/sync";
import { GOOGLE_HEALTH_POLL_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup";
import {
@@ -69,9 +70,15 @@ export async function handleGoogleHealthSync(
}
try {
const result = await syncUserGoogleHealth(userId);
// A backfill holds the account for one slice at a time, so
// the tick waits for the slice to end rather than skipping the
// account for as long as the whole import runs.
const result = await syncUserGoogleHealth(userId, {
waitForLockMs: GOOGLE_HEALTH_POLL_LOCK_WAIT_MS,
});
// Another run of this account (its backfill, a manual sync)
// holds the account; this tick leaves it to that run.
// still held the account after the wait; this tick leaves it
// to that run.
if (result.busy) return { status: "skipped", imported: 0 };
const parked =
result.failed &&
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { WideEventBuilder } from "../event-builder";
import {
EXTERNAL_CALLS_MAX,
EXTERNAL_CALLS_PLAIN_MAX,
WideEventBuilder,
} from "../event-builder";
describe("WideEventBuilder.setHttp", () => {
it("stores plain http metadata as-is", () => {
@@ -140,3 +144,46 @@ describe("WideEventBuilder.capStatusLevel", () => {
expect(evt.toJSON().level).toBe("warn");
});
});
describe("WideEventBuilder — bounded external calls", () => {
it("lists failures beyond the share of plain successes, and counts the rest", () => {
const evt = new WideEventBuilder("background");
for (let i = 0; i < 500; i++) {
evt.addExternalCall({
service: "google-health",
method: `page=${i}`,
duration_ms: 1,
status: 200,
});
}
evt.addExternalCall({
service: "google-health",
method: "page=500",
duration_ms: 1,
status: 503,
error: "transient HTTP 503",
});
const event = evt.toJSON();
expect(event.external_calls).toHaveLength(EXTERNAL_CALLS_PLAIN_MAX + 1);
expect(event.external_calls!.at(-1)).toMatchObject({ status: 503 });
expect(event.external_calls_omitted).toBe(500 - EXTERNAL_CALLS_PLAIN_MAX);
for (let i = 0; i < 1000; i++) {
evt.addExternalCall({
service: "google-health",
method: `retry=${i}`,
duration_ms: 1,
status: 429,
note: "rate_limited_retrying_in_ms=2000",
});
}
const after = evt.toJSON();
expect(after.external_calls).toHaveLength(EXTERNAL_CALLS_MAX);
expect(after.external_calls_omitted).toBe(
500 -
EXTERNAL_CALLS_PLAIN_MAX +
1000 -
(EXTERNAL_CALLS_MAX - EXTERNAL_CALLS_PLAIN_MAX - 1),
);
});
});
+24
View File
@@ -35,6 +35,12 @@ function redactDeep<T>(value: T, depth = 0): T {
return value;
}
/** The most external calls one event lists. */
export const EXTERNAL_CALLS_MAX = 200;
/** The most calls without an error or a note one event lists. */
export const EXTERNAL_CALLS_PLAIN_MAX = 60;
/**
* Baut ein Wide Event Schritt fuer Schritt auf.
* Wird am Anfang eines Requests/Tasks erstellt und am Ende emittiert.
@@ -44,6 +50,7 @@ export class WideEventBuilder {
private startTime: number;
private highestLevel: LogLevel = "info";
private statusLevelCeiling: LogLevel = "error";
private plainExternalCalls = 0;
constructor(kind: EventKind = "http") {
this.startTime = performance.now();
@@ -208,6 +215,23 @@ export class WideEventBuilder {
addExternalCall(call: NonNullable<WideEvent["external_calls"]>[0]): this {
if (!this.event.external_calls) this.event.external_calls = [];
// Bounded. A long background run (a full-history backfill walks hundreds
// of pages in one event) would otherwise hold every call in memory and
// write them all as one log line. Calls that failed or carry a note are
// what a reader looks for, so they keep the larger share; plain successes
// stop being listed sooner. Whatever is left out is counted, never lost
// silently.
const calls = this.event.external_calls;
const notable = call.error !== undefined || call.note !== undefined;
if (
calls.length >= EXTERNAL_CALLS_MAX ||
(!notable && this.plainExternalCalls >= EXTERNAL_CALLS_PLAIN_MAX)
) {
this.event.external_calls_omitted =
(this.event.external_calls_omitted ?? 0) + 1;
return this;
}
if (!notable) this.plainExternalCalls += 1;
// An outbound failure carries whatever the remote said, and for several
// integrations that is the request URL — which for Telegram embeds the bot
// token. `setError` and `setHttp` scrub on the way in; this entry point did
+5
View File
@@ -123,6 +123,11 @@ export interface WideEvent {
*/
note?: string;
}>;
/**
* How many external calls the event did not list because it had already
* listed its share (`EXTERNAL_CALLS_MAX`, `EXTERNAL_CALLS_PLAIN_MAX`).
*/
external_calls_omitted?: number;
// Hintergrundaufgabe (nur bei kind === "background")
background?: {
@@ -0,0 +1,396 @@
/**
* #1220 — a Google Health history backfill over three years of heart rate,
* driven through the admission drain slice by slice until it completes,
* against Postgres.
*
* What went wrong. A backfill noted its progress only once a whole collection
* was walked to the end. Three years of heart rate at one reading a minute is
* some 1 600 pages, and an attempt that did not get through all of them (its
* budget, a rate limit it could not wait out, one failed request among
* thousands) started the next attempt from the newest page again. The account
* never got past its first unfinished collection, every attempt failed with
* "marker not stamped", and none of them left a line saying where it stopped,
* because the drain ran the import outside any log event.
*
* What this pins, end to end:
* - the backfill completes, through as many slices as the history needs,
* and every slice after the first starts where the one before it stopped;
* - every reading is imported exactly once: the rows match the history, the
* slices' import counts add up to it, and no row was ever rewritten;
* - a server error, a lost request and a rate limit Google will not lift
* for an hour each cost a retry or a slice boundary, never the walk;
* - the hourly sync runs between slices (and beside one, by waiting for
* it) instead of being skipped for as long as the import runs;
* - every slice writes a log event naming its requests and where it got to;
* - the heap does not grow with the history (run it under the production
* ceiling: `NODE_OPTIONS=--max-old-space-size=524`).
*
* Google is replaced at the `safeFetch` seam by a generator that builds each
* page on request from the filter and page token it is asked with, so the
* fixture never sits in memory.
*/
import v8 from "node:v8";
import vm from "node:vm";
import type { Job } from "pg-boss";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { WideEvent } from "@/lib/logging/types";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "gh-multi-year-owner";
const MINUTE = 60_000;
/** The newest reading: now, so the hourly sync's window lands on real data. */
const NOW = Math.floor(Date.now() / MINUTE) * MINUTE;
/** Three years of heart rate, one reading a minute. */
const POINTS = 3 * 365 * 24 * 60;
/**
* How much the collected heap may grow over the second half of the history:
* from the first sample taken inside a page request once the second slice is
* over, to the most any later sample reaches. Every sample is taken mid-walk,
* so each carries the same in-flight working set (one page, the ORM's and the
* driver's buffers, some 35 MB here) and only growth is compared. The first
* two slices are left out because the first hourly sync and the first
* rate-limited slice load code paths once. Measured, not chosen: the line
* stays within half a MB of flat. A backfill that kept what it read would
* grow by hundreds of MB over the same stretch.
*/
const GROWTH_BUDGET_BYTES = 16 * 1024 * 1024;
const fake = vi.hoisted(() => ({
now: 0,
total: 0,
/** Heart-rate requests seen, the backfill's among them, and readings served. */
heartRateRequests: 0,
backfillRequests: 0,
servedPoints: 0,
/** Faults to inject, keyed by the backfill request number they hit. */
faults: new Map<number, "503" | "timeout" | "429-long">(),
onRequest: null as (() => void) | null,
}));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
const respond = (body: unknown, status = 200, headers = {}) =>
new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", ...headers },
});
return {
...actual,
// A plain function, not `vi.fn`: a mock records every result, and here
// each result is a page of a thousand readings.
safeFetch: async (url: string) => {
const u = new URL(url);
if (!u.pathname.endsWith("/dataTypes/heart-rate/dataPoints")) {
// Every other collection is empty for this account.
return respond({});
}
fake.heartRateRequests += 1;
fake.onRequest?.();
// The hourly sync always asks from a lower bound; the backfill never.
const filter = u.searchParams.get("filter") ?? "";
const fromBackfill = !filter.includes(">=");
if (fromBackfill) fake.backfillRequests += 1;
const fault = fromBackfill
? fake.faults.get(fake.backfillRequests)
: undefined;
if (fault) {
fake.faults.delete(fake.backfillRequests);
if (fault === "timeout") {
throw new actual.SafeFetchError("safeFetch aborted", "timeout");
}
if (fault === "503") {
return respond({ error: { code: 503, status: "UNAVAILABLE" } }, 503);
}
return respond(
{ error: { code: 429, status: "RESOURCE_EXHAUSTED" } },
429,
{ "retry-after": "3600" },
);
}
// Newest first, one reading a minute: reading `g` is `g` minutes old.
// The hourly sync asks from a lower bound (`>=`), a resumed backfill
// up to its edge (`<=`).
const from = /physical_time >= "([^"]+)"/.exec(filter)?.[1];
const upTo = /physical_time <= "([^"]+)"/.exec(filter)?.[1];
const newest = upTo
? Math.max(0, Math.ceil((fake.now - Date.parse(upTo)) / MINUTE))
: 0;
const oldest = from
? Math.min(
fake.total - 1,
Math.floor((fake.now - Date.parse(from)) / MINUTE),
)
: fake.total - 1;
const pageSize = Number(u.searchParams.get("pageSize") ?? 1000);
const page = Number(u.searchParams.get("pageToken") ?? 0);
const start = newest + page * pageSize;
const end = Math.min(oldest + 1, start + pageSize);
const dataPoints = [];
for (let g = start; g < end; g++) {
dataPoints.push({
name: `users/me/dataTypes/heart-rate/dataPoints/${7_000_000_000 + g}`,
dataSource: { platform: "FITBIT" },
heartRate: {
beatsPerMinute: String(52 + (g % 61)),
sampleTime: {
physicalTime: new Date(fake.now - g * MINUTE).toISOString(),
},
},
});
}
fake.servedPoints += dataPoints.length;
return respond({
dataPoints,
...(end <= oldest ? { nextPageToken: String(page + 1) } : {}),
});
},
};
});
/** Jobs the backfill and the hourly sync queue, recorded. */
const sent = vi.hoisted(() => ({
jobs: [] as Array<{ queue: string; data: unknown }>,
}));
vi.mock("@/lib/jobs/boss-instance", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/jobs/boss-instance")>()),
getGlobalBoss: () => ({
send: async (queue: string, data: unknown) => {
sent.jobs.push({ queue, data });
return `job-${sent.jobs.length}`;
},
}),
}));
/** The log events the runs write, recorded instead of printed. */
const logged = vi.hoisted(() => ({ events: [] as WideEvent[] }));
vi.mock("@/lib/logging/transports", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/logging/transports")>()),
emitIfSampled: (event: WideEvent) => {
logged.events.push(event);
},
}));
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-multi-year] ${line}\n`);
}
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() + 7 * 86_400_000),
},
});
fake.now = NOW;
fake.total = POINTS;
}, 120_000);
afterAll(async () => {
await truncateAllTables(getPrismaClient());
});
type AdmissionPayload =
import("@/lib/jobs/integration-backfill-admission").IntegrationBackfillAdmissionPayload;
function admissionJob(
payload: AdmissionPayload,
n: number,
): Job<AdmissionPayload> {
return {
id: `admission-${n}`,
name: "integration-backfill-admission",
data: payload,
// The production expiry: sixteen hours.
expireInSeconds: 16 * 60 * 60,
} as unknown as Job<AdmissionPayload>;
}
function nextSlice(): AdmissionPayload | null {
const next = sent.jobs.find(
(j) => j.queue === "integration-backfill-admission",
);
return next ? (next.data as AdmissionPayload) : null;
}
describe("Google Health backfill over three years of heart rate (#1220)", () => {
it("completes slice by slice, imports every reading once, and keeps the hourly sync running", async () => {
const { drainIntegrationBackfillAdmission } =
await import("@/lib/jobs/integration-backfill-drain");
const { handleGoogleHealthSync } =
await import("@/lib/jobs/reminder/google-health-sync");
const { GOOGLE_HEALTH_TRANSIENT_RETRY } =
await import("@/lib/google-health/client");
const { GOOGLE_HEALTH_BACKFILL_SLICE_PAGES } =
await import("@/lib/jobs/google-health-backfill");
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0;
// A server error and a lost request in the first slice, and a rate limit
// Google will not lift for an hour in the second.
fake.faults.set(40, "503");
fake.faults.set(80, "timeout");
fake.faults.set(GOOGLE_HEALTH_BACKFILL_SLICE_PAGES + 120, "429-long");
// Heap samples from inside the page requests, between two pages of the
// work being measured, once the second slice is over (see the budget).
let measuring = false;
let anchor: number | null = null;
let peak = 0;
let samples = 0;
fake.onRequest = () => {
if (!measuring || fake.heartRateRequests % 50 !== 0) return;
const live = liveHeapBytes();
if (anchor === null) {
anchor = peak = live;
return;
}
samples++;
peak = Math.max(peak, live);
};
const prisma = getPrismaClient();
let payload: AdmissionPayload | null = {
kind: "google-health-backfill",
data: { userId: USER, enqueuedAt: new Date().toISOString() },
};
const slices: Array<{
slice: number;
imported: number;
continued: number;
}> = [];
let hourlyBeside: Promise<unknown> | null = null;
for (let n = 0; payload && n < 40; n++) {
sent.jobs = [];
logged.events = [];
const drained = drainIntegrationBackfillAdmission([
admissionJob(payload, n),
]);
// Once, start the hourly sync while a slice is running: it waits for
// the slice instead of skipping the account.
if (n === 2) {
hourlyBeside = handleGoogleHealthSync([] as never);
}
const outcome = await drained;
expect(outcome.ok).toBe(true);
const did = (outcome as { did: Record<string, number> }).did;
slices.push({
slice: payload.data.slice ?? 0,
imported: did.imported,
continued: did.continued,
});
// The slice wrote one log event, with its requests and where it got to.
const runEvents = logged.events.filter(
(e) => e.background?.task_name === "job.integration_backfill",
);
expect(runEvents).toHaveLength(1);
const sliceMeta = runEvents[0]!.meta?.["googleHealth.backfill.slice"] as
{ pages: number; stopReason: string | null; edges: object } | undefined;
expect(sliceMeta).toBeDefined();
expect(
runEvents[0]!.external_calls?.some((c) =>
c.method.startsWith("fetchHeartRate(page="),
),
).toBe(true);
report(
`slice ${payload.data.slice ?? 0}: pages=${sliceMeta!.pages} ` +
`stop=${sliceMeta!.stopReason} imported=${did.imported} ` +
`edges=${JSON.stringify(sliceMeta!.edges)}`,
);
if (n === 1) measuring = true;
payload = nextSlice();
// Between slices the account is free, and the hourly sync runs.
if (payload) {
const hourly = await handleGoogleHealthSync([] as never);
expect(hourly).toMatchObject({
ok: true,
did: { users_skipped: 0, users_failed: 0, users_parked: 0 },
});
}
}
expect(await hourlyBeside).toMatchObject({
ok: true,
did: { users_skipped: 0 },
});
// Completed, and the note of how far it got is gone with it.
const connection = await prisma.googleHealthConnection.findUniqueOrThrow({
where: { userId: USER },
select: { backfillCompletedAt: true, backfillProgress: true },
});
expect(connection.backfillCompletedAt).toBeInstanceOf(Date);
expect(connection.backfillProgress).toBeNull();
expect(payload).toBeNull();
// More than one slice, every one before the last continued, and the
// rate-limited one among them.
expect(slices.length).toBeGreaterThan(2);
expect(slices.slice(0, -1).every((s) => s.continued === 1)).toBe(true);
expect(fake.faults.size).toBe(0);
expect(slices.map((s) => s.slice)).toEqual(slices.map((_, i) => i));
// Every reading exactly once.
const rows = await prisma.measurement.count({
where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" },
});
expect(rows).toBe(POINTS);
expect(slices.reduce((sum, s) => sum + s.imported, 0)).toBe(POINTS);
const versions = await prisma.measurement.aggregate({
where: { userId: USER, type: "PULSE", source: "GOOGLE_HEALTH" },
_max: { syncVersion: true },
_min: { syncVersion: true },
});
expect(versions._max.syncVersion).toBe(versions._min.syncVersion);
// Each slice re-reads only the reading on its edge (plus the hourly
// sync's own window), not the history before it.
report(
`requests=${fake.heartRateRequests} served=${fake.servedPoints} ` +
`points=${POINTS} slices=${slices.length}`,
);
// The heap did not grow with the history.
const growth = peak - (anchor ?? 0);
report(
`heap growth after the second slice: ${mb(growth)} MB over ${samples} ` +
`samples (budget ${mb(GROWTH_BUDGET_BYTES)} MB, ceiling ` +
`${mb(v8.getHeapStatistics().heap_size_limit)} MB, peak ${mb(peak)} MB)`,
);
expect(samples).toBeGreaterThan(5);
expect(growth).toBeLessThan(GROWTH_BUDGET_BYTES);
}, 3_600_000);
});
@@ -57,6 +57,8 @@ const fake = vi.hoisted(() => ({
throttledPages: new Set<number>(),
/** Revoke the current access token when this heart-rate page is asked. */
revokeAtPage: -1,
/** Answer every heart-rate request with a 500. */
failHeartRate: false,
/** Hold the request for this heart-rate page until `release` is called. */
holdAtPage: -1,
release: null as null | (() => void),
@@ -138,13 +140,24 @@ vi.mock("@/lib/safe-fetch", async (importOriginal) => {
{ "retry-after": "0" },
);
}
if (fake.failHeartRate) {
return json({ error: { code: 500, status: "INTERNAL" } }, 500);
}
if (page === fake.holdAtPage) {
fake.holdAtPage = -1;
await fake.held;
}
const end = Math.min(fake.heartRatePoints, (page + 1) * 10);
// Newest first, one point a minute; a resumed walk's `<=` edge
// filter starts the list at the edge.
const edge = /physical_time <= "([^"]+)"/.exec(
u.searchParams.get("filter") ?? "",
)?.[1];
const first = edge
? Math.ceil((fake.now - Date.parse(edge)) / 60_000)
: 0;
const end = Math.min(fake.heartRatePoints, first + (page + 1) * 10);
const dataPoints = [];
for (let g = page * 10; g < end; g++) {
for (let g = first + page * 10; g < end; g++) {
dataPoints.push({
name: `users/me/dataTypes/heart-rate/dataPoints/${1_000_000 + g}`,
heartRate: {
@@ -221,6 +234,7 @@ beforeEach(async () => {
fake.throttledPages = new Set();
fake.revokeAtPage = -1;
fake.holdAtPage = -1;
fake.failHeartRate = false;
fake.release = null;
fake.held = null;
fake.requestsByPath = new Map();
@@ -398,8 +412,19 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
);
expect(done).not.toContain("fetchHeartRate");
// And the heart-rate stream keeps the edge it reached: the oldest of
// the thirty samples it wrote.
expect(
(afterFirst?.backfillProgress as { cursors: Record<string, unknown> })
.cursors.fetchHeartRate,
).toMatchObject({
at: new Date(fake.now - 29 * 60_000).toISOString(),
pages: 3,
});
// Second attempt, the queued slice: the finished collections are not
// read again.
// read again, and the stream starts at its edge, not at its newest page:
// the 31 samples from the edge on are four pages, not six.
fake.requestsByPath = new Map();
await runGoogleHealthBackfillForUser(USER, undefined, 1);
@@ -408,7 +433,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
expect(
fake.requestsByPath.get("steps/dataPoints:dailyRollUp"),
).toBeUndefined();
expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(6);
expect(fake.requestsByPath.get("heart-rate/dataPoints")).toBe(4);
expect(await pulseRows()).toBe(60);
const afterSecond =
await getPrismaClient().googleHealthConnection.findUnique({
@@ -418,7 +443,7 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
expect(afterSecond?.backfillProgress).toBeNull();
});
it("keeps failing a backfill whose slice finished nothing new", async () => {
it("continues a slice that only moved the stream's edge, and fails one that moved nothing", async () => {
const { runGoogleHealthBackfillForUser } =
await import("@/lib/jobs/google-health-backfill");
@@ -428,13 +453,29 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
await runGoogleHealthBackfillForUser(USER, stopAtThirdPage);
expect(sent.jobs).toHaveLength(1);
// The next slice cannot get through the heart-rate stream either: no
// collection finishes, so this one is stuck and says so.
// The next slice finishes no collection either, but it moves the edge
// thirty samples further back: progress, so it continues.
fake.requestsByPath = new Map();
await expect(
runGoogleHealthBackfillForUser(USER, stopAtThirdPage, 1),
).rejects.toThrow(/incomplete/);
expect(sent.jobs).toHaveLength(1);
).resolves.toMatchObject({ continued: true });
expect(sent.jobs).toHaveLength(2);
// A slice whose every heart-rate request fails moves nothing: it is
// stuck, says where, and queues nothing.
const { GOOGLE_HEALTH_TRANSIENT_RETRY } =
await import("@/lib/google-health/client");
const baseDelay = GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs;
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = 0;
fake.failHeartRate = true;
try {
await expect(
runGoogleHealthBackfillForUser(USER, undefined, 2),
).rejects.toThrow(/no progress in slice 2; fetchHeartRate:failed/);
} finally {
GOOGLE_HEALTH_TRANSIENT_RETRY.baseDelayMs = baseDelay;
}
expect(sent.jobs).toHaveLength(2);
const row = await getPrismaClient().googleHealthConnection.findUnique({
where: { userId: USER },
});
@@ -459,27 +500,32 @@ describe("Google Health backfill under rate limits and token expiry (#1194)", ()
}
expect(fake.holdAtPage).toBe(-1);
const requestsBefore = [...fake.requestsByPath.values()].reduce(
(a, b) => a + b,
0,
);
const total = () =>
[...fake.requestsByPath.values()].reduce((a, b) => a + b, 0);
const requestsBefore = total();
const manual = await syncUserGoogleHealth(USER);
expect(manual.busy).toBe(true);
const hourly = await handleGoogleHealthSync([] as never);
expect(hourly).toMatchObject({
ok: true,
did: { users_skipped: 1, users_parked: 0, users_failed: 0 },
// The hourly poll waits for the account instead of skipping it: it does
// nothing while the backfill holds the lock ...
let hourlySettled = false;
const hourly = handleGoogleHealthSync([] as never).finally(() => {
hourlySettled = true;
});
const requestsAfter = [...fake.requestsByPath.values()].reduce(
(a, b) => a + b,
0,
);
expect(requestsAfter).toBe(requestsBefore);
await new Promise((r) => setTimeout(r, 2_500));
expect(hourlySettled).toBe(false);
expect(total()).toBe(requestsBefore);
// ... and runs once the backfill lets go.
fake.release?.();
await backfill;
const requestsAfterBackfill = total();
expect(await hourly).toMatchObject({
ok: true,
did: { users_skipped: 0, users_parked: 0, users_failed: 0 },
});
expect(total()).toBeGreaterThan(requestsAfterBackfill);
expect(await pulseRows()).toBe(60);
expect(await ledgerState()).toBe("connected");
});
}, 60_000);
});