From 9bf25765ddc13cc5b1062ed3f8396ddba6a7d2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 30 Aug 2026 22:38:51 +0800 Subject: [PATCH] =?UTF-8?q?task-81:=20=E5=BB=BA=E7=AB=8B=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E8=BD=AE=E8=AF=A2=E8=AF=B7=E6=B1=82=E9=87=8F?= =?UTF-8?q?=E3=80=81=E5=93=8D=E5=BA=94=E4=BD=93=E5=A4=A7=E5=B0=8F=E5=92=8C?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=86=85=E5=AD=98=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增纯 TS 轮询基线组件 task-polling-baseline:累计轮询请求数、每次 批量响应的总字节与单次最大响应、按条目数/字节双上限的有界内存缓存 (超限驱逐最旧条目),失败路径零状态变更。useTaskProgressLoop 新增 可选 onBaseline 选项接入基线(默认零行为变化)。测试用 Node 24 原生 type-stripping + node:test 运行(npm test),8 个用例覆盖默认路径、 批量、幂等、空输入、单元素、限流溢出、非法输入与失败恢复。 --- frontend-vue/package.json | 3 +- .../shared/composables/useTaskProgressLoop.ts | 13 ++ .../src/shared/task-polling-baseline.ts | 145 ++++++++++++++++ .../tests/task-polling-baseline.test.ts | 161 ++++++++++++++++++ 4 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 frontend-vue/src/shared/task-polling-baseline.ts create mode 100644 frontend-vue/tests/task-polling-baseline.test.ts diff --git a/frontend-vue/package.json b/frontend-vue/package.json index 1e85cce1..d9c3d9a9 100644 --- a/frontend-vue/package.json +++ b/frontend-vue/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite --host --port 5173", "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test tests/*.test.ts" }, "dependencies": { "axios": "^1.13.6", diff --git a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts index ada27e11..bb430f38 100644 --- a/frontend-vue/src/shared/composables/useTaskProgressLoop.ts +++ b/frontend-vue/src/shared/composables/useTaskProgressLoop.ts @@ -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 { onError?: (error: unknown) => void /** 自定义轮询间隔;默认根据 document.visibilityState 自适应(5s/30s) */ getIntervalMs?: () => number + /** + * 可选轮询基线统计(Task 81):传入后每轮请求与响应都会被记录到基线, + * 并在每次 refreshOnce 结束后把基线实例回传,供页面/测试观测请求量、 + * 响应体大小与缓存内存占用。 + */ + onBaseline?: (baseline: TaskPollingBaseline) => void } export interface TaskProgressLoopHandle { @@ -95,6 +105,7 @@ export function useTaskProgressLoop( 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(readIdsFromStorage(options.storageKey)) const taskStatuses = ref>({}) @@ -141,6 +152,7 @@ export function useTaskProgressLoop( 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( options.onError?.(error) } finally { inFlight.value = false + if (baseline) options.onBaseline?.(baseline) } } diff --git a/frontend-vue/src/shared/task-polling-baseline.ts b/frontend-vue/src/shared/task-polling-baseline.ts new file mode 100644 index 00000000..36c67328 --- /dev/null +++ b/frontend-vue/src/shared/task-polling-baseline.ts @@ -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() + 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( + 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 diff --git a/frontend-vue/tests/task-polling-baseline.test.ts b/frontend-vue/tests/task-polling-baseline.test.ts new file mode 100644 index 00000000..f2848a31 --- /dev/null +++ b/frontend-vue/tests/task-polling-baseline.test.ts @@ -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) +})