/** * 进度请求响应缓存与并发合并(Task 90)。 * * 为进度接口提供断网、超时、服务恢复与重复响应场景的确定行为: * - set/get:按 key 缓存最近一次成功响应,TTL 过期后惰性清除并计 miss; * - startInflight/getInflight/endInflight:同一 key 的并发请求只发起一次, * 后续调用方合并到同一 Promise(重复登记返回 false,不覆盖原 Promise); * - clear:断网恢复后清空缓存与 in-flight,重新可用; * - 请求失败(断网/超时)只释放 in-flight 槽位,不清除未过期的旧缓存, * 服务恢复后旧缓存仍可降级命中。 * * 有界内存:maxEntries 超限驱逐最旧条目;maxInflight 超限拒绝合并 * (请求照常发出,只是不合并);空 key 写入与登记 fail-fast 抛错。 * 纯 TS 模块,无副作用;now 可注入用于测试时钟推进。 */ export interface TaskProgressRequestCacheOptions { /** * 缓存有效期(毫秒),必须为正数;也可传函数在每次 set 时动态取值 * (如按页面可见性切换 TTL),函数返回值同样必须为正数 */ ttlMs: number | (() => number) /** 缓存条目上限,超过时驱逐最旧条目;必须为正数 */ maxEntries?: number /** 同时合并的 in-flight 请求数上限,超过时拒绝登记;必须为正数 */ maxInflight?: number /** 时钟注入,默认 Date.now */ now?: () => number } export interface TaskProgressRequestCacheStats { cacheEntries: number inflightCount: number hitCount: number missCount: number evictedCount: number rejectedCount: number } export interface TaskProgressRequestCache { get: (key: string) => T | undefined set: (key: string, data: T) => void clear: () => void startInflight: (key: string, promise: Promise) => boolean getInflight: (key: string) => Promise | undefined endInflight: (key: string) => void stats: () => TaskProgressRequestCacheStats } export function createTaskProgressRequestCache(options: TaskProgressRequestCacheOptions) { const ttl = options.ttlMs const ttlValue = typeof ttl === 'number' ? ttl : 0 if (typeof ttl === 'number') { if (!(ttl > 0)) { throw new Error('ttlMs 必须为正数: ' + ttl) } } else if (typeof ttl === 'function') { if (!(ttl() > 0)) { throw new Error('ttlMs 必须为正数: ' + ttl()) } } else { throw new Error('ttlMs 必须为正数: ' + String(ttl)) } const maxEntries = options.maxEntries ?? 100 const maxInflight = options.maxInflight ?? 16 if (!(maxEntries > 0)) { throw new Error('maxEntries 必须为正数: ' + maxEntries) } if (!(maxInflight > 0)) { throw new Error('maxInflight 必须为正数: ' + maxInflight) } const now = options.now ?? Date.now interface CacheEntry { data: T expiresAt: number createdAt: number } const entries = new Map() const inflight = new Map>() let hitCount = 0 let missCount = 0 let evictedCount = 0 let rejectedCount = 0 function requireKey(key: string) { if (typeof key !== 'string' || key.length === 0) { throw new Error('key 必须是非空字符串: ' + String(key)) } } function get(key: string): T | undefined { if (typeof key !== 'string' || key.length === 0) { return undefined } const entry = entries.get(key) if (!entry) { missCount += 1 return undefined } if (entry.expiresAt <= now()) { entries.delete(key) missCount += 1 return undefined } hitCount += 1 return entry.data } function set(key: string, data: T) { requireKey(key) const current = now() const ttlMs = typeof ttl === 'number' ? ttl : ttl() if (!(ttlMs > 0)) { throw new Error('ttlMs 必须为正数: ' + ttlMs) } entries.set(key, { data, expiresAt: current + ttlMs, createdAt: current }) while (entries.size > maxEntries) { const oldestKey = entries.keys().next().value as string entries.delete(oldestKey) evictedCount += 1 } } function clear() { entries.clear() inflight.clear() } function startInflight(key: string, promise: Promise): boolean { requireKey(key) if (inflight.has(key)) { rejectedCount += 1 return false } if (inflight.size >= maxInflight) { rejectedCount += 1 return false } inflight.set(key, promise) return true } function getInflight(key: string): Promise | undefined { if (typeof key !== 'string' || key.length === 0) return undefined return inflight.get(key) } function endInflight(key: string) { if (typeof key !== 'string' || key.length === 0) return inflight.delete(key) } function stats(): TaskProgressRequestCacheStats { return { cacheEntries: entries.size, inflightCount: inflight.size, hitCount, missCount, evictedCount, rejectedCount, } } return { get, set, clear, startInflight, getInflight, endInflight, stats } } export type TaskProgressRequestCacheHandle = ReturnType