mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(medications): every intake path converges on the slot, and caches cannot resurrect a pre-write body
The external ingest endpoint was the one intake writer that never attributed to the minted band: it stored a bare row beside the pending slot, so an early take through the API showed as the last intake while the slot kept demanding a dose, the ledger recorded an extra plus a miss, and the day tally counted both. It now resolves the band and converges onto the pending row like every other path, the Telegram confirm and skip buttons do the same preferring the reminder's own slot, and the nightly convergence pass heals the pairs this has already produced — one band match, one live anchor row, both days' rollups recomputed. The cache layer fences in-flight builders on invalidation so a read can neither join a pre-write build nor have a background refresh re-cache a pre-write body as fresh. Ad-hoc rows no longer resolve a different slot through the six-hour radius, a bounded early grace credits a take up to an hour before the window without crossing the previous band, day-scale cadences taken early downgrade the slot-day prompt instead of inviting a double dose, the overdue pill ignores the projector's pending mints, created intakes carry the same plausibility bounds as edits, slot moves keep the recorded injection site and dose, both affected days recompute on a moved toggle, deliberate slot-less skips converge instead of orphaning, the coach reads adherence from the ledger, the cards and quick-add run in the user's timezone with the served next-due as the gate, and schedule-revision, wipe and restore writes invalidate the caches they feed.
This commit is contained in:
1 parent
ea29e9cf1b
commit
aaecd141ee
53 files changed
+2550
-202
No files matched your search
+159
@@ -451,6 +451,165 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerCache invalidation vs in-flight builders", () => {
|
||||
it("a read right after an evict does NOT join the pre-write builder", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
let releaseFirst: ((v: string) => void) | null = null;
|
||||
let calls = 0;
|
||||
const builder = vi.fn(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Promise<string>((r) => {
|
||||
releaseFirst = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve("post-write");
|
||||
});
|
||||
|
||||
// Builder 1 starts (reads pre-write state) and is still in flight…
|
||||
const first = cache.wrap("u1|list", builder);
|
||||
// …when a write evicts the key.
|
||||
cache.delete("u1|list");
|
||||
|
||||
// The next read must start a FRESH build, not join the stale one.
|
||||
const second = await cache.wrap("u1|list", builder);
|
||||
expect(second.outcome).toBe("miss");
|
||||
expect(second.value).toBe("post-write");
|
||||
expect(builder).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The detached pre-write builder resolves AFTER the fresh one — its
|
||||
// commit must be discarded, never overwriting the post-write entry.
|
||||
releaseFirst!("pre-write");
|
||||
const firstResult = await first;
|
||||
expect(firstResult.value).toBe("pre-write");
|
||||
expect(cache.get("u1|list")).toBe("post-write");
|
||||
});
|
||||
|
||||
it("deleteByPrefix fences an in-flight builder the same way", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
let release: ((v: string) => void) | null = null;
|
||||
const builder = vi.fn(
|
||||
() =>
|
||||
new Promise<string>((r) => {
|
||||
release = r;
|
||||
}),
|
||||
);
|
||||
const inFlight = cache.wrap("u1|compliance", builder);
|
||||
cache.deleteByPrefix("u1|");
|
||||
release!("pre-write");
|
||||
await inFlight;
|
||||
// The pre-write result never entered the cache.
|
||||
expect(cache.get("u1|compliance")).toBeNull();
|
||||
});
|
||||
|
||||
it("an SWR refresh that completes after an evict does not re-cache the pre-write body", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ServerCache<string>({
|
||||
maxEntries: 4,
|
||||
ttlMs: 1000,
|
||||
staleTtlMs: 600_000,
|
||||
});
|
||||
let releaseRefresh: ((v: string) => void) | null = null;
|
||||
let calls = 0;
|
||||
const builder = vi.fn(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.resolve("v1");
|
||||
if (calls === 2) {
|
||||
return new Promise<string>((r) => {
|
||||
releaseRefresh = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve("post-write");
|
||||
});
|
||||
|
||||
await cache.wrapSwr("u1|list", builder);
|
||||
vi.advanceTimersByTime(1500);
|
||||
|
||||
// Stale read kicks off the detached background refresh (pre-write).
|
||||
const stale = await cache.wrapSwr("u1|list", builder);
|
||||
expect(stale.outcome).toBe("stale");
|
||||
|
||||
// A write evicts the bucket while the refresh is still in flight…
|
||||
cache.deleteByPrefix("u1|");
|
||||
// …then the pre-write refresh completes. Without the fence it would
|
||||
// re-insert the pre-write body as FRESH for the full TTL.
|
||||
releaseRefresh!("pre-write");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(cache.get("u1|list")).toBeNull();
|
||||
const next = await cache.wrapSwr("u1|list", builder);
|
||||
expect(next.outcome).toBe("miss");
|
||||
expect(next.value).toBe("post-write");
|
||||
});
|
||||
|
||||
it("markStaleByPrefix discards an in-flight refresh and keeps SWR semantics", async () => {
|
||||
vi.useFakeTimers();
|
||||
const cache = new ServerCache<string>({
|
||||
maxEntries: 4,
|
||||
ttlMs: 1000,
|
||||
staleTtlMs: 600_000,
|
||||
});
|
||||
let releaseRefresh: ((v: string) => void) | null = null;
|
||||
let calls = 0;
|
||||
const builder = vi.fn(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) return Promise.resolve("v1");
|
||||
if (calls === 2) {
|
||||
return new Promise<string>((r) => {
|
||||
releaseRefresh = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve("post-write");
|
||||
});
|
||||
|
||||
await cache.wrapSwr("u1|k", builder);
|
||||
vi.advanceTimersByTime(1500);
|
||||
const stale = await cache.wrapSwr("u1|k", builder);
|
||||
expect(stale.outcome).toBe("stale");
|
||||
|
||||
// Background write marks the bucket stale mid-refresh; the pre-write
|
||||
// refresh result must not land as a fresh entry.
|
||||
cache.markStaleByPrefix("u1|");
|
||||
releaseRefresh!("pre-write");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// SWR semantics hold: the prior value still serves stale and a NEW
|
||||
// (post-write) recompute is kicked off.
|
||||
const next = await cache.wrapSwr("u1|k", builder);
|
||||
expect(next.outcome).toBe("stale");
|
||||
expect(next.value).toBe("v1");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(builder).toHaveBeenCalledTimes(3);
|
||||
const warm = await cache.wrapSwr("u1|k", builder);
|
||||
expect(warm.outcome).toBe("hit");
|
||||
expect(warm.value).toBe("post-write");
|
||||
});
|
||||
|
||||
it("invalidation between two stampeding readers gives the later reader fresh data", async () => {
|
||||
const cache = new ServerCache<string>({ maxEntries: 4, ttlMs: 60_000 });
|
||||
let release: ((v: string) => void) | null = null;
|
||||
let calls = 0;
|
||||
const builder = vi.fn(() => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Promise<string>((r) => {
|
||||
release = r;
|
||||
});
|
||||
}
|
||||
return Promise.resolve("fresh");
|
||||
});
|
||||
|
||||
const a = cache.wrap("k", builder); // pre-write build in flight
|
||||
cache.delete("k"); // write
|
||||
const b = cache.wrap("k", builder); // post-write read → fresh build
|
||||
release!("stale");
|
||||
const [ra, rb] = await Promise.all([a, b]);
|
||||
expect(ra.value).toBe("stale"); // joined its own (pre-write) build
|
||||
expect(rb.value).toBe("fresh");
|
||||
expect(cache.get("k")).toBe("fresh");
|
||||
});
|
||||
});
|
||||
|
||||
describe("global cache registry", () => {
|
||||
it("exposes the eight blueprint caches as module-scope singletons", () => {
|
||||
expect(caches.analytics).toBeDefined();
|
||||
|
||||
Vendored
+32
@@ -246,6 +246,38 @@ export function invalidateUserInsightsLayout(userId: string): void {
|
||||
caches.insightsLayout.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.9 — full per-user cache eviction for the wipe / restore paths.
|
||||
* A data wipe or a backup restore replaces (or removes) every row the
|
||||
* cached payloads were built on, so every per-user bucket hard-evicts —
|
||||
* a marked-stale SWR cell would keep serving the pre-wipe body.
|
||||
*/
|
||||
export function invalidateUserData(userId: string): void {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
caches.medications.deleteByPrefix(userId);
|
||||
caches.medicationCompliance.deleteByPrefix(`${userId}|`);
|
||||
caches.medicationsIntake.deleteByPrefix(`${userId}|`);
|
||||
caches.achievements.deleteByPrefix(userId);
|
||||
caches.insightsTargets.deleteByPrefix(userId);
|
||||
caches.insightsDerived.deleteByPrefix(`${userId}|`);
|
||||
caches.moodAnalytics.deleteByPrefix(userId);
|
||||
caches.moodInsights.deleteByPrefix(userId);
|
||||
caches.workouts.deleteByPrefix(`${userId}|`);
|
||||
caches.dashboardWidgets.deleteByPrefix(userId);
|
||||
caches.insightsLayout.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.9 — bucket-wide clear for the admin all-user wipe. Every cache
|
||||
* empties; the next read per user is a cold build over the (now empty /
|
||||
* restored) tables. Acceptable for an operation this rare.
|
||||
*/
|
||||
export function invalidateAllCaches(): void {
|
||||
for (const cache of Object.values(caches)) {
|
||||
cache.deleteByPrefix("");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global eviction of the bug-report-status cache.
|
||||
*
|
||||
|
||||
Vendored
+96
-26
@@ -67,12 +67,30 @@ export interface ServerCacheStats {
|
||||
readonly stampedes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.16.9 — single-flight slot with invalidation fencing. A builder that
|
||||
* was in flight when an invalidation (`delete` / `deleteByPrefix` /
|
||||
* `markStaleByPrefix`) hit its key must NOT commit its result: the build
|
||||
* read pre-write data, and committing it after the eviction would
|
||||
* re-cache the pre-write body as fresh for the full TTL. The slot is the
|
||||
* 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.
|
||||
*/
|
||||
interface PendingBuild<T> {
|
||||
promise: Promise<T>;
|
||||
invalidated: boolean;
|
||||
}
|
||||
|
||||
export class ServerCache<T> {
|
||||
private readonly map = new Map<
|
||||
string,
|
||||
{ expiresAt: number; staleUntil: number; value: T }
|
||||
>();
|
||||
private readonly pending = new Map<string, Promise<T>>();
|
||||
private readonly pending = new Map<string, PendingBuild<T>>();
|
||||
private hits = 0;
|
||||
private misses = 0;
|
||||
private evictions = 0;
|
||||
@@ -80,6 +98,59 @@ export class ServerCache<T> {
|
||||
|
||||
constructor(private readonly opts: ServerCacheOptions) {}
|
||||
|
||||
/**
|
||||
* Fence + detach the in-flight builder for one key (no-op when none).
|
||||
* Part of every invalidation op — see `PendingBuild`.
|
||||
*/
|
||||
private detachPending(key: string): void {
|
||||
const slot = this.pending.get(key);
|
||||
if (slot) {
|
||||
slot.invalidated = true;
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fence + detach every in-flight builder under `prefix`. */
|
||||
private detachPendingByPrefix(prefix: string): void {
|
||||
for (const [key, slot] of this.pending) {
|
||||
if (key.startsWith(prefix)) {
|
||||
slot.invalidated = true;
|
||||
this.pending.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a fenced single-flight build for `key`. The commit only
|
||||
* lands when no invalidation hit the key while the builder ran; the
|
||||
* `finally` only clears the slot it owns (an invalidation may already
|
||||
* have detached it and a newer build may occupy the key).
|
||||
*/
|
||||
private startBuild(
|
||||
key: string,
|
||||
builder: () => Promise<T>,
|
||||
ttlMsOverride?: number,
|
||||
): PendingBuild<T> {
|
||||
const slot: PendingBuild<T> = {
|
||||
promise: undefined as unknown as Promise<T>,
|
||||
invalidated: false,
|
||||
};
|
||||
slot.promise = builder()
|
||||
.then((value) => {
|
||||
if (!slot.invalidated) {
|
||||
this.set(key, value, ttlMsOverride);
|
||||
}
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (this.pending.get(key) === slot) {
|
||||
this.pending.delete(key);
|
||||
}
|
||||
});
|
||||
this.pending.set(key, slot);
|
||||
return slot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a key. Returns null on miss, expired entry, or absent entry.
|
||||
* Side effect: an expired entry is removed; a live entry is
|
||||
@@ -178,6 +249,12 @@ export class ServerCache<T> {
|
||||
* behaviour).
|
||||
*/
|
||||
markStaleByPrefix(prefix: string): 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);
|
||||
let marked = 0;
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.map) {
|
||||
@@ -191,6 +268,9 @@ export class ServerCache<T> {
|
||||
|
||||
/** Single-key eviction. Returns true when the key was present. */
|
||||
delete(key: string): boolean {
|
||||
// v1.16.9 — fence the in-flight builder so a pre-write build can
|
||||
// neither be joined by post-evict reads nor commit its result.
|
||||
this.detachPending(key);
|
||||
return this.map.delete(key);
|
||||
}
|
||||
|
||||
@@ -203,6 +283,8 @@ export class ServerCache<T> {
|
||||
* possible cache-key variant the user might have warmed.
|
||||
*/
|
||||
deleteByPrefix(prefix: string): number {
|
||||
// v1.16.9 — fence in-flight builders under the prefix (see `delete`).
|
||||
this.detachPendingByPrefix(prefix);
|
||||
let removed = 0;
|
||||
for (const key of this.map.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
@@ -238,21 +320,13 @@ export class ServerCache<T> {
|
||||
const inFlight = this.pending.get(key);
|
||||
if (inFlight) {
|
||||
this.stampedes += 1;
|
||||
const value = await inFlight;
|
||||
const value = await inFlight.promise;
|
||||
return { value, outcome: "stampede" };
|
||||
}
|
||||
|
||||
this.misses += 1;
|
||||
const promise = builder()
|
||||
.then((value) => {
|
||||
this.set(key, value, ttlMsOverride);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, promise);
|
||||
const value = await promise;
|
||||
const slot = this.startBuild(key, builder, ttlMsOverride);
|
||||
const value = await slot.promise;
|
||||
return { value, outcome: "miss" };
|
||||
}
|
||||
|
||||
@@ -288,20 +362,11 @@ export class ServerCache<T> {
|
||||
// single-flight slot so concurrent stale reads share one rebuild.
|
||||
this.hits += 1;
|
||||
if (!this.pending.has(key)) {
|
||||
const refresh = builder()
|
||||
.then((value) => {
|
||||
this.set(key, value, ttlMsOverride);
|
||||
return value;
|
||||
})
|
||||
.catch((err) => {
|
||||
// The stale value already went out; don't crash the worker.
|
||||
// Re-throw so the join path of any awaiting caller sees it.
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
this.pending.delete(key);
|
||||
});
|
||||
this.pending.set(key, refresh);
|
||||
// v1.16.9 — the background refresh runs through the same fenced
|
||||
// single-flight slot as `wrap`, so an invalidation that lands
|
||||
// while it is in flight discards its (pre-write) result instead
|
||||
// of re-caching it as fresh.
|
||||
const refresh = this.startBuild(key, builder, ttlMsOverride).promise;
|
||||
// Detach: the foreground caller does not await the rebuild.
|
||||
// Handle the rejection on the detached handle so an
|
||||
// unhandled-rejection warning never fires — but never silently:
|
||||
@@ -344,6 +409,11 @@ export class ServerCache<T> {
|
||||
/** Reset every counter + entry. Test-only escape hatch. */
|
||||
__resetForTests(): void {
|
||||
this.map.clear();
|
||||
// Fence any in-flight builder so a build crossing a test boundary
|
||||
// cannot commit into the next test's clean cache.
|
||||
for (const slot of this.pending.values()) {
|
||||
slot.invalidated = true;
|
||||
}
|
||||
this.pending.clear();
|
||||
this.hits = 0;
|
||||
this.misses = 0;
|
||||
|
||||
Reference in new issue
Block a user