diff --git a/src/lib/api/__tests__/dedupe.test.ts b/src/lib/api/__tests__/dedupe.test.ts new file mode 100644 index 00000000..4ea72517 --- /dev/null +++ b/src/lib/api/__tests__/dedupe.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { dedupe, cancelDedupe, clearDedupeCache, buildDedupeKey } from '@/lib/api/dedupe'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + return { promise, resolve, reject }; +} + +describe('dedupe (basic behavior)', () => { + afterEach(() => { + clearDedupeCache(); + }); + + it('deduplicates concurrent requests with the same key', async () => { + const d = deferred(); + const fn = vi.fn().mockReturnValue(d.promise); + + const p1 = dedupe('k', fn); + const p2 = dedupe('k', fn); + + d.resolve('ok'); + await expect(p1).resolves.toBe('ok'); + await expect(p2).resolves.toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('allows different keys to proceed independently', async () => { + const d1 = deferred(); + const d2 = deferred(); + const fn = vi.fn() + .mockReturnValueOnce(d1.promise) + .mockReturnValueOnce(d2.promise); + + const p1 = dedupe('a', fn); + const p2 = dedupe('b', fn); + + d1.resolve('a-res'); + d2.resolve('b-res'); + + await expect(p1).resolves.toBe('a-res'); + await expect(p2).resolves.toBe('b-res'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('rejects all callers when the factory throws', async () => { + const d = deferred(); + const fn = vi.fn().mockReturnValue(d.promise); + + const p1 = dedupe('err', fn); + const p2 = dedupe('err', fn); + + d.reject(new Error('nope')); + await expect(p1).rejects.toThrow('nope'); + await expect(p2).rejects.toThrow('nope'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('allows a subsequent request after the first completes', async () => { + const d1 = deferred(); + const d2 = deferred(); + const fn = vi.fn() + .mockReturnValueOnce(d1.promise) + .mockReturnValueOnce(d2.promise); + + const r1 = dedupe('k', fn); + d1.resolve('first'); + await expect(r1).resolves.toBe('first'); + + const r2 = dedupe('k', fn); + d2.resolve('second'); + await expect(r2).resolves.toBe('second'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('buildDedupeKey produces stable keys', () => { + expect(buildDedupeKey('GET', '/api/test')).toBe('GET:/api/test'); + expect(buildDedupeKey('get', '/api/test')).toBe('GET:/api/test'); + expect(buildDedupeKey('POST', '/api/test', { a: 1 })).toBe('POST:/api/test:{"a":1}'); + }); + + it('cancelDedupe removes an in-flight entry', async () => { + const neverSettle = () => new Promise(() => {}); + dedupe('cancel-me', neverSettle); + cancelDedupe('cancel-me'); + + const fn = vi.fn().mockResolvedValue('fresh'); + await expect(dedupe('cancel-me', fn)).resolves.toBe('fresh'); + expect(fn).toHaveBeenCalledTimes(1); + }); +}); + +describe('dedupe (MAX_INFLIGHT limit)', () => { + afterEach(() => { + clearDedupeCache(); + }); + + it('rejects new entries when the cache is full', async () => { + const neverSettle = () => new Promise(() => {}); + + for (let i = 0; i < 200; i++) { + dedupe(`key-${i}`, neverSettle); + } + + await expect(dedupe('overflow', () => Promise.resolve('ok'))) + .rejects.toThrow('Deduplication cache full'); + }); + + it('accepts a new entry when a slot frees up', async () => { + for (let i = 0; i < 199; i++) { + dedupe(`pending-${i}`, () => new Promise(() => {})); + } + + const fn = vi.fn().mockResolvedValue('done'); + await expect(dedupe('slot-frees', fn)).resolves.toBe('done'); + + await expect(dedupe('last', () => Promise.resolve('ok'))).resolves.toBe('ok'); + }); +}); + +describe('dedupe (TTL eviction)', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + clearDedupeCache(); + }); + + it('evicts entries after the TTL and rejects the promise', async () => { + const neverSettle = () => new Promise(() => {}); + const promise = dedupe('ttl-key', neverSettle); + + vi.advanceTimersByTime(30_000); + + await expect(promise).rejects.toThrow('timed out'); + }); + + it('allows a new request after TTL eviction', async () => { + const neverSettle = () => new Promise(() => {}); + const evicted = dedupe('ttl-key', neverSettle); + const evictDone = expect(evicted).rejects.toThrow('timed out'); + + vi.advanceTimersByTime(30_000); + + await evictDone; + + const fn = vi.fn().mockResolvedValue('fresh'); + await expect(dedupe('ttl-key', fn)).resolves.toBe('fresh'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('clears the timer when the request completes before TTL', async () => { + let resolveFn!: (v: string) => void; + const fn = vi.fn().mockImplementation( + () => new Promise(resolve => { resolveFn = resolve; }), + ); + + const promise = dedupe('fast-key', fn); + resolveFn('fast'); + + await Promise.resolve(); + + await expect(promise).resolves.toBe('fast'); + + vi.advanceTimersByTime(30_000); + + const fn2 = vi.fn().mockResolvedValue('after'); + await expect(dedupe('fast-key', fn2)).resolves.toBe('after'); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + it('evicts multiple entries at their respective TTLs', async () => { + const neverSettle = () => new Promise(() => {}); + const pA = dedupe('a', neverSettle); + const pB = dedupe('b', neverSettle); + + vi.advanceTimersByTime(30_000); + + await expect(pA).rejects.toThrow('timed out'); + await expect(pB).rejects.toThrow('timed out'); + }); +}); diff --git a/src/lib/api/dedupe.ts b/src/lib/api/dedupe.ts index 4d750caa..3a29c802 100644 --- a/src/lib/api/dedupe.ts +++ b/src/lib/api/dedupe.ts @@ -3,8 +3,14 @@ * * Merges concurrent identical requests so only one network call is made. * Subsequent callers receive the same promise as the in-flight request. + * + * The cache is bounded: at most MAX_INFLIGHT entries can be in-flight at once, + * and each entry has a TTL of DEDUPE_TTL_MS after which it is automatically evicted. */ +const MAX_INFLIGHT = 200; +const DEDUPE_TTL_MS = 30_000; + type Resolver = { resolve: (value: T) => void; reject: (reason?: unknown) => void; @@ -13,6 +19,7 @@ type Resolver = { interface InFlight { promise: Promise; resolvers: Resolver[]; + timer?: ReturnType; } const cache = new Map>(); @@ -34,12 +41,18 @@ export function buildDedupeKey(method: string, url: string, body?: unknown): str * @param key - Unique identifier for this request (use buildDedupeKey) * @param fn - Factory that performs the actual request */ -export async function dedupe(key: string, fn: () => Promise): Promise { +export function dedupe(key: string, fn: () => Promise): Promise { const existing = cache.get(key) as InFlight | undefined; if (existing) { return existing.promise; } + if (cache.size >= MAX_INFLIGHT) { + return Promise.reject( + new Error(`Deduplication cache full (max ${MAX_INFLIGHT} entries)`), + ); + } + let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; @@ -49,26 +62,42 @@ export async function dedupe(key: string, fn: () => Promise): Promise { }); const entry: InFlight = { promise, resolvers: [{ resolve, reject }] }; + entry.timer = setTimeout(() => { + cache.delete(key); + reject(new Error(`Deduplication entry timed out after ${DEDUPE_TTL_MS}ms`)); + }, DEDUPE_TTL_MS); + cache.set(key, entry as InFlight); - try { - const result = await fn(); - resolve(result); - return result; - } catch (err) { - reject(err); - throw err; - } finally { - cache.delete(key); - } + fn() + .then((result) => { + clearTimeout(entry.timer); + resolve(result); + }) + .catch((err) => { + clearTimeout(entry.timer); + reject(err); + }) + .finally(() => { + cache.delete(key); + }); + + return promise; } /** Remove a specific key from the cache (e.g. on cancellation). */ export function cancelDedupe(key: string): void { + const entry = cache.get(key) as InFlight | undefined; + if (entry?.timer) { + clearTimeout(entry.timer); + } cache.delete(key); } /** Clear the entire deduplication cache. */ export function clearDedupeCache(): void { + for (const entry of cache.values()) { + if (entry.timer) clearTimeout(entry.timer); + } cache.clear(); }