Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions src/lib/api/__tests__/dedupe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { dedupe, cancelDedupe, clearDedupeCache, buildDedupeKey } from '@/lib/api/dedupe';

function deferred<T = unknown>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<string>();
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<string>();
const d2 = deferred<string>();
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<string>();
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<string>();
const d2 = deferred<string>();
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<string>(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');
});
});
51 changes: 40 additions & 11 deletions src/lib/api/dedupe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> = {
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
Expand All @@ -13,6 +19,7 @@ type Resolver<T> = {
interface InFlight<T> {
promise: Promise<T>;
resolvers: Resolver<T>[];
timer?: ReturnType<typeof setTimeout>;
}

const cache = new Map<string, InFlight<unknown>>();
Expand All @@ -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<T>(key: string, fn: () => Promise<T>): Promise<T> {
export function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
const existing = cache.get(key) as InFlight<T> | 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;

Expand All @@ -49,26 +62,42 @@ export async function dedupe<T>(key: string, fn: () => Promise<T>): Promise<T> {
});

const entry: InFlight<T> = { 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<unknown>);

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<unknown> | 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();
}
Loading