168 lines
5.1 KiB
TypeScript
168 lines
5.1 KiB
TypeScript
/**
|
|
* 进度请求响应缓存与并发合并(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<T> {
|
|
get: (key: string) => T | undefined
|
|
set: (key: string, data: T) => void
|
|
clear: () => void
|
|
startInflight: (key: string, promise: Promise<T>) => boolean
|
|
getInflight: (key: string) => Promise<T> | undefined
|
|
endInflight: (key: string) => void
|
|
stats: () => TaskProgressRequestCacheStats
|
|
}
|
|
|
|
export function createTaskProgressRequestCache<T>(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<string, CacheEntry>()
|
|
const inflight = new Map<string, Promise<T>>()
|
|
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<T>): 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<T> | 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<typeof createTaskProgressRequestCache>
|