diff --git a/src/analytics/ttl-cache.spec.ts b/src/analytics/ttl-cache.spec.ts new file mode 100644 index 0000000..4a27cff --- /dev/null +++ b/src/analytics/ttl-cache.spec.ts @@ -0,0 +1,65 @@ +import { TtlCache } from './ttl-cache'; + +describe('TtlCache', () => { + it('should return cached value within TTL without calling loader again', async () => { + const cache = new TtlCache(1000); + const loader = jest.fn().mockResolvedValue(42); + + const first = await cache.getOrLoad(loader); + const second = await cache.getOrLoad(loader); + + expect(first).toBe(42); + expect(second).toBe(42); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should coalesce concurrent calls into a single loader invocation', async () => { + const cache = new TtlCache(1000); + let resolveLoader!: (val: string) => void; + const loader = jest.fn( + () => + new Promise((resolve) => { + resolveLoader = resolve; + }), + ); + + const p1 = cache.getOrLoad(loader); + const p2 = cache.getOrLoad(loader); + + resolveLoader('result'); + const [res1, res2] = await Promise.all([p1, p2]); + + expect(res1).toBe('result'); + expect(res2).toBe('result'); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('should discard in-flight result if invalidate() was called while loading (#463)', async () => { + const cache = new TtlCache(5000); + let resolveStale!: (val: string) => void; + + const staleLoader = jest.fn( + () => + new Promise((resolve) => { + resolveStale = resolve; + }), + ); + + // 1. Uruchamiamy powolny loader + const stalePromise = cache.getOrLoad(staleLoader); + + // 2. W trakcie trwania zapytania następuje unieważnienie cache + cache.invalidate(); + + // 3. Stary loader kończy działanie + resolveStale('stale_data'); + await stalePromise; + + // 4. Kolejne odpytanie musi wymusić świeże pobranie zamiast serwować stale_data + const freshLoader = jest.fn().mockResolvedValue('fresh_data'); + const freshResult = await cache.getOrLoad(freshLoader); + + expect(freshResult).toBe('fresh_data'); + expect(freshLoader).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/analytics/ttl-cache.ts b/src/analytics/ttl-cache.ts index a97ff0b..49c08d0 100644 --- a/src/analytics/ttl-cache.ts +++ b/src/analytics/ttl-cache.ts @@ -5,11 +5,14 @@ export class TtlCache { private entry: { data: T; expiresAt: number } | null = null; private inflight: Promise | null = null; + private generation = 0; constructor(private readonly ttlMs: number) {} invalidate(): void { this.entry = null; + this.inflight = null; + this.generation++; } async getOrLoad(loader: () => Promise): Promise { @@ -20,15 +23,23 @@ export class TtlCache { if (this.inflight) { return this.inflight; } + + const currentGen = this.generation; + this.inflight = (async () => { try { const data = await loader(); - this.entry = { data, expiresAt: Date.now() + this.ttlMs }; + if (currentGen === this.generation) { + this.entry = { data, expiresAt: Date.now() + this.ttlMs }; + } return data; } finally { - this.inflight = null; + if (currentGen === this.generation) { + this.inflight = null; + } } })(); + return this.inflight; } }