diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 68f3b767..29cdfe84 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -8,19 +8,20 @@ unwrapJavaResponse, } from "@/shared/api/http"; import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config"; +import { createTaskProgressRequestCache } from "@/shared/task-progress-request-cache"; const JAVA_API_PREFIX = "/newApi/api"; -type TaskProgressCacheEntry = { - expiresAt: number; - data: unknown; -}; interface TaskProgressBatchOptions { force?: boolean; } -const taskProgressResponseCache = new Map(); -const taskProgressInflightRequests = new Map>(); +/** 进度批量接口的响应缓存与并发合并:TTL 过期、有界条目、in-flight 去重 */ +const taskProgressResponseCache = createTaskProgressRequestCache({ + ttlMs: () => getTaskProgressCacheTtlMs(), + maxEntries: 100, + maxInflight: 16, +}); function getCurrentUserId() { const raw = @@ -59,13 +60,12 @@ async function postTaskProgressBatch( } const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds); - const now = Date.now(); const cached = taskProgressResponseCache.get(cacheKey); - if (!options.force && cached && cached.expiresAt > now) { - return cached.data as T; + if (!options.force && cached !== undefined) { + return cached as T; } - const inflight = taskProgressInflightRequests.get(cacheKey); + const inflight = taskProgressResponseCache.getInflight(cacheKey); if (!options.force && inflight) { return (await inflight) as T; } @@ -74,17 +74,14 @@ async function postTaskProgressBatch( post, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }), ) .then((data) => { - taskProgressResponseCache.set(cacheKey, { - data, - expiresAt: Date.now() + getTaskProgressCacheTtlMs(), - }); + taskProgressResponseCache.set(cacheKey, data); return data; }) .finally(() => { - taskProgressInflightRequests.delete(cacheKey); + taskProgressResponseCache.endInflight(cacheKey); }); - taskProgressInflightRequests.set(cacheKey, requestPromise); + taskProgressResponseCache.startInflight(cacheKey, requestPromise); return requestPromise; } diff --git a/frontend-vue/src/shared/task-progress-request-cache.ts b/frontend-vue/src/shared/task-progress-request-cache.ts new file mode 100644 index 00000000..af483ad6 --- /dev/null +++ b/frontend-vue/src/shared/task-progress-request-cache.ts @@ -0,0 +1,167 @@ +/** + * 进度请求响应缓存与并发合并(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 diff --git a/frontend-vue/tests/task-progress-request-cache.test.ts b/frontend-vue/tests/task-progress-request-cache.test.ts new file mode 100644 index 00000000..ee97aa41 --- /dev/null +++ b/frontend-vue/tests/task-progress-request-cache.test.ts @@ -0,0 +1,134 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createTaskProgressRequestCache } from '../src/shared/task-progress-request-cache.ts' + +const detail = (n: number) => ({ items: [{ taskId: n, status: 'SUCCESS' }] }) + +test('test_task_090_progress_resilience_normal_default_path', () => { + let now = 1000 + const cache = createTaskProgressRequestCache({ ttlMs: 5000, now: () => now }) + assert.equal(cache.get('a'), undefined) + cache.set('a', detail(1)) + assert.deepEqual(cache.get('a'), detail(1)) + const stats = cache.stats() + assert.equal(stats.cacheEntries, 1) + assert.equal(stats.hitCount, 1) + assert.equal(stats.missCount, 1) + // in-flight 合并:先查后登记,同 key 并发只保留一个 + const p = Promise.resolve(detail(2)) + assert.equal(cache.getInflight('b'), undefined) + assert.equal(cache.startInflight('b', p), true) + assert.equal(cache.getInflight('b'), p) + assert.equal(cache.startInflight('b', Promise.resolve(detail(3))), false, '同 key 已合并,拒绝重复登记') + cache.endInflight('b') + assert.equal(cache.getInflight('b'), undefined) +}) + +test('test_task_090_progress_resilience_normal_multiple_items', () => { + const cache = createTaskProgressRequestCache({ ttlMs: 10_000 }) + for (let i = 1; i <= 4; i++) cache.set(`k${i}`, detail(i)) + assert.deepEqual(cache.get('k1'), detail(1)) + assert.deepEqual(cache.get('k4'), detail(4)) + assert.equal(cache.stats().cacheEntries, 4) + // 各 key 互不串扰;顺序稳定 + assert.deepEqual(cache.get('k2'), detail(2)) + assert.deepEqual(cache.get('k3'), detail(3)) +}) + +test('test_task_090_progress_resilience_normal_repeated_operation_is_idempotent', () => { + const cache = createTaskProgressRequestCache({ ttlMs: 5000 }) + cache.set('a', detail(1)) + cache.set('a', detail(1)) + assert.equal(cache.stats().cacheEntries, 1, '重复 set 覆盖不新增条目') + assert.deepEqual(cache.get('a'), detail(1)) + // endInflight 幂等 + const p = Promise.resolve(detail(2)) + cache.startInflight('b', p) + cache.endInflight('b') + cache.endInflight('b') + assert.equal(cache.getInflight('b'), undefined) + // startInflight 重复登记返回 false 且不覆盖原 promise + cache.startInflight('c', p) + assert.equal(cache.startInflight('c', Promise.resolve(detail(9))), false) + assert.equal(cache.getInflight('c'), p) +}) + +test('test_task_090_progress_resilience_boundary_empty_input', () => { + const cache = createTaskProgressRequestCache({ ttlMs: 5000 }) + assert.equal(cache.get(''), undefined, '空 key 读取宽容') + assert.equal(cache.get('missing'), undefined) + assert.equal(cache.getInflight(''), undefined) + cache.endInflight('') // 宽容无操作 + cache.clear() + assert.equal(cache.stats().cacheEntries, 0) + assert.equal(cache.stats().inflightCount, 0) + // clear 后继续可正常读写(服务恢复后可重新使用) + cache.set('a', detail(1)) + assert.deepEqual(cache.get('a'), detail(1)) +}) + +test('test_task_090_progress_resilience_boundary_single_item', () => { + const cache = createTaskProgressRequestCache({ ttlMs: 5000 }) + cache.set('single', detail(7)) + assert.deepEqual(cache.get('single'), detail(7)) + assert.equal(cache.stats().cacheEntries, 1) + assert.equal(cache.stats().missCount, 0) + // 单并发请求合并 + const p = Promise.resolve(detail(8)) + assert.equal(cache.startInflight('single', p), true) + assert.equal(cache.getInflight('single'), p) +}) + +test('test_task_090_progress_resilience_boundary_limit_and_overflow', () => { + let now = 0 + const cache = createTaskProgressRequestCache({ ttlMs: 5000, maxEntries: 3, maxInflight: 2, now: () => now }) + for (let i = 1; i <= 5; i++) cache.set(`k${i}`, detail(i)) + assert.equal(cache.stats().cacheEntries, 3, '超过 maxEntries 只保留最近 3 条') + assert.equal(cache.stats().evictedCount, 2) + assert.equal(cache.get('k1'), undefined, '最旧条目被驱逐') + assert.equal(cache.get('k2'), undefined) + assert.deepEqual(cache.get('k5'), detail(5)) + // TTL 过期:惰性清除并计 miss + now = 10_000 + assert.equal(cache.get('k5'), undefined) + assert.equal(cache.stats().cacheEntries, 2) + // in-flight 超上限拒绝合并,但不丢请求 + const p = Promise.resolve(detail(9)) + assert.equal(cache.startInflight('a', p), true) + assert.equal(cache.startInflight('b', p), true) + assert.equal(cache.startInflight('c', p), false, '超过 maxInflight 拒绝合并') + assert.equal(cache.stats().rejectedCount, 1) + assert.equal(cache.getInflight('c'), undefined) +}) + +test('test_task_090_progress_resilience_invalid_input_rejected', () => { + assert.throws(() => createTaskProgressRequestCache({ ttlMs: 0 }), /ttlMs 必须为正数/) + assert.throws(() => createTaskProgressRequestCache({ ttlMs: -1 }), /ttlMs 必须为正数/) + assert.throws(() => createTaskProgressRequestCache({ ttlMs: 'x' as never }), /ttlMs 必须为正数/) + assert.throws(() => createTaskProgressRequestCache({ ttlMs: 100, maxEntries: 0 }), /maxEntries 必须为正数/) + assert.throws(() => createTaskProgressRequestCache({ ttlMs: 100, maxInflight: 0 }), /maxInflight 必须为正数/) + const cache = createTaskProgressRequestCache({ ttlMs: 100 }) + assert.throws(() => cache.set('', detail(1)), /key 必须是非空字符串/) + assert.throws(() => cache.set(null as never, detail(1)), /key 必须是非空字符串/) + assert.throws(() => cache.startInflight('', Promise.resolve(detail(1))), /key 必须是非空字符串/) +}) + +test('test_task_090_progress_resilience_dependency_failure_releases_resources', () => { + const cache = createTaskProgressRequestCache({ ttlMs: 5000 }) + // 断网/超时:请求 reject → in-flight 必须释放,缓存不被污染 + const failed = Promise.reject(new Error('Network Error')) + const promise = failed.catch(() => undefined) // 吞掉 unhandled rejection + cache.startInflight('a', promise) + assert.equal(cache.stats().inflightCount, 1) + cache.endInflight('a') + assert.equal(cache.stats().inflightCount, 0, '失败后 in-flight 槽位释放') + assert.equal(cache.get('a'), undefined, '失败响应不进入缓存') + // 服务恢复:重试成功 → 缓存重新填充并可命中 + cache.set('a', detail(1)) + assert.deepEqual(cache.get('a'), detail(1)) + // 断网时旧缓存兜底:请求失败不删除未过期旧条目 + const p2 = failed.catch(() => undefined) + cache.startInflight('a', p2) + cache.endInflight('a') + assert.deepEqual(cache.get('a'), detail(1), '失败不清除旧缓存,恢复后可降级命中') +})