task-81: 建立前端任务轮询请求量、响应体大小和页面内存基线

新增纯 TS 轮询基线组件 task-polling-baseline:累计轮询请求数、每次
批量响应的总字节与单次最大响应、按条目数/字节双上限的有界内存缓存
(超限驱逐最旧条目),失败路径零状态变更。useTaskProgressLoop 新增
可选 onBaseline 选项接入基线(默认零行为变化)。测试用 Node 24 原生
type-stripping + node:test 运行(npm test),8 个用例覆盖默认路径、
批量、幂等、空输入、单元素、限流溢出、非法输入与失败恢复。
This commit is contained in:
2026-08-30 22:38:51 +08:00
parent 0ce664d2b9
commit 9bf25765dd
4 changed files with 321 additions and 1 deletions
@@ -1,6 +1,10 @@
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
import {
createTaskPollingBaseline,
type TaskPollingBaseline,
} from '@/shared/task-polling-baseline'
/**
* 通用任务进度轮询组合式函数。
@@ -46,6 +50,12 @@ export interface TaskProgressLoopOptions<TDetail> {
onError?: (error: unknown) => void
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s */
getIntervalMs?: () => number
/**
* 可选轮询基线统计(Task 81):传入后每轮请求与响应都会被记录到基线,
* 并在每次 refreshOnce 结束后把基线实例回传,供页面/测试观测请求量、
* 响应体大小与缓存内存占用。
*/
onBaseline?: (baseline: TaskPollingBaseline) => void
}
export interface TaskProgressLoopHandle<TDetail> {
@@ -95,6 +105,7 @@ export function useTaskProgressLoop<TDetail>(
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
const baseline = options.onBaseline ? createTaskPollingBaseline() : null
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
const taskStatuses = ref<Record<number, string>>({})
@@ -141,6 +152,7 @@ export function useTaskProgressLoop<TDetail>(
const ids = taskIds.value.filter((id) => id > 0)
if (!ids.length) return
inFlight.value = true
baseline?.recordRequest()
try {
const result = await options.fetchProgress(ids)
const items = result?.items || []
@@ -175,6 +187,7 @@ export function useTaskProgressLoop<TDetail>(
options.onError?.(error)
} finally {
inFlight.value = false
if (baseline) options.onBaseline?.(baseline)
}
}
@@ -0,0 +1,145 @@
/**
* 前端任务轮询基线(Task 81)。
*
* 为浏览器端的任务轮询建立三条可观测、有界的基线:
* - 请求量:recordRequest() 记录发起的轮询请求次数;
* - 响应体大小:recordResponseItems() 累计每次批量进度响应(整个 items 数组)的字节数,并记录单次最大响应;
* - 页面内存:最近一次响应按条目保留在内存缓存中(条目数 / 缓存字节双上限),
* 超限时驱逐最旧条目,保证缓存有界、不随任务数量无界增长。
*
* 该组件是纯 TS、无副作用模块,供 useTaskProgressLoop 及其测试复用;
* 所有统计在 reset() 前单调累加,key 提取或序列化失败时整个调用失败且不产生任何状态变更。
*/
export interface TaskPollingBaselineOptions {
/** 缓存最大条目数,默认 500 */
maxEntries?: number
/** 缓存最大字节数,默认 1 MiB */
maxCacheBytes?: number
}
export interface TaskPollingBaselineStats {
/** 累计轮询请求次数 */
requestCount: number
/** 累计响应体总字节数 */
totalResponseBytes: number
/** 单次最大响应体字节数 */
largestResponseBytes: number
/** 缓存中的条目数 */
entryCount: number
/** 缓存占用字节数 */
cachedBytes: number
/** 被驱逐或拒绝的条目数 */
droppedCount: number
/** 缓存条目 key(按首次到达顺序) */
entryKeys: number[]
}
/** 每条缓存条目的固定估算开销:key + 记录结构 + 文本编码余量 */
const ENTRY_OVERHEAD = 64
const DEFAULT_MAX_ENTRIES = 500
const DEFAULT_MAX_CACHE_BYTES = 1024 * 1024
interface CacheEntry {
bytes: number
}
export function createTaskPollingBaseline(options: TaskPollingBaselineOptions = {}) {
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES
const maxCacheBytes = options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES
if (!(maxEntries > 0) || !(maxCacheBytes > 0)) {
throw new Error('上限配置必须为正数: maxEntries=' + maxEntries + ', maxCacheBytes=' + maxCacheBytes)
}
const cache = new Map<number, CacheEntry>()
let requestCount = 0
let totalResponseBytes = 0
let largestResponseBytes = 0
let cachedBytes = 0
let droppedCount = 0
function evictIfNeeded() {
while (cache.size > maxEntries || cachedBytes > maxCacheBytes) {
const oldest = cache.keys().next().value as number | undefined
if (oldest == null) break
const entry = cache.get(oldest)
cache.delete(oldest)
cachedBytes -= entry ? entry.bytes : 0
droppedCount += 1
}
}
function recordRequest() {
requestCount += 1
}
/**
* 记录一次批量进度响应,返回成功写入缓存的条目数。
* items 非数组、extractKey 非函数或 key 提取抛错时整个调用失败,不产生任何状态变更。
*/
function recordResponseItems<T>(
items: T[],
extractKey: (item: T) => number | null | undefined,
): number {
if (!Array.isArray(items)) throw new Error('items 必须是数组')
if (typeof extractKey !== 'function') throw new Error('extractKey 必须是函数')
const encoder = new TextEncoder()
const prepared: Array<{ key: number; bytes: number }> = []
let arrayBytes = 0
if (items.length > 0) {
arrayBytes = encoder.encode(JSON.stringify(items)).byteLength
}
for (const item of items) {
const serialized = JSON.stringify(item)
const bytes = encoder.encode(serialized).byteLength + ENTRY_OVERHEAD
const key = extractKey(item)
if (typeof key === 'number' && Number.isFinite(key) && key > 0) {
prepared.push({ key, bytes })
}
}
// 全部校验与序列化通过后才提交统计,保证失败路径零状态变更
totalResponseBytes += arrayBytes
if (arrayBytes > largestResponseBytes) largestResponseBytes = arrayBytes
let inserted = 0
for (const { key, bytes } of prepared) {
if (bytes > maxCacheBytes) {
droppedCount += 1
continue
}
const existing = cache.get(key)
if (existing) cachedBytes -= existing.bytes
cache.set(key, { bytes })
cachedBytes += bytes
inserted += 1
evictIfNeeded()
}
return inserted
}
function reset() {
cache.clear()
requestCount = 0
totalResponseBytes = 0
largestResponseBytes = 0
cachedBytes = 0
droppedCount = 0
}
function stats(): TaskPollingBaselineStats {
return {
requestCount,
totalResponseBytes,
largestResponseBytes,
entryCount: cache.size,
cachedBytes,
droppedCount,
entryKeys: [...cache.keys()],
}
}
return { recordRequest, recordResponseItems, reset, stats }
}
export type TaskPollingBaseline = ReturnType<typeof createTaskPollingBaseline>