task-81: 建立前端任务轮询请求量、响应体大小和页面内存基线
新增纯 TS 轮询基线组件 task-polling-baseline:累计轮询请求数、每次 批量响应的总字节与单次最大响应、按条目数/字节双上限的有界内存缓存 (超限驱逐最旧条目),失败路径零状态变更。useTaskProgressLoop 新增 可选 onBaseline 选项接入基线(默认零行为变化)。测试用 Node 24 原生 type-stripping + node:test 运行(npm test),8 个用例覆盖默认路径、 批量、幂等、空输入、单元素、限流溢出、非法输入与失败恢复。
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host --port 5173",
|
"dev": "vite --host --port 5173",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
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
|
onError?: (error: unknown) => void
|
||||||
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */
|
/** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */
|
||||||
getIntervalMs?: () => number
|
getIntervalMs?: () => number
|
||||||
|
/**
|
||||||
|
* 可选轮询基线统计(Task 81):传入后每轮请求与响应都会被记录到基线,
|
||||||
|
* 并在每次 refreshOnce 结束后把基线实例回传,供页面/测试观测请求量、
|
||||||
|
* 响应体大小与缓存内存占用。
|
||||||
|
*/
|
||||||
|
onBaseline?: (baseline: TaskPollingBaseline) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskProgressLoopHandle<TDetail> {
|
export interface TaskProgressLoopHandle<TDetail> {
|
||||||
@@ -95,6 +105,7 @@ export function useTaskProgressLoop<TDetail>(
|
|||||||
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
|
const timers = createCategorizedTimers(`task-progress-loop:${options.scope}`)
|
||||||
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
|
const isTerminal = options.isTerminal ?? DEFAULT_TERMINAL
|
||||||
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
|
const intervalMs = options.getIntervalMs ?? getTaskPollIntervalMs
|
||||||
|
const baseline = options.onBaseline ? createTaskPollingBaseline() : null
|
||||||
|
|
||||||
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
|
const taskIds = ref<number[]>(readIdsFromStorage(options.storageKey))
|
||||||
const taskStatuses = ref<Record<number, string>>({})
|
const taskStatuses = ref<Record<number, string>>({})
|
||||||
@@ -141,6 +152,7 @@ export function useTaskProgressLoop<TDetail>(
|
|||||||
const ids = taskIds.value.filter((id) => id > 0)
|
const ids = taskIds.value.filter((id) => id > 0)
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
inFlight.value = true
|
inFlight.value = true
|
||||||
|
baseline?.recordRequest()
|
||||||
try {
|
try {
|
||||||
const result = await options.fetchProgress(ids)
|
const result = await options.fetchProgress(ids)
|
||||||
const items = result?.items || []
|
const items = result?.items || []
|
||||||
@@ -175,6 +187,7 @@ export function useTaskProgressLoop<TDetail>(
|
|||||||
options.onError?.(error)
|
options.onError?.(error)
|
||||||
} finally {
|
} finally {
|
||||||
inFlight.value = false
|
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>
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createTaskPollingBaseline } from '../src/shared/task-polling-baseline.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' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = (d: FakeDetail) => d.id
|
||||||
|
const enc = (v: unknown) => new TextEncoder().encode(JSON.stringify(v)).byteLength
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_normal_default_path', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
baseline.recordRequest()
|
||||||
|
baseline.recordRequest()
|
||||||
|
baseline.recordRequest()
|
||||||
|
const inserted = baseline.recordResponseItems([detail(1), detail(2)], byId)
|
||||||
|
assert.equal(inserted, 2)
|
||||||
|
const stats = baseline.stats()
|
||||||
|
assert.equal(stats.requestCount, 3)
|
||||||
|
assert.equal(stats.entryCount, 2)
|
||||||
|
assert.equal(stats.droppedCount, 0)
|
||||||
|
assert.ok(stats.cachedBytes > 0)
|
||||||
|
assert.ok(stats.cachedBytes <= 1024 * 1024)
|
||||||
|
assert.equal(stats.totalResponseBytes, enc([detail(1), detail(2)]))
|
||||||
|
assert.equal(stats.largestResponseBytes, stats.totalResponseBytes)
|
||||||
|
assert.deepEqual(stats.entryKeys, [1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_normal_multiple_items', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
const items = Array.from({ length: 10 }, (_, i) => detail(i + 1))
|
||||||
|
const inserted = baseline.recordResponseItems(items, byId)
|
||||||
|
assert.equal(inserted, 10)
|
||||||
|
const stats = baseline.stats()
|
||||||
|
assert.equal(stats.entryCount, 10)
|
||||||
|
assert.deepEqual(stats.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||||
|
// 覆盖更新不改变首次到达顺序
|
||||||
|
baseline.recordResponseItems([detail(5)], byId)
|
||||||
|
const after = baseline.stats()
|
||||||
|
assert.equal(after.entryCount, 10)
|
||||||
|
assert.deepEqual(after.entryKeys, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||||
|
assert.equal(after.totalResponseBytes, enc(items) + enc([detail(5)]))
|
||||||
|
assert.equal(after.largestResponseBytes, enc(items))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
const items = [detail(1), detail(2), detail(3)]
|
||||||
|
baseline.recordResponseItems(items, byId)
|
||||||
|
baseline.recordResponseItems(items, byId)
|
||||||
|
const stats = baseline.stats()
|
||||||
|
assert.equal(stats.entryCount, 3)
|
||||||
|
assert.equal(stats.droppedCount, 0)
|
||||||
|
const single = createTaskPollingBaseline()
|
||||||
|
single.recordResponseItems(items, byId)
|
||||||
|
assert.equal(stats.cachedBytes, single.stats().cachedBytes)
|
||||||
|
assert.equal(stats.totalResponseBytes, single.stats().totalResponseBytes * 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_boundary_empty_input', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
assert.equal(baseline.recordResponseItems([], byId), 0)
|
||||||
|
assert.equal(baseline.recordResponseItems([], byId), 0)
|
||||||
|
const stats = baseline.stats()
|
||||||
|
assert.equal(stats.requestCount, 0)
|
||||||
|
assert.equal(stats.totalResponseBytes, 0)
|
||||||
|
assert.equal(stats.largestResponseBytes, 0)
|
||||||
|
assert.equal(stats.entryCount, 0)
|
||||||
|
assert.equal(stats.cachedBytes, 0)
|
||||||
|
assert.equal(stats.droppedCount, 0)
|
||||||
|
assert.deepEqual(stats.entryKeys, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_boundary_single_item', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
const inserted = baseline.recordResponseItems([detail(7)], byId)
|
||||||
|
assert.equal(inserted, 1)
|
||||||
|
const stats = baseline.stats()
|
||||||
|
assert.equal(stats.entryCount, 1)
|
||||||
|
assert.deepEqual(stats.entryKeys, [7])
|
||||||
|
assert.equal(stats.cachedBytes, enc(detail(7)) + 64)
|
||||||
|
assert.equal(stats.totalResponseBytes, enc([detail(7)]))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_boundary_limit_and_overflow', () => {
|
||||||
|
// 条目数超限:驱逐最旧,缓存有界
|
||||||
|
const byEntries = createTaskPollingBaseline({ maxEntries: 3, maxCacheBytes: 1_000_000 })
|
||||||
|
byEntries.recordResponseItems([detail(1), detail(2), detail(3), detail(4), detail(5)], byId)
|
||||||
|
const s1 = byEntries.stats()
|
||||||
|
assert.equal(s1.entryCount, 3)
|
||||||
|
assert.deepEqual(s1.entryKeys, [3, 4, 5])
|
||||||
|
assert.equal(s1.droppedCount, 2)
|
||||||
|
// 单条超过字节上限:直接拒绝
|
||||||
|
const small = createTaskPollingBaseline({ maxCacheBytes: 200 })
|
||||||
|
const big = { id: 9, name: 'x'.repeat(1000), status: 'RUNNING' }
|
||||||
|
const inserted = small.recordResponseItems([big], byId)
|
||||||
|
assert.equal(inserted, 0)
|
||||||
|
const s2 = small.stats()
|
||||||
|
assert.equal(s2.entryCount, 0)
|
||||||
|
assert.equal(s2.droppedCount, 1)
|
||||||
|
assert.ok(s2.cachedBytes <= 200)
|
||||||
|
// 批量累积超限:逐条驱逐最旧直到有界
|
||||||
|
const tiny = createTaskPollingBaseline({ maxCacheBytes: 300 })
|
||||||
|
tiny.recordResponseItems([detail(1), detail(2), detail(3)], byId)
|
||||||
|
const s3 = tiny.stats()
|
||||||
|
assert.equal(s3.entryCount, 2)
|
||||||
|
assert.deepEqual(s3.entryKeys, [2, 3])
|
||||||
|
assert.equal(s3.droppedCount, 1)
|
||||||
|
assert.ok(s3.cachedBytes <= 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_invalid_input_rejected', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
assert.throws(() => baseline.recordResponseItems(null as never, byId), /items 必须是数组/)
|
||||||
|
assert.throws(() => baseline.recordResponseItems([detail(1)], null as never), /extractKey 必须是函数/)
|
||||||
|
assert.throws(() => createTaskPollingBaseline({ maxEntries: 0 }), /上限配置必须为正数/)
|
||||||
|
assert.throws(() => createTaskPollingBaseline({ maxCacheBytes: -1 }), /上限配置必须为正数/)
|
||||||
|
// 非法 key 跳过而不是抛错(与轮询层行为一致)
|
||||||
|
const inserted = baseline.recordResponseItems(
|
||||||
|
[{ id: 0, name: 'x', status: 'y' }, detail(2)],
|
||||||
|
byId,
|
||||||
|
)
|
||||||
|
assert.equal(inserted, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_081_polling_frontend_dependency_failure_releases_resources', () => {
|
||||||
|
const baseline = createTaskPollingBaseline()
|
||||||
|
baseline.recordRequest()
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
baseline.recordResponseItems([detail(1)], () => {
|
||||||
|
throw new Error('boom')
|
||||||
|
}),
|
||||||
|
/boom/,
|
||||||
|
)
|
||||||
|
const after = baseline.stats()
|
||||||
|
assert.equal(after.entryCount, 0)
|
||||||
|
assert.equal(after.totalResponseBytes, 0)
|
||||||
|
assert.equal(after.requestCount, 1)
|
||||||
|
assert.equal(after.droppedCount, 0)
|
||||||
|
baseline.reset()
|
||||||
|
assert.deepEqual(baseline.stats(), {
|
||||||
|
requestCount: 0,
|
||||||
|
totalResponseBytes: 0,
|
||||||
|
largestResponseBytes: 0,
|
||||||
|
entryCount: 0,
|
||||||
|
cachedBytes: 0,
|
||||||
|
droppedCount: 0,
|
||||||
|
entryKeys: [],
|
||||||
|
})
|
||||||
|
// 错误可恢复:修复后同一实例继续工作
|
||||||
|
baseline.recordResponseItems([detail(1)], byId)
|
||||||
|
assert.equal(baseline.stats().entryCount, 1)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user