mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(sync): place a start=now cursor a minute behind the present
updatedAt is stamped when a write is built, not when it commits, so a transaction in flight when the cursor was handed out could commit a row stamped before it, and a watermark at serverNow skipped that row for good. The watermarks now sit one minute back; client upserts are idempotent, so the overlap is harmless. The token's issue time stays serverNow.
This commit is contained in:
1 parent
126bab8c00
commit
01884e91fa
8 files changed
+79
-26
No files matched your search
+12
-8
@@ -4515,16 +4515,20 @@ paths:
|
||||
- in: query
|
||||
name: start
|
||||
schema:
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
|
||||
every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
|
||||
from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
|
||||
`cursor` (422). A server without it ignores the parameter and starts from the oldest row."
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before
|
||||
`serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields
|
||||
what changes from then on, plus anything written in that last minute (a write stamped before `serverNow`
|
||||
can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless),
|
||||
including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422).
|
||||
A server without it ignores the parameter and starts from the oldest row."
|
||||
type: string
|
||||
const: now
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in
|
||||
every domain, in the same opaque format as any other page. Paging forward from it yields only what changes
|
||||
from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with
|
||||
`cursor` (422). A server without it ignores the parameter and starts from the oldest row."
|
||||
description: "`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before
|
||||
`serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what
|
||||
changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can
|
||||
commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including
|
||||
`measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server
|
||||
without it ignores the parameter and starts from the oldest row."
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* `GET /api/sync/changes?start=now` (#1339) — a cursor at the present.
|
||||
*
|
||||
* The answer is an empty page whose cursor sits at `serverNow` in every
|
||||
* domain, in the feed's own opaque format, and nothing is read: the point is
|
||||
* The answer is an empty page whose cursor sits a minute before `serverNow`
|
||||
* in every domain (a write stamped before it and committed after it is not
|
||||
* skipped), in the feed's own opaque format, and nothing is read: the point is
|
||||
* to skip the history walk, so a read here would be the bug. The database is
|
||||
* deliberately not mocked and not reachable: any read would fail the request,
|
||||
* so the 200 below is itself the proof that none happened.
|
||||
@@ -54,7 +55,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("GET /api/sync/changes?start=now", () => {
|
||||
it("answers an empty page with a cursor at serverNow in every domain", async () => {
|
||||
it("answers an empty page with a cursor a minute before serverNow in every domain", async () => {
|
||||
const before = Date.now();
|
||||
const res = await GET(req("?start=now"));
|
||||
const after = Date.now();
|
||||
@@ -80,7 +81,10 @@ describe("GET /api/sync/changes?start=now", () => {
|
||||
const cursor = decodeCursor(data.cursor);
|
||||
expect(cursor).not.toBeNull();
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor![domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
|
||||
expect(cursor![domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
expect(decodeCursorIssuedAt(data.cursor)).toBe(nowMs);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,9 @@
|
||||
* cycle tracking off gets both blocks empty (never a 403 — see the
|
||||
* gate note in the handler) and their cursor watermarks untouched.
|
||||
*
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits at
|
||||
* `serverNow` in every domain, in the same opaque format, so a client that
|
||||
* `start=now` (#1339) answers an empty page whose cursor sits a minute
|
||||
* before `serverNow` in every domain (`START_NOW_OVERLAP_MS`), in the same
|
||||
* opaque format, so a client that
|
||||
* only wants what changes from now on (the Apple Health write-back) pages
|
||||
* forward from there instead of draining the whole history first.
|
||||
*
|
||||
|
||||
@@ -256,7 +256,7 @@ const syncChangesQuery = z
|
||||
.literal("now")
|
||||
.optional()
|
||||
.describe(
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits at `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields only what changes from then on, including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
"`now` answers an empty page (every domain empty, `hasMore: false`) whose `cursor` sits one minute before `serverNow` in every domain, in the same opaque format as any other page. Paging forward from it yields what changes from then on, plus anything written in that last minute (a write stamped before `serverNow` can commit after it, and is not skipped; upserts are idempotent, so a row seen twice is harmless), including `measurementMirror`, without walking the history first. Mutually exclusive with `cursor` (422). A server without it ignores the parameter and starts from the oldest row.",
|
||||
),
|
||||
limit: z.coerce
|
||||
.number()
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
decodeCursor,
|
||||
decodeCursorIssuedAt,
|
||||
encodeCursorAtNow,
|
||||
START_NOW_OVERLAP_MS,
|
||||
SYNC_DOMAINS,
|
||||
type SyncCursor,
|
||||
} from "../cursor";
|
||||
@@ -138,12 +139,18 @@ describe("cursor issue time (sync reset)", () => {
|
||||
});
|
||||
|
||||
describe("encodeCursorAtNow", () => {
|
||||
it("places every domain at the instant, before any id in that millisecond", () => {
|
||||
it("places every domain a minute back, before any id in that millisecond", () => {
|
||||
const nowMs = 1_790_000_000_123;
|
||||
const token = encodeCursorAtNow(nowMs);
|
||||
const cursor = decodeCursor(token);
|
||||
// A write stamped before the instant but committed after it is still
|
||||
// ahead of the watermark.
|
||||
expect(START_NOW_OVERLAP_MS).toBe(60_000);
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
expect(cursor?.[domain], domain).toEqual({ updatedAtMs: nowMs, id: "0" });
|
||||
expect(cursor?.[domain], domain).toEqual({
|
||||
updatedAtMs: nowMs - 60_000,
|
||||
id: "0",
|
||||
});
|
||||
}
|
||||
expect(decodeCursorIssuedAt(token)).toBe(nowMs);
|
||||
// Every cuid sorts after the tie-breaker, so a row in the same
|
||||
|
||||
@@ -110,8 +110,8 @@ function emptyChanges(): SyncChangesBody["changes"] {
|
||||
}
|
||||
|
||||
/**
|
||||
* `start=now` (#1339): an empty page with a cursor at `serverNow` in every
|
||||
* domain, for a client that wants changes from now on (the Apple Health
|
||||
* `start=now` (#1339): an empty page with a cursor a minute before
|
||||
* `serverNow` in every domain (`encodeCursorAtNow`), for a client that wants changes from now on (the Apple Health
|
||||
* write-back) without paging through the history first. `hasMore` is false:
|
||||
* the client is caught up as of `serverNow` by definition.
|
||||
*/
|
||||
|
||||
+18
-7
@@ -103,17 +103,28 @@ export function encodeCursor(cursor: SyncCursor, issuedAtMs?: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* A token positioned at `nowMs` in every domain (#1339), the same opaque
|
||||
* format a drained page hands out: paging forward from it yields only what
|
||||
* changes from now on, with no walk through the history first. The id `"0"`
|
||||
* sorts before every cuid, so a row stamped in the very millisecond is
|
||||
* delivered rather than skipped; the client's upserts are idempotent, so an
|
||||
* overlap costs nothing and a gap would.
|
||||
* How far behind the present a `start=now` cursor is placed. `updatedAt` is
|
||||
* stamped by the application when the write is built, not when it commits, so
|
||||
* a transaction in flight at `nowMs` can commit a row stamped before it after
|
||||
* the cursor was handed out; a watermark at `nowMs` would skip that row for
|
||||
* good. A minute covers any write the app keeps open.
|
||||
*/
|
||||
export const START_NOW_OVERLAP_MS = 60_000;
|
||||
|
||||
/**
|
||||
* A token positioned just before `nowMs` in every domain (#1339), the same
|
||||
* opaque format a drained page hands out: paging forward from it yields what
|
||||
* changes from now on, with no walk through the history first. The watermarks
|
||||
* sit `START_NOW_OVERLAP_MS` back and the id `"0"` sorts before every cuid, so
|
||||
* a row stamped in that last minute is delivered rather than skipped; the
|
||||
* client's upserts are idempotent, so an overlap costs nothing and a gap would.
|
||||
* The issue time stays `nowMs`: it is when the token was handed out.
|
||||
*/
|
||||
export function encodeCursorAtNow(nowMs: number): string {
|
||||
const cursor: SyncCursor = {};
|
||||
const watermark = nowMs - START_NOW_OVERLAP_MS;
|
||||
for (const domain of SYNC_DOMAINS) {
|
||||
cursor[domain] = { updatedAtMs: nowMs, id: "0" };
|
||||
cursor[domain] = { updatedAtMs: watermark, id: "0" };
|
||||
}
|
||||
return encodeCursor(cursor, nowMs);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
* and switching a token's write-back on afterwards must still deliver all of
|
||||
* that token's readings on `measurementMirror`, because the switch happens
|
||||
* after the cursor.
|
||||
*
|
||||
* The cursor sits a minute behind the present: `updatedAt` is stamped when a
|
||||
* write is built, not when it commits, so a row stamped just before the
|
||||
* cursor can become visible just after it, and must not be skipped.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
@@ -81,6 +85,8 @@ describe("a cursor that starts now", () => {
|
||||
ingestTokenId: scale.tokenId,
|
||||
measuredAt: new Date(Date.UTC(2026, 9, 1 + i, 6)),
|
||||
externalId: `old-${i}`,
|
||||
// Written well before the cursor's minute of overlap.
|
||||
updatedAt: new Date(Date.now() - 120_000),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -138,6 +144,26 @@ describe("a cursor that starts now", () => {
|
||||
expect(after.changes.measurementMirror.upserts).toEqual([]);
|
||||
});
|
||||
|
||||
it("delivers a row stamped before the cursor that commits after it", async () => {
|
||||
const start = await pull({ start: "now" });
|
||||
// A write in flight when the cursor was handed out: its stamp predates
|
||||
// the cursor, its commit does not.
|
||||
const late = await makeMeasurement(
|
||||
{ id: userId },
|
||||
{
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: scale.tokenId,
|
||||
measuredAt: new Date(Date.UTC(2026, 10, 4, 6)),
|
||||
externalId: "late",
|
||||
updatedAt: new Date(Date.now() - 30_000),
|
||||
},
|
||||
);
|
||||
const next = await pull({ cursor: start.cursor! });
|
||||
expect(next.changes.measurements.upserts.map((r) => r.id)).toEqual([
|
||||
late.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("refuses start=now beside a cursor", async () => {
|
||||
const res = await callRoute(syncChanges, {
|
||||
method: "GET",
|
||||
|
||||
Reference in new issue
Block a user