Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
328 lines
13 KiB
TypeScript
328 lines
13 KiB
TypeScript
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
import { createSingleFlightCache } from '../src/single-flight-cache';
|
|
|
|
/** A promise whose resolve/reject is exposed, for driving single-flight races. */
|
|
function deferred<T>() {
|
|
let resolve!: (v: T) => void;
|
|
let reject!: (e: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe('SingleFlightCache', () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('disposes retained values and rejects new reads without restarting loads or timers', async () => {
|
|
vi.useFakeTimers();
|
|
const load = vi.fn(async (key: string) => ({ key }));
|
|
const cache = createSingleFlightCache({ ttlMs: 300_000, load });
|
|
await cache.get('first');
|
|
await cache.get('second');
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
|
|
cache.dispose();
|
|
cache.dispose();
|
|
cache.clear();
|
|
|
|
expect(cache.size).toBe(0);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
await expect(cache.get('first')).rejects.toThrow('disposed');
|
|
await expect(cache.get('new', { force: true })).rejects.toThrow('disposed');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it.each(['resolve', 'reject'] as const)(
|
|
'preserves a pending %s after disposal without retaining the result',
|
|
async (outcome) => {
|
|
vi.useFakeTimers();
|
|
const pending = deferred<string>();
|
|
const cache = createSingleFlightCache({ ttlMs: 300_000, load: () => pending.promise });
|
|
const first = cache.get('key');
|
|
expect(cache.get('key')).toBe(first);
|
|
cache.dispose();
|
|
|
|
if (outcome === 'resolve') {
|
|
pending.resolve('late value');
|
|
await expect(first).resolves.toBe('late value');
|
|
} else {
|
|
pending.reject(new Error('late failure'));
|
|
await expect(first).rejects.toThrow('late failure');
|
|
}
|
|
|
|
expect(cache.size).toBe(0);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
},
|
|
);
|
|
|
|
it('releases expired values while idle without expiring newer values early', async () => {
|
|
vi.useFakeTimers();
|
|
const load = vi.fn(async (key: string) => ({ key }));
|
|
const cache = createSingleFlightCache({ ttlMs: 1000, load });
|
|
await cache.get('old');
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
const newer = await cache.get('new');
|
|
|
|
expect(cache.size).toBe(2);
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(cache.size).toBe(1);
|
|
expect(await cache.get('new')).toBe(newer);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(cache.size).toBe(0);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it('keeps a refreshed entry until its new deadline and expires other keys', async () => {
|
|
vi.useFakeTimers();
|
|
const cache = createSingleFlightCache({ ttlMs: 1000, load: async (key: string) => key });
|
|
await cache.get('refreshed');
|
|
await cache.get('old');
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
await cache.get('refreshed', { force: true });
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(cache.size).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(cache.size).toBe(0);
|
|
});
|
|
|
|
it.each(['invalidate', 'clear'] as const)(
|
|
'%s cancels idle cleanup when the last value is removed',
|
|
async (operation) => {
|
|
vi.useFakeTimers();
|
|
const cache = createSingleFlightCache({ ttlMs: 1000, load: async () => 'value' });
|
|
await cache.get('key');
|
|
expect(vi.getTimerCount()).toBe(1);
|
|
cache[operation]('key');
|
|
expect(cache.size).toBe(0);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
|
|
await cache.get('key');
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cache.size).toBe(0);
|
|
},
|
|
);
|
|
|
|
it('does not let expiration detach a pending refresh', async () => {
|
|
vi.useFakeTimers();
|
|
const pending = deferred<string>();
|
|
const load = vi.fn().mockResolvedValueOnce('old').mockReturnValue(pending.promise);
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
|
await cache.get('key');
|
|
const refresh = cache.get('key', { force: true });
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
|
|
expect(cache.size).toBe(0);
|
|
expect(cache.get('key')).toBe(refresh);
|
|
pending.resolve('new');
|
|
await expect(refresh).resolves.toBe('new');
|
|
expect(cache.size).toBe(1);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cache.size).toBe(0);
|
|
});
|
|
|
|
it('does not retain values when caching is disabled by a zero TTL', async () => {
|
|
vi.useFakeTimers();
|
|
const cache = createSingleFlightCache({ ttlMs: 0, load: async () => 'value' });
|
|
await expect(cache.get('key')).resolves.toBe('value');
|
|
expect(cache.size).toBe(0);
|
|
expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
|
|
it.each(['invalidate', 'clear'] as const)(
|
|
'%s detaches pending loads from the cache',
|
|
async (operation) => {
|
|
const old = deferred<string>();
|
|
const current = deferred<string>();
|
|
const load = vi
|
|
.fn()
|
|
.mockReturnValueOnce(old.promise)
|
|
.mockReturnValueOnce(current.promise);
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
|
const first = cache.get('key');
|
|
|
|
cache[operation]('key');
|
|
const second = cache.get('key');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
|
|
old.resolve('old');
|
|
await expect(first).resolves.toBe('old');
|
|
expect(cache.get('key')).toBe(second);
|
|
|
|
current.resolve('current');
|
|
await expect(second).resolves.toBe('current');
|
|
await expect(cache.get('key')).resolves.toBe('current');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
},
|
|
);
|
|
|
|
it('does not let a late invalidated result overwrite a newer value', async () => {
|
|
const old = deferred<string>();
|
|
const load = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue('current');
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
|
const first = cache.get('key');
|
|
cache.invalidate('key');
|
|
|
|
await expect(cache.get('key')).resolves.toBe('current');
|
|
old.resolve('old');
|
|
await first;
|
|
await expect(cache.get('key')).resolves.toBe('current');
|
|
});
|
|
|
|
it('caches within the TTL and reloads once expired', async () => {
|
|
let clock = 1000;
|
|
const load = vi.fn(async (k: string) => `${k}@${clock}`);
|
|
const cache = createSingleFlightCache<string, string>({
|
|
ttlMs: 100,
|
|
load,
|
|
now: () => clock,
|
|
});
|
|
|
|
expect(await cache.get('a')).toBe('a@1000');
|
|
clock = 1050; // still fresh (< 100)
|
|
expect(await cache.get('a')).toBe('a@1000');
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
clock = 1101; // 101ms since store → expired
|
|
expect(await cache.get('a')).toBe('a@1101');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('de-dupes concurrent callers onto a single load (single-flight)', async () => {
|
|
const d = deferred<string>();
|
|
const load = vi.fn(() => d.promise);
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
|
|
|
|
const p1 = cache.get('k');
|
|
const p2 = cache.get('k');
|
|
const p3 = cache.get('k');
|
|
expect(load).toHaveBeenCalledTimes(1); // only one load in flight
|
|
|
|
d.resolve('v');
|
|
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('keys independently — different keys load separately', async () => {
|
|
const load = vi.fn(async (k: number) => k * 2);
|
|
const cache = createSingleFlightCache<number, number>({ ttlMs: 1000, load });
|
|
expect(await cache.get(1)).toBe(2);
|
|
expect(await cache.get(2)).toBe(4);
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('force bypasses the freshness check but still joins an in-flight load', async () => {
|
|
let clock = 0;
|
|
const load = vi.fn(async (k: string) => `${k}@${clock}`);
|
|
const cache = createSingleFlightCache<string, string>({
|
|
ttlMs: 10_000,
|
|
load,
|
|
now: () => clock,
|
|
});
|
|
|
|
expect(await cache.get('a')).toBe('a@0'); // 1 load, cached fresh
|
|
clock = 5; // still fresh
|
|
expect(await cache.get('a', { force: true })).toBe('a@5'); // force → reload despite fresh
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
|
|
// force still single-flights: two concurrent forced gets share one load.
|
|
const d = deferred<string>();
|
|
load.mockImplementationOnce(() => d.promise);
|
|
const p1 = cache.get('a', { force: true });
|
|
const p2 = cache.get('a', { force: true });
|
|
expect(load).toHaveBeenCalledTimes(3); // not 4
|
|
d.resolve('shared');
|
|
expect(await Promise.all([p1, p2])).toEqual(['shared', 'shared']);
|
|
});
|
|
|
|
it('shouldCache=false leaves the entry uncached (next call reloads) but still de-dupes in-flight', async () => {
|
|
let n = 0;
|
|
const load = vi.fn(async () => ({ error: true as boolean, n: ++n }));
|
|
const cache = createSingleFlightCache({
|
|
ttlMs: 10_000,
|
|
load,
|
|
shouldCache: (v) => !v.error,
|
|
});
|
|
|
|
// concurrent callers still share one load...
|
|
const [a, b] = await Promise.all([cache.get('k'), cache.get('k')]);
|
|
expect(a).toEqual({ error: true, n: 1 });
|
|
expect(b).toEqual({ error: true, n: 1 });
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
// ...but nothing was cached, so the next call reloads.
|
|
expect(await cache.get('k')).toEqual({ error: true, n: 2 });
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
|
|
// a good result IS cached.
|
|
load.mockImplementationOnce(async () => ({ error: false, n: ++n }));
|
|
const good = await cache.get('k');
|
|
expect(good).toEqual({ error: false, n: 3 });
|
|
expect(await cache.get('k')).toEqual({ error: false, n: 3 }); // served from cache
|
|
expect(load).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('a rejected load propagates to all joined callers, is not cached, and clears in-flight', async () => {
|
|
let attempt = 0;
|
|
const load = vi.fn(async () => {
|
|
attempt++;
|
|
|
|
if (attempt === 1) {
|
|
throw new Error('boom');
|
|
}
|
|
|
|
return 'ok';
|
|
});
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
|
|
|
|
const p1 = cache.get('k');
|
|
const p2 = cache.get('k');
|
|
await expect(p1).rejects.toThrow('boom');
|
|
await expect(p2).rejects.toThrow('boom'); // same in-flight, same rejection
|
|
expect(load).toHaveBeenCalledTimes(1);
|
|
|
|
// in-flight cleared + not cached → next call retries and succeeds.
|
|
expect(await cache.get('k')).toBe('ok');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('invalidate drops a cached entry; clear drops all', async () => {
|
|
const load = vi.fn(async (k: string) => k.toUpperCase());
|
|
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
|
|
await cache.get('a');
|
|
await cache.get('b');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
|
|
cache.invalidate('a');
|
|
await cache.get('a'); // reloads
|
|
await cache.get('b'); // still cached
|
|
expect(load).toHaveBeenCalledTimes(3);
|
|
|
|
cache.clear();
|
|
await cache.get('a');
|
|
await cache.get('b');
|
|
expect(load).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
it('allows retry when a loader throws before returning its promise', async () => {
|
|
const load = vi
|
|
.fn<() => Promise<string>>()
|
|
.mockImplementationOnce(() => {
|
|
throw new Error('failed before request');
|
|
})
|
|
.mockResolvedValue('recovered');
|
|
const cache = createSingleFlightCache({ ttlMs: 1000, load });
|
|
|
|
await expect(cache.get('key')).rejects.toThrow('failed before request');
|
|
await expect(cache.get('key')).resolves.toBe('recovered');
|
|
expect(load).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|