task-82: 为进度响应 Map 增加 TTL 清理与最大条目数
新增纯 TS 进度响应缓存 progress-response-cache:写入后 ttlMs 过期、 条目数超 maxEntries 驱逐最旧、读取/枚举/清理惰性移除过期条目,时钟 可注入以便测试 TTL 边界。set 对非法 key fail-fast 抛错,时钟故障时 零部分写入,clear 释放全部条目。useTaskProgressLoop 新增可选 progressCache 选项,每轮响应快照写入有界缓存(默认不启用)。
This commit is contained in:
@@ -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<TDetail> {
|
||||
* 响应体大小与缓存内存占用。
|
||||
*/
|
||||
onBaseline?: (baseline: TaskPollingBaseline) => void
|
||||
/**
|
||||
* 可选进度响应缓存(Task 82):传入后每轮响应快照按 taskId 写入
|
||||
* TTL+最大条目数的有界缓存(读取时惰性清理过期条目),供页面在
|
||||
* 轮询间隙复用最近一次进度快照。
|
||||
*/
|
||||
progressCache?: ProgressResponseCache<TDetail>
|
||||
}
|
||||
|
||||
export interface TaskProgressLoopHandle<TDetail> {
|
||||
@@ -167,6 +177,7 @@ export function useTaskProgressLoop<TDetail>(
|
||||
} catch {
|
||||
/* onUpdate 抛错不应中断本轮 */
|
||||
}
|
||||
options.progressCache?.set(id, detail)
|
||||
if (status) {
|
||||
nextStatuses[id] = status
|
||||
if (isTerminal(status)) {
|
||||
|
||||
@@ -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<V> {
|
||||
value: V
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function createProgressResponseCache<V>(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<number, Entry<V>>()
|
||||
|
||||
function validateKey(key: number): boolean {
|
||||
return Number.isFinite(key) && key > 0 && Number.isInteger(key)
|
||||
}
|
||||
|
||||
function isExpired(entry: Entry<V>, 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<V> = ReturnType<typeof createProgressResponseCache<V>>
|
||||
@@ -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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({
|
||||
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<FakeDetail>({ 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<FakeDetail>({
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user