Files
SnowLuma/packages/common/tests/single-flight-cache.test.ts
T
TanTan fa9c73363d
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
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

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);
});
});