task-90: 进度接口增加断网、超时、服务恢复和重复响应测试
This commit is contained in:
@@ -8,19 +8,20 @@
|
|||||||
unwrapJavaResponse,
|
unwrapJavaResponse,
|
||||||
} from "@/shared/api/http";
|
} from "@/shared/api/http";
|
||||||
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
|
import { getTaskProgressCacheTtlMs } from "@/shared/task-progress-config";
|
||||||
|
import { createTaskProgressRequestCache } from "@/shared/task-progress-request-cache";
|
||||||
|
|
||||||
const JAVA_API_PREFIX = "/newApi/api";
|
const JAVA_API_PREFIX = "/newApi/api";
|
||||||
type TaskProgressCacheEntry = {
|
|
||||||
expiresAt: number;
|
|
||||||
data: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TaskProgressBatchOptions {
|
interface TaskProgressBatchOptions {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const taskProgressResponseCache = new Map<string, TaskProgressCacheEntry>();
|
/** 进度批量接口的响应缓存与并发合并:TTL 过期、有界条目、in-flight 去重 */
|
||||||
const taskProgressInflightRequests = new Map<string, Promise<unknown>>();
|
const taskProgressResponseCache = createTaskProgressRequestCache<unknown>({
|
||||||
|
ttlMs: () => getTaskProgressCacheTtlMs(),
|
||||||
|
maxEntries: 100,
|
||||||
|
maxInflight: 16,
|
||||||
|
});
|
||||||
|
|
||||||
function getCurrentUserId() {
|
function getCurrentUserId() {
|
||||||
const raw =
|
const raw =
|
||||||
@@ -59,13 +60,12 @@ async function postTaskProgressBatch<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds);
|
const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds);
|
||||||
const now = Date.now();
|
|
||||||
const cached = taskProgressResponseCache.get(cacheKey);
|
const cached = taskProgressResponseCache.get(cacheKey);
|
||||||
if (!options.force && cached && cached.expiresAt > now) {
|
if (!options.force && cached !== undefined) {
|
||||||
return cached.data as T;
|
return cached as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inflight = taskProgressInflightRequests.get(cacheKey);
|
const inflight = taskProgressResponseCache.getInflight(cacheKey);
|
||||||
if (!options.force && inflight) {
|
if (!options.force && inflight) {
|
||||||
return (await inflight) as T;
|
return (await inflight) as T;
|
||||||
}
|
}
|
||||||
@@ -74,17 +74,14 @@ async function postTaskProgressBatch<T>(
|
|||||||
post<JavaApiResponse<T>, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }),
|
post<JavaApiResponse<T>, { taskIds: number[] }>(path, { taskIds: normalizedTaskIds }),
|
||||||
)
|
)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
taskProgressResponseCache.set(cacheKey, {
|
taskProgressResponseCache.set(cacheKey, data);
|
||||||
data,
|
|
||||||
expiresAt: Date.now() + getTaskProgressCacheTtlMs(),
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
taskProgressInflightRequests.delete(cacheKey);
|
taskProgressResponseCache.endInflight(cacheKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
taskProgressInflightRequests.set(cacheKey, requestPromise);
|
taskProgressResponseCache.startInflight(cacheKey, requestPromise);
|
||||||
return requestPromise;
|
return requestPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<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>
|
||||||
@@ -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), '失败不清除旧缓存,恢复后可降级命中')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user