Files
HealthLog/tests/integration/google-health-sleep-overlap.test.ts
T
Marc-André Bombeck 46047e57c0 fix(google-health): keep long backfills out of reauth parking and settle overlapping sleep segments
A history backfill carried one access token through walks that outlive it.
After an hour every request answered 401, which classifies as a revoked
grant, so the account was parked at error_reauth with a refresh token that
was still valid, and every later run skipped it until a manual reconnect.
Running beside the hourly sync, the backfill also pushed the account over
Google's per-minute quota, and each 429 failed its whole collection.

- Walks take a token source that renews the access token before it expires
  and once more when Google refuses it mid-walk.
- A 429 is waited out (Retry-After, RetryInfo, else a doubling backoff) and
  the request repeated; it is never a reauth.
- error_reauth only short-circuits a run when the token endpoint refused the
  grant (needsReauth). A connection parked by a data request's 401 refreshes
  on its next run and clears itself when that run completes.
- One run per account at a time: syncs, the backfill and the sleep repair
  share an advisory lock. The hourly poll skips a held account, a manual
  sync answers 409 sync_in_progress, the backfill waits a bounded time.
- A backfill attempt records each collection it finishes cleanly
  (backfill_progress) and the next attempt resumes with the rest.
- The incremental watermark is stamped with the cycle's start, not its end.

Overlapping sleep segments that share a stage and an end share the natural
key (type, measuredAt, source, sleepStage). A re-score that moved one row
onto a sibling's slot threw P2002 and failed the sync on every run.

- A batch keeps one reading per natural key, chosen the same way each time
  (longer segment, then smaller externalId).
- A move that still collides is settled by reconcileExternalMeasurement,
  which retires the stale row, instead of failing the sync.
- Updates run before inserts, and the key-format rescue no longer re-keys a
  row this batch is moving.
- The sleep replace-by-window protects every session's fresh rows, so two
  overlapping sessions no longer tombstone and resurrect each other hourly.

Refs #1194, #1195
2026-10-10 12:59:25 +02:00

259 lines
9.0 KiB
TypeScript

/**
* #1195 — overlapping Google Health sleep segments, synced against Postgres.
*
* What went wrong. A measurement row has two unique identities: its
* externalId, and its natural key `(type, measuredAt, source, sleepStage)`.
* A sleep segment is stored at its END, so two segments of the same stage
* that end at the same instant share a natural key even though Google gives
* them different externalIds. Google reports such segments: two sessions over
* one stretch of a night, and segments it re-scores so that one ends where a
* sibling already ends. The sync updated rows by externalId one by one, the
* update that moved a row onto a slot another row held threw P2002, and the
* whole sync was marked failed, every hour, for a sync that had written
* everything else.
*
* What this pins:
* - a re-score that moves a segment onto a sibling's slot is a clean sync,
* with one row left on that slot carrying the longer segment;
* - two sessions reporting the same segments do not double the night, and
* re-syncing them leaves every row untouched (no tombstone churn, no
* `syncVersion` bump), so the slot does not change hands every hour;
* - the night's asleep total is the union of the overlapping segments,
* never their sum.
*/
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
const USER = "gh-sleep-overlap-owner";
const TZ = "Europe/Berlin";
const fake = vi.hoisted(() => ({
sessions: [] as unknown[],
}));
vi.mock("@/lib/safe-fetch", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/safe-fetch")>();
return {
...actual,
safeFetch: async (url: string) => {
const u = new URL(url);
const body = u.pathname.endsWith("/dataTypes/sleep/dataPoints")
? { dataPoints: fake.sessions }
: {};
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
},
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
/** The night under test: 22:00 UTC two days ago to 06:00 UTC yesterday. */
const NIGHT_START = (() => {
const d = new Date();
d.setUTCHours(22, 0, 0, 0);
return d.getTime() - 2 * 24 * 60 * 60 * 1000;
})();
const at = (minutes: number): string =>
new Date(NIGHT_START + minutes * 60_000).toISOString();
function segment(type: string, from: number, to: number) {
return { type, startTime: at(from), endTime: at(to) };
}
function session(
id: string,
stages: Array<ReturnType<typeof segment>>,
): unknown {
const starts = stages.map((s) => s.startTime).sort();
const ends = stages.map((s) => s.endTime).sort();
return {
name: `users/me/dataTypes/sleep/dataPoints/${id}`,
sleep: {
interval: { startTime: starts[0], endTime: ends[ends.length - 1] },
stages,
},
};
}
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
const { encrypt } = await import("@/lib/crypto");
await prisma.user.create({
data: { id: USER, username: USER, timezone: TZ },
});
await prisma.googleHealthConnection.create({
data: {
userId: USER,
googleUserId: `google-${USER}`,
accessToken: encrypt("access"),
refreshToken: encrypt("refresh"),
tokenExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
fake.sessions = [];
}, 120_000);
afterAll(async () => {
await truncateAllTables(getPrismaClient());
});
async function sync() {
const { syncUserGoogleHealth } = await import("@/lib/google-health/sync");
return syncUserGoogleHealth(USER);
}
async function sleepRows() {
return getPrismaClient().measurement.findMany({
where: { userId: USER, type: "SLEEP_DURATION" },
select: {
id: true,
value: true,
measuredAt: true,
sleepStage: true,
source: true,
externalId: true,
deletedAt: true,
syncVersion: true,
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
});
}
async function asleepMinutes(): Promise<number> {
const { reconstructSleepNights } =
await import("@/lib/analytics/sleep-night");
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
const nights = reconstructSleepNights(live, TZ);
return nights.reduce((sum, n) => sum + n.asleepMinutes, 0);
}
describe("Google Health overlapping sleep segments (#1195)", () => {
it("lands a re-score that moves a segment onto a sibling's slot without failing the sync", async () => {
// First scoring: two LIGHT segments, the second starting inside the
// first, ending at different instants.
fake.sessions = [
session("night", [
segment("LIGHT", 0, 60),
segment("LIGHT", 30, 120),
segment("DEEP", 120, 180),
]),
];
const first = await sync();
expect(first.failed).toBe(false);
// Re-score: the first segment now runs to 120, onto the end the second
// segment already holds. Same stage, same end: one natural key.
fake.sessions = [
session("night", [
segment("LIGHT", 0, 120),
segment("LIGHT", 30, 120),
segment("DEEP", 120, 180),
]),
];
const second = await sync();
expect(second.failed).toBe(false);
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
const lightAt120 = live.filter(
(r) => r.sleepStage === "CORE" && r.measuredAt.toISOString() === at(120),
);
expect(lightAt120).toHaveLength(1);
// The longer segment holds the slot.
expect(lightAt120[0]!.value).toBe(120);
expect(lightAt120[0]!.externalId).toContain(`:sleep:${at(0)}`);
// 120 minutes of light sleep (00:00-02:00) and 60 of deep: the overlap
// is counted once.
expect(await asleepMinutes()).toBe(180);
// The same answer again is a no-op: the slot keeps its row.
const before = await sleepRows();
const third = await sync();
expect(third.failed).toBe(false);
expect(await sleepRows()).toEqual(before);
});
it("keeps both readings when a segment moves off a slot a new segment takes", async () => {
fake.sessions = [
session("night", [segment("LIGHT", 0, 60), segment("DEEP", 90, 150)]),
];
expect((await sync()).failed).toBe(false);
// Re-score: the light segment now runs to 90, and a new light segment
// ends at 60, the slot the first one is leaving.
fake.sessions = [
session("night", [
segment("LIGHT", 0, 90),
segment("LIGHT", 30, 60),
segment("DEEP", 90, 150),
]),
];
expect((await sync()).failed).toBe(false);
const light = (await sleepRows())
.filter((r) => r.deletedAt === null && r.sleepStage === "CORE")
.map((r) => [r.measuredAt.toISOString(), r.value, r.externalId]);
expect(light).toEqual([
[at(60), 30, expect.stringContaining(`:sleep:${at(30)}`)],
[at(90), 90, expect.stringContaining(`:sleep:${at(0)}`)],
]);
});
it("keeps one row per segment when two sessions report the same night", async () => {
const stages = [
segment("DEEP", 60, 120),
segment("REM", 120, 150),
segment("LIGHT", 150, 240),
];
fake.sessions = [session("phone", stages), session("watch", stages)];
const first = await sync();
expect(first.failed).toBe(false);
const afterFirst = await sleepRows();
expect(afterFirst.filter((r) => r.deletedAt === null)).toHaveLength(3);
expect(await asleepMinutes()).toBe(180);
// Hour after hour the same two sessions come back. Nothing moves:
// neither session's window clears the other's rows, and the slot is not
// handed from one session's externalId to the other's.
for (let i = 0; i < 2; i++) {
const again = await sync();
expect(again.failed).toBe(false);
expect(await sleepRows()).toEqual(afterFirst);
}
expect(await asleepMinutes()).toBe(180);
});
it("settles overlapping sessions that disagree, without failing and without inflating the night", async () => {
// The watch's session ends its light segment later than the phone's.
fake.sessions = [
session("phone", [segment("LIGHT", 0, 90), segment("DEEP", 90, 150)]),
session("watch", [segment("LIGHT", 10, 90), segment("DEEP", 90, 160)]),
];
expect((await sync()).failed).toBe(false);
// Re-score on the phone: its deep segment now ends where the watch's
// does, so both sessions put a DEEP segment on one slot.
fake.sessions = [
session("phone", [segment("LIGHT", 0, 90), segment("DEEP", 90, 160)]),
session("watch", [segment("LIGHT", 10, 90), segment("DEEP", 90, 160)]),
];
expect((await sync()).failed).toBe(false);
expect((await sync()).failed).toBe(false);
const live = (await sleepRows()).filter((r) => r.deletedAt === null);
const keys = live.map(
(r) => `${r.sleepStage}|${r.measuredAt.toISOString()}`,
);
expect(new Set(keys).size).toBe(keys.length);
// Light 00:00-01:30 and deep 01:30-02:40: 160 minutes asleep.
expect(await asleepMinutes()).toBe(160);
});
});