阶段 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 干净。
132 lines
5.1 KiB
TypeScript
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);
|
|
});
|
|
});
|