Files
SnowLuma/packages/common/tests/single-flight-cache.test.ts
T
DrSmoothl 9d67630396 refactor(common,core): extract single-flight+TTL cache primitive
阶段 1:把手写多份的「单飞+TTL 记忆化」收进一个 common 原语,消除各处
各自实现「TTL 判定 / inflight 去重 / finally 清理」的漂移风险(底层都是 Tencent
风控:忙碌客户端会为每条消息发一次未缓存 OIDB 拉取,持续爆发触发封号)。

- 新增 common/single-flight-cache.ts:createSingleFlightCache<K,V>({ ttlMs, load,
  shouldCache?, now? }).get(key, {force});shouldCache 容纳「错误结果不缓存以便
  重试」,now 可注入便于测 TTL。7 个单测覆盖新鲜命中/单飞去重/force/TTL 过期/
  shouldCache/错误传播/invalidate。
- 迁移 contacts.fetchGroupMemberList(key=groupId, ttl 60s, 默认 shouldCache)与
  update-check.getUpdateInfo(全局 key, ttl 6h, shouldCache=!error)。行为严格不变。

对抗性 Agent 逐条对比 HEAD 确认两处等价(含 update-check 时间戳来源毫秒级微差、
对 6h TTL 可忽略)。common 43 / core 467 全绿、typecheck 干净。
2026-07-01 17:25:04 +08:00

132 lines
5.1 KiB
TypeScript

import { 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', () => {
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);
});
});