mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
Vendored
+39
-3
@@ -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}|`);
|
||||
|
||||
Vendored
+11
-11
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user