task-90: 进度接口增加断网、超时、服务恢复和重复响应测试

This commit is contained in:
2026-08-30 23:01:17 +08:00
parent cc2722c104
commit bf38a1b9a0
3 changed files with 314 additions and 16 deletions
+13 -16
View File
@@ -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<string, TaskProgressCacheEntry>();
const taskProgressInflightRequests = new Map<string, Promise<unknown>>();
/** 进度批量接口的响应缓存与并发合并:TTL 过期、有界条目、in-flight 去重 */
const taskProgressResponseCache = createTaskProgressRequestCache<unknown>({
ttlMs: () => getTaskProgressCacheTtlMs(),
maxEntries: 100,
maxInflight: 16,
});
function getCurrentUserId() {
const raw =
@@ -59,13 +60,12 @@ async function postTaskProgressBatch<T>(
}
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<T>(
post<JavaApiResponse<T>, { 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;
}
@@ -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>