diff --git a/src/lib/analytics/pre-fold-remainder.ts b/src/lib/analytics/pre-fold-remainder.ts index ec48d8d3e..af673c6af 100644 --- a/src/lib/analytics/pre-fold-remainder.ts +++ b/src/lib/analytics/pre-fold-remainder.ts @@ -28,7 +28,7 @@ import { createHash } from "node:crypto"; import { Prisma } from "@/generated/prisma/client"; -import { closedBlockKeyPart } from "@/lib/cache/invalidate"; +import { closedBlockKeyPart } from "@/lib/cache/closed-block"; import { caches, type ServerCache } from "@/lib/cache/server-cache"; import { prisma } from "@/lib/db"; import { dayWeightedRowsSql, foldMeanSql } from "@/lib/measurements/day-mean"; diff --git a/src/lib/cache/__tests__/closed-block-invalidation.test.ts b/src/lib/cache/__tests__/closed-block-invalidation.test.ts index 4aec873b1..091420728 100644 --- a/src/lib/cache/__tests__/closed-block-invalidation.test.ts +++ b/src/lib/cache/__tests__/closed-block-invalidation.test.ts @@ -1,10 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { - closedBlockKeyPart, - closedBlockUntouched, - invalidateUserMeasurements, -} from "../invalidate"; +import { closedBlockKeyPart, closedBlockUntouched } from "../closed-block"; +import { invalidateUserMeasurements } from "../invalidate"; import { caches, type ServerCache } from "../server-cache"; const analytics = caches.analytics as ServerCache<{ v: number }>; diff --git a/src/lib/cache/closed-block.ts b/src/lib/cache/closed-block.ts new file mode 100644 index 000000000..6a4728e94 --- /dev/null +++ b/src/lib/cache/closed-block.ts @@ -0,0 +1,23 @@ +/** + * The key part that marks an analytics entry as a closed block: a value that + * depends only on readings measured before `before`. Such a block is the + * same however many syncs land after it, as long as none of them touches a + * reading that old, so a measurement write that says how far back it + * reached (`touchedFrom`) leaves it in place. Every other invalidation, and + * every write that does not say, drops it as before. + */ +export function closedBlockKeyPart(before: Date | number): string { + const ms = typeof before === "number" ? before : before.getTime(); + return `before:${ms}`; +} + +const CLOSED_BLOCK_RE = /\|before:(\d+)(?:\||$)/; + +/** + * Whether `key` is a closed block that a write reaching back to + * `touchedFrom` cannot have changed. Exported for its test. + */ +export function closedBlockUntouched(key: string, touchedFrom: Date): boolean { + const match = CLOSED_BLOCK_RE.exec(key); + return match !== null && Number(match[1]) <= touchedFrom.getTime(); +} diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 826026763..0081f1eea 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -20,6 +20,7 @@ import { forgetCoverageProbe } from "@/lib/rollups/coverage-inflight"; +import { closedBlockUntouched } from "./closed-block"; import { caches } from "./server-cache"; /** @@ -48,30 +49,6 @@ export function invalidateUserDashboardSnapshot(userId: string): void { caches.analytics.deleteByPrefix(dashboardSnapshotCacheKey(userId)); } -/** - * The key part that marks an analytics entry as a closed block: a value that - * depends only on readings measured before `before`. Such a block is the - * same however many syncs land after it, as long as none of them touches a - * reading that old, so a measurement write that says how far back it - * reached (`touchedFrom`) leaves it in place. Every other invalidation, and - * every write that does not say, drops it as before. - */ -export function closedBlockKeyPart(before: Date | number): string { - const ms = typeof before === "number" ? before : before.getTime(); - return `before:${ms}`; -} - -const CLOSED_BLOCK_RE = /\|before:(\d+)(?:\||$)/; - -/** - * Whether `key` is a closed block that a write reaching back to - * `touchedFrom` cannot have changed. Exported for its test. - */ -export function closedBlockUntouched(key: string, touchedFrom: Date): boolean { - const match = CLOSED_BLOCK_RE.exec(key); - return match !== null && Number(match[1]) <= touchedFrom.getTime(); -} - /** * Invalidate every cache that may reflect a user's measurement set. * diff --git a/src/lib/day/notable.ts b/src/lib/day/notable.ts index 5b3704fd4..8a67ab02d 100644 --- a/src/lib/day/notable.ts +++ b/src/lib/day/notable.ts @@ -31,7 +31,7 @@ import type { MeasurementType } from "@/generated/prisma/enums"; import type { DayNotable } from "@/lib/day/contract"; import { readLocalDailyStats, type DailyStats } from "@/lib/day/daily-stats"; -import { closedBlockKeyPart } from "@/lib/cache/invalidate"; +import { closedBlockKeyPart } from "@/lib/cache/closed-block"; import { caches, type ServerCache } from "@/lib/cache/server-cache"; import { prisma } from "@/lib/db"; import { startOfLocalDayKey } from "@/lib/tz/local-day"; diff --git a/src/lib/provenance/__tests__/source-provenance.test.ts b/src/lib/provenance/__tests__/source-provenance.test.ts index 17cc5f773..412d93687 100644 --- a/src/lib/provenance/__tests__/source-provenance.test.ts +++ b/src/lib/provenance/__tests__/source-provenance.test.ts @@ -115,10 +115,10 @@ describe("the provenance query orders like the canonical pick", () => { `ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text`, ), ); + // The shared pick groups to (type, day, source) first and orders the + // groups by the same keys, the UTC day named `d`. expect(squash(canonicalMeasurementsCte(rank))).toContain( - squash( - `ORDER BY "type", date_trunc('day', "measured_at"), (${rank}), "source"::text`, - ), + squash(`ORDER BY "type", d, (${rank}), "source"::text`), ); // Values are bound, never spliced. expect(userId).toBe("u1"); diff --git a/tests/integration/server-cache-routes.test.ts b/tests/integration/server-cache-routes.test.ts index 6e5535fac..64ad40cf2 100644 --- a/tests/integration/server-cache-routes.test.ts +++ b/tests/integration/server-cache-routes.test.ts @@ -117,8 +117,21 @@ function countBuildsPerKey(cache: { return builds; } +/** + * The slice's pre-fold remainder is a cell of its own in the same bucket (a + * closed block, `pre-fold-remainder.ts`), built when the slice takes the + * rollup path; it is counted apart from the route's two cells. + */ +function preFoldBuildsOf(builds: Map, userId: string) { + return [...builds.entries()] + .filter(([k]) => k.startsWith(`${userId}|pre-fold-remainder|`)) + .reduce((n, [, c]) => n + c, 0); +} + function buildsOf(builds: Map, userId: string) { - const entries = [...builds.entries()].filter(([k]) => k.startsWith(userId)); + const entries = [...builds.entries()].filter( + ([k]) => k.startsWith(userId) && !k.includes("|pre-fold-remainder|"), + ); return { defaultBody: entries .filter(([k]) => k.startsWith(`${userId}|default|`)) @@ -230,7 +243,9 @@ describe("v1.4.34 IW-G — server-cache route integration", () => { summaries: 2, keys: 2, }); - expect(caches.analytics.stats().misses).toBe(4); + expect(caches.analytics.stats().misses).toBe( + 4 + preFoldBuildsOf(builds, user.id), + ); // Sanity: the body reflects the new write. const body = (await third.json()) as {