perf(analytics): keep closed blocks of past readings cached across syncs

Every sync marked all of an account's analytics cells stale, so the slice
re-read the pre-fold remainder (every reading older than five years,
folded per source and day, the slice's largest statement on a long
history) and the day view re-read its past history blocks, although a
sync of today's readings cannot change either.

A cache key can now name the instant its value's readings end before
(closedBlockKeyPart). invalidateUserMeasurements takes touchedFrom, the
earliest measuredAt a write created, changed or removed, and leaves such
blocks in place when it is not earlier; without it every block goes as
before. The batch route passes it from the identities it wrote. The
pre-fold remainder moves into its own module and is cached as a closed
block; the day view's past history blocks carry their end, with a day of
margin for a night across midnight.

The batch route's two up-front reads move into batch-stored-rows.ts with
the once-per-batch key rule they share.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:24:59 +02:00
1 parent 901cd884e8
commit f5d6710670
12 files changed
+493 -279

No files matched your search

@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
closedBlockKeyPart,
closedBlockUntouched,
invalidateUserMeasurements,
} from "../invalidate";
import { caches, type ServerCache } from "../server-cache";
const analytics = caches.analytics as ServerCache<{ v: number }>;
const USER = "u-closed";
const SPLIT = Date.UTC(2021, 9, 22);
const closedKey = `${USER}|pre-fold-remainder|${closedBlockKeyPart(SPLIT)}|x`;
const openKey = `${USER}|dashboard-snapshot|en`;
describe("closed analytics blocks", () => {
beforeEach(() => {
analytics.deleteByPrefix(`${USER}|`);
analytics.set(closedKey, { v: 1 });
analytics.set(openKey, { v: 2 });
});
it("reads the instant a closed block ends before", () => {
expect(closedBlockUntouched(closedKey, new Date(SPLIT))).toBe(true);
expect(closedBlockUntouched(closedKey, new Date(SPLIT - 1))).toBe(false);
expect(closedBlockUntouched(openKey, new Date(SPLIT))).toBe(false);
});
it("survives a write of newer readings, evicting or marking stale", () => {
for (const evict of [false, true]) {
invalidateUserMeasurements(USER, {
evict,
touchedFrom: new Date(SPLIT + 86_400_000),
});
expect(analytics.get(closedKey)).toEqual({ v: 1 });
expect(analytics.get(openKey)).toBeNull();
analytics.set(openKey, { v: 2 });
}
});
it("goes with a write that reaches before it, or does not say", () => {
invalidateUserMeasurements(USER, { touchedFrom: new Date(SPLIT - 1) });
expect(analytics.get(closedKey)).toBeNull();
analytics.set(closedKey, { v: 1 });
invalidateUserMeasurements(USER);
expect(analytics.get(closedKey)).toBeNull();
});
});
+39 -3
View File
@@ -48,6 +48,30 @@ 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.
*
@@ -74,16 +98,28 @@ export function invalidateUserDashboardSnapshot(userId: string): void {
*/
export function invalidateUserMeasurements(
userId: string,
opts?: { evict?: boolean },
opts?: {
evict?: boolean;
/**
* The earliest `measuredAt` the write created, changed or removed. With
* it, closed blocks of older readings (`closedBlockKeyPart`) stay.
* Leave it out unless the caller knows every row it touched.
*/
touchedFrom?: Date;
},
): void {
// A coverage probe already running read the set before this write; the
// next reader must not join it.
forgetCoverageProbe(userId);
const touchedFrom = opts?.touchedFrom;
const spare = touchedFrom
? (key: string) => closedBlockUntouched(key, touchedFrom)
: undefined;
// The `${userId}|` prefix covers the slim / thick analytics cells, the
// iOS summary cell, AND the v1.7.0 dashboard snapshot
// (`${userId}|dashboard-snapshot`) in one pass.
if (opts?.evict) {
caches.analytics.deleteByPrefix(`${userId}|`);
caches.analytics.deleteByPrefix(`${userId}|`, spare);
} else {
// v1.12.7 — mark stale rather than hard-evict. Measurement writes are
// the highest-frequency dirty signal (every iOS Apple-Health sync
@@ -94,7 +130,7 @@ export function invalidateUserMeasurements(
// summary cells read via plain `cached` are unaffected: a marked-stale
// entry has `expiresAt === now`, so their next read is a clean miss and
// rebuilds fresh — identical to the old evict for those keys.
caches.analytics.markStaleByPrefix(`${userId}|`);
caches.analytics.markStaleByPrefix(`${userId}|`, spare);
}
caches.achievements.deleteByPrefix(userId);
caches.workouts.deleteByPrefix(`${userId}|`);
+11 -11
View File
@@ -88,15 +88,15 @@ export interface ServerCacheStats {
* fence — invalidation flips `invalidated` and detaches the slot from
* `pending`, so (a) the builder's `.then` skips the `set()` and (b) the
* next read starts a fresh build instead of joining the stale one.
* Callers already awaiting the detached promise still receive its value
* (equivalent to having read a moment before the write); it just never
* enters the cache.
* Callers already awaiting the detached promise still get its value (as
* if read just before the write); it never enters the cache.
*/
interface PendingBuild<T> {
promise: Promise<T>;
invalidated: boolean;
}
type KeyFilter = (key: string) => boolean; // closed blocks, `invalidate.ts`
export class ServerCache<T> {
private readonly map = new Map<
string,
@@ -124,9 +124,9 @@ export class ServerCache<T> {
}
/** Fence + detach every in-flight builder under `prefix`. */
private detachPendingByPrefix(prefix: string): void {
private detachPendingByPrefix(prefix: string, spare?: KeyFilter): void {
for (const [key, slot] of this.pending) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
slot.invalidated = true;
this.pending.delete(key);
}
@@ -283,17 +283,17 @@ export class ServerCache<T> {
* so the next read is a clean miss — identical to the old evict
* behaviour).
*/
markStaleByPrefix(prefix: string): number {
markStaleByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders too: a refresh that started
// before the write would otherwise complete after this mark and
// re-cache the pre-write body as FRESH for the full TTL. Detaching
// keeps the SWR contract — the marked entry still serves stale, and
// the next read kicks off a fresh (post-write) recompute.
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
entry.expiresAt = now;
marked += 1;
}
@@ -320,12 +320,12 @@ export class ServerCache<T> {
* entire user-bucket from a cache without having to enumerate every
* possible cache-key variant the user might have warmed.
*/
deleteByPrefix(prefix: string): number {
deleteByPrefix(prefix: string, spare?: KeyFilter): number {
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
this.detachPendingByPrefix(prefix);
this.detachPendingByPrefix(prefix, spare);
let removed = 0;
for (const key of this.map.keys()) {
if (key.startsWith(prefix)) {
if (key.startsWith(prefix) && !spare?.(key)) {
const entry = this.map.get(key);
this.map.delete(key);
this.totalWeight -= entry?.weight ?? 0;