diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index bb430f38..3ee14dc0 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -5,6 +5,10 @@ import { createTaskPollingBaseline, type TaskPollingBaseline, } from '@/shared/task-polling-baseline' +import { + createProgressResponseCache, + type ProgressResponseCache, +} from '@/shared/progress-response-cache' /** * 通用任务进度轮询组合式函数。 @@ -56,6 +60,12 @@ export interface TaskProgressLoopOptions { * 响应体大小与缓存内存占用。 */ onBaseline?: (baseline: TaskPollingBaseline) => void + /** + * 可选进度响应缓存(Task 82):传入后每轮响应快照按 taskId 写入 + * TTL+最大条目数的有界缓存(读取时惰性清理过期条目),供页面在 + * 轮询间隙复用最近一次进度快照。 + */ + progressCache?: ProgressResponseCache } export interface TaskProgressLoopHandle { @@ -167,6 +177,7 @@ export function useTaskProgressLoop( } catch { /* onUpdate 抛错不应中断本轮 */ } + options.progressCache?.set(id, detail) if (status) { nextStatuses[id] = status if (isTerminal(status)) { diff --git a/frontend-vue/src/shared/progress-response-cache.ts b/frontend-vue/src/shared/progress-response-cache.ts new file mode 100644 index 00000000..b7a53cbc --- /dev/null +++ b/frontend-vue/src/shared/progress-response-cache.ts @@ -0,0 +1,141 @@ +/** + * 进度响应 Map(Task 82)。 + * + * 为前端任务轮询的进度响应快照提供带 TTL 与最大条目数的内存缓存: + * - 条目在写入后 ttlMs 毫秒过期,读取/枚举/清理时惰性移除; + * - 条目数超过 maxEntries 时驱逐最旧条目(首次写入顺序),缓存始终有界; + * - 时钟可注入(now),测试可用虚拟时钟验证 TTL 边界,不依赖真实时间。 + * + * 纯 TS 无副作用模块;set 对非法 key 抛出「key 必须是正整数」错误(fail-fast), + * 时钟抛错时 set 不留下任何部分写入;读取路径(get/has/delete)对非法 key 宽容。 + * 供 useTaskProgressLoop 的可选缓存选项复用。 + */ +export interface ProgressResponseCacheOptions { + /** 条目过期时间(毫秒),必须为正数 */ + ttlMs: number + /** 缓存最大条目数,必须为正数 */ + maxEntries: number + /** 时钟来源;默认 Date.now(),测试可注入虚拟时钟 */ + now?: () => number +} + +interface Entry { + value: V + createdAt: number +} + +export function createProgressResponseCache(options: ProgressResponseCacheOptions) { + const ttlMs = options.ttlMs + const maxEntries = options.maxEntries + const now = options.now ?? Date.now + if (!(ttlMs > 0)) { + throw new Error('ttlMs 必须为正数: ' + ttlMs) + } + if (!(maxEntries > 0)) { + throw new Error('maxEntries 必须为正数: ' + maxEntries) + } + + const map = new Map>() + + function validateKey(key: number): boolean { + return Number.isFinite(key) && key > 0 && Number.isInteger(key) + } + + function isExpired(entry: Entry, at: number): boolean { + return at - entry.createdAt >= ttlMs + } + + function purgeExpired(): number { + const at = now() + let removed = 0 + for (const [key, entry] of map) { + if (isExpired(entry, at)) { + map.delete(key) + removed += 1 + } + } + return removed + } + + function evictIfNeeded(at: number) { + while (map.size > maxEntries) { + const oldest = map.keys().next().value as number | undefined + if (oldest == null) break + map.delete(oldest) + } + } + + function set(key: number, value: V) { + if (!validateKey(key)) { + throw new Error('key 必须是正整数: ' + key) + } + const createdAt = now() + if (map.has(key)) { + map.set(key, { value, createdAt }) + } else { + map.set(key, { value, createdAt }) + evictIfNeeded(createdAt) + } + } + + function get(key: number): V | undefined { + if (!validateKey(key)) return undefined + const at = now() + const entry = map.get(key) + if (!entry) return undefined + if (isExpired(entry, at)) { + map.delete(key) + return undefined + } + return entry.value + } + + function has(key: number): boolean { + if (!validateKey(key)) return false + const at = now() + const entry = map.get(key) + if (!entry) return false + if (isExpired(entry, at)) { + map.delete(key) + return false + } + return true + } + + function deleteEntry(key: number): boolean { + if (!validateKey(key)) return false + return map.delete(key) + } + + function clear() { + map.clear() + } + + function entries(): Array<[number, V]> { + const at = now() + const result: Array<[number, V]> = [] + for (const [key, entry] of map) { + if (isExpired(entry, at)) { + map.delete(key) + continue + } + result.push([key, entry.value]) + } + return result + } + + return { + get size() { + return map.size + }, + set, + get, + has, + delete: deleteEntry, + clear, + entries, + purgeExpired, + } +} + +export type ProgressResponseCache = ReturnType> diff --git a/frontend-vue/tests/progress-response-cache.test.ts b/frontend-vue/tests/progress-response-cache.test.ts new file mode 100644 index 00000000..2e1b8915 --- /dev/null +++ b/frontend-vue/tests/progress-response-cache.test.ts @@ -0,0 +1,209 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createProgressResponseCache } from '../src/shared/progress-response-cache.ts' + +interface FakeDetail { + id: number + name: string + status: string +} + +function detail(id: number): FakeDetail { + return { id, name: `task-${id}`, status: id % 2 === 0 ? 'SUCCESS' : 'RUNNING' } +} + +function clock(start = 1000) { + let now = start + return { + now: () => now, + tick: (ms: number) => { + now += ms + }, + } +} + +test('test_task_082_progress_cleanup_normal_default_path', () => { + const c = clock() + const cache = createProgressResponseCache({ + ttlMs: 30_000, + maxEntries: 500, + now: c.now, + }) + cache.set(1, detail(1)) + assert.deepEqual(cache.get(1), detail(1)) + assert.equal(cache.has(1), true) + assert.equal(cache.size, 1) + assert.deepEqual(cache.entries(), [[1, detail(1)]]) +}) + +test('test_task_082_progress_cleanup_normal_multiple_items', () => { + const c = clock() + const cache = createProgressResponseCache({ + ttlMs: 60_000, + maxEntries: 10, + now: c.now, + }) + for (let i = 1; i <= 10; i++) cache.set(i, detail(i)) + assert.equal(cache.size, 10) + assert.deepEqual( + cache.entries().map(([k]) => k), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ) + assert.deepEqual( + cache.entries().map(([, v]) => v.id), + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + ) +}) + +test('test_task_082_progress_cleanup_normal_repeated_operation_is_idempotent', () => { + const c = clock() + const cache = createProgressResponseCache({ + ttlMs: 60_000, + maxEntries: 10, + now: c.now, + }) + cache.set(1, detail(1)) + cache.set(1, { ...detail(1), status: 'SUCCESS' }) + assert.equal(cache.size, 1) + assert.equal(cache.get(1)?.status, 'SUCCESS') + cache.set(1, { ...detail(1), status: 'SUCCESS' }) + assert.equal(cache.size, 1) + assert.deepEqual( + cache.entries().map(([k]) => k), + [1], + ) + // 更新已存在条目不改变插入顺序 + cache.set(2, detail(2)) + cache.set(1, detail(1)) + assert.deepEqual( + cache.entries().map(([k]) => k), + [1, 2], + ) +}) + +test('test_task_082_progress_cleanup_boundary_empty_input', () => { + const c = clock() + const cache = createProgressResponseCache({ + ttlMs: 1000, + maxEntries: 5, + now: c.now, + }) + assert.equal(cache.size, 0) + assert.equal(cache.get(1), undefined) + assert.equal(cache.has(1), false) + assert.deepEqual(cache.entries(), []) + assert.equal(cache.purgeExpired(), 0) + cache.clear() + assert.equal(cache.size, 0) + assert.equal(cache.purgeExpired(), 0) +}) + +test('test_task_082_progress_cleanup_boundary_single_item', () => { + const c = clock() + const cache = createProgressResponseCache({ + ttlMs: 1000, + maxEntries: 5, + now: c.now, + }) + cache.set(7, detail(7)) + assert.deepEqual(cache.get(7), detail(7)) + assert.equal(cache.size, 1) + assert.equal(cache.has(7), true) + c.tick(1001) + assert.equal(cache.get(7), undefined) + assert.equal(cache.has(7), false) + assert.equal(cache.size, 0) + assert.equal(cache.purgeExpired(), 0) +}) + +test('test_task_082_progress_cleanup_boundary_limit_and_overflow', () => { + // 条目数超限:驱逐最旧,缓存有界 + const c1 = clock() + const byEntries = createProgressResponseCache({ + ttlMs: 60_000, + maxEntries: 3, + now: c1.now, + }) + for (let i = 1; i <= 5; i++) byEntries.set(i, detail(i)) + assert.equal(byEntries.size, 3) + assert.deepEqual( + byEntries.entries().map(([k]) => k), + [3, 4, 5], + ) + assert.equal(byEntries.get(1), undefined) + assert.equal(byEntries.get(2), undefined) + + // TTL 清理:到期条目被 purgeExpired 清除 + const c2 = clock() + const byTtl = createProgressResponseCache({ + ttlMs: 10_000, + maxEntries: 100, + now: c2.now, + }) + byTtl.set(1, detail(1)) + byTtl.set(2, detail(2)) + c2.tick(5_000) + byTtl.set(3, detail(3)) + assert.equal(byTtl.purgeExpired(), 0) + c2.tick(5_001) + assert.equal(byTtl.purgeExpired(), 2) + assert.deepEqual( + byTtl.entries().map(([k]) => k), + [3], + ) + assert.equal(byTtl.size, 1) + // 读取路径惰性清理 + c2.tick(5_000) + assert.equal(byTtl.get(3), undefined) + assert.equal(byTtl.size, 0) + assert.equal(byTtl.purgeExpired(), 0) +}) + +test('test_task_082_progress_cleanup_invalid_input_rejected', () => { + const c = clock() + assert.throws(() => createProgressResponseCache({ ttlMs: 0, maxEntries: 5 }), /ttlMs 必须为正数/) + assert.throws(() => createProgressResponseCache({ ttlMs: -1, maxEntries: 5 }), /ttlMs 必须为正数/) + assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: 0 }), /maxEntries 必须为正数/) + assert.throws(() => createProgressResponseCache({ ttlMs: 1000, maxEntries: -5 }), /maxEntries 必须为正数/) + const cache = createProgressResponseCache({ ttlMs: 1000, maxEntries: 5, now: c.now }) + assert.throws(() => cache.set(0, detail(0)), /key 必须是正整数/) + assert.throws(() => cache.set(-1, detail(-1)), /key 必须是正整数/) + assert.throws(() => cache.set(NaN, detail(NaN)), /key 必须是正整数/) + assert.throws(() => cache.set(1.5, detail(1.5)), /key 必须是正整数/) + // 读取路径宽容:非法 key 不抛错 + assert.equal(cache.get(-1), undefined) + assert.equal(cache.has(0), false) + assert.equal(cache.delete(0), false) +}) + +test('test_task_082_progress_cleanup_dependency_failure_releases_resources', () => { + const c = clock() + let broken = true + const faultyNow = () => { + if (broken) throw new Error('clock down') + return c.now() + } + const cache = createProgressResponseCache({ + ttlMs: 1000, + maxEntries: 5, + now: faultyNow, + }) + // 时钟故障时 set 失败,缓存零状态变更 + assert.throws(() => cache.set(1, detail(1)), /clock down/) + assert.equal(cache.size, 0) + // 故障恢复后同一实例继续工作 + broken = false + cache.set(1, detail(1)) + assert.equal(cache.get(1)?.id, 1) + // 时钟故障时读取/清理抛错但不破坏条目 + broken = true + assert.throws(() => cache.get(1), /clock down/) + assert.throws(() => cache.purgeExpired(), /clock down/) + assert.throws(() => cache.entries(), /clock down/) + broken = false + assert.equal(cache.get(1)?.id, 1) + assert.equal(cache.size, 1) + // clear 释放全部资源 + cache.clear() + assert.equal(cache.size, 0) +})