task-85: 优化 Similar ASIN 轮询与文件生成等待,避免重复 force 请求
新增纯 TS force 节流守卫 asin-force-throttle:为每个 taskId 维护 TTL 冷却窗口,窗口内不重复发 force 请求(避免反复触发后端生成文件),到期 后允许重试,任务终态后 clear 立即释放记录。BrandSimilarAsinTab 的 refreshTaskProgress 改为按 pendingFileTaskIds 逐项查守卫决定本轮是否 带 force,请求成功后整批进入冷却。8 个测试覆盖默认、批量、幂等、空、 单元素、200 任务溢出、非法输入与时钟故障恢复。
This commit is contained in:
@@ -211,6 +211,7 @@ import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import { getStoredApiSecret } from '@/shared/utils/api-secret-store'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import { saveUrlWithProgress } from '@/shared/utils/download-progress'
|
||||
import { createAsinForceThrottle } from '@/shared/asin-force-throttle'
|
||||
|
||||
const selectedFileNames = ref<string[]>([])
|
||||
const uploadedFiles = ref<UploadFileVo[]>([])
|
||||
@@ -228,6 +229,8 @@ const pollingTaskIds = ref<number[]>([])
|
||||
const pendingFileTaskIds = ref<number[]>([])
|
||||
const pollTimer = ref<number | null>(null)
|
||||
const pollingInFlight = ref(false)
|
||||
// force 请求节流:文件生成中不重复 force(TTL 窗口内只发一次),终态后 clear 释放
|
||||
const forceThrottle = createAsinForceThrottle({ ttlMs: 30_000 })
|
||||
const HISTORY_CACHE_TTL_MS = 3000
|
||||
let historyInFlight: Promise<void> | null = null
|
||||
let lastHistoryLoadedAt = 0
|
||||
@@ -608,6 +611,7 @@ function addPendingFileTask(taskId: number) {
|
||||
|
||||
function removePendingFileTask(taskId: number) {
|
||||
pendingFileTaskIds.value = pendingFileTaskIds.value.filter((id) => id !== taskId)
|
||||
forceThrottle.clear(taskId)
|
||||
if (!pollingTaskIds.value.includes(taskId)) {
|
||||
const next = { ...liveProgressItems.value }
|
||||
delete next[taskId]
|
||||
@@ -683,7 +687,12 @@ async function refreshTaskProgress() {
|
||||
let shouldRefreshHistory = false
|
||||
const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value]))
|
||||
if (taskIds.length) {
|
||||
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 })
|
||||
const force = pendingFileTaskIds.value.some((taskId) => forceThrottle.shouldForce(taskId))
|
||||
const batch = await getSimilarAsinTaskProgressBatch(taskIds, { force })
|
||||
if (force) {
|
||||
// force 请求已发出,进入冷却窗口;TTL 内不再重复 force,避免重复触发文件生成
|
||||
pendingFileTaskIds.value.forEach((taskId) => forceThrottle.markForce(taskId))
|
||||
}
|
||||
for (const detail of batch.items || []) {
|
||||
const task = detail.task
|
||||
if (!task?.id) continue
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Similar ASIN force 请求节流守卫(Task 85)。
|
||||
*
|
||||
* 原实现只要 pendingFileTaskIds 非空,每轮轮询都以 force=true 请求批量
|
||||
* 进度,可能反复触发后端重复生成文件。本守卫为每个 taskId 维护一个 TTL
|
||||
* 窗口:窗口内 shouldForce 返回 false(不重复发 force 请求),到期后
|
||||
* 允许重试(文件生成可能仍在进行,但后端可再次确认);任务终态后调用
|
||||
* clear 立即释放记录,窗口表不会无界增长。
|
||||
*
|
||||
* 纯 TS 无副作用模块:markForce 对非法 taskId fail-fast 抛错,读取路径
|
||||
* (shouldForce/isThrottled/clear/stats)对非法 id 宽容;时钟抛错时
|
||||
* 读取失败但记录不丢失,恢复后可继续工作。
|
||||
*/
|
||||
export interface AsinForceThrottleOptions {
|
||||
/** force 请求的冷却窗口(毫秒),必须为正数 */
|
||||
ttlMs: number
|
||||
/** 时钟来源;默认 Date.now(),测试可注入虚拟时钟 */
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export interface AsinForceThrottleStats {
|
||||
/** 已执行的 force 标记次数 */
|
||||
forcedCount: number
|
||||
/** 被节流跳过的 force 请求次数 */
|
||||
skippedCount: number
|
||||
/** 当前处于冷却窗口内的 taskId 数 */
|
||||
activeCount: number
|
||||
}
|
||||
|
||||
export function createAsinForceThrottle(options: AsinForceThrottleOptions) {
|
||||
const ttlMs = options.ttlMs
|
||||
const now = options.now ?? Date.now
|
||||
if (!(ttlMs > 0)) {
|
||||
throw new Error('ttlMs 必须为正数: ' + ttlMs)
|
||||
}
|
||||
|
||||
const cooldowns = new Map<number, number>()
|
||||
let forcedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
function validateTaskId(taskId: number): boolean {
|
||||
return Number.isFinite(taskId) && taskId > 0 && Number.isInteger(taskId)
|
||||
}
|
||||
|
||||
function isThrottled(taskId: number): boolean {
|
||||
if (!validateTaskId(taskId)) return false
|
||||
const at = now()
|
||||
const until = cooldowns.get(taskId)
|
||||
if (until == null) return false
|
||||
if (at >= until) {
|
||||
cooldowns.delete(taskId)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function shouldForce(taskId: number): boolean {
|
||||
if (!validateTaskId(taskId)) return false
|
||||
if (isThrottled(taskId)) {
|
||||
skippedCount += 1
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function markForce(taskId: number) {
|
||||
if (!validateTaskId(taskId)) {
|
||||
throw new Error('taskId 必须是正整数: ' + taskId)
|
||||
}
|
||||
if (isThrottled(taskId)) return
|
||||
cooldowns.set(taskId, now() + ttlMs)
|
||||
forcedCount += 1
|
||||
}
|
||||
|
||||
function clear(taskId: number): boolean {
|
||||
if (!validateTaskId(taskId)) return false
|
||||
return cooldowns.delete(taskId)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
cooldowns.clear()
|
||||
}
|
||||
|
||||
function stats(): AsinForceThrottleStats {
|
||||
const at = now()
|
||||
for (const [taskId, until] of cooldowns) {
|
||||
if (at >= until) cooldowns.delete(taskId)
|
||||
}
|
||||
return {
|
||||
forcedCount,
|
||||
skippedCount,
|
||||
activeCount: cooldowns.size,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get activeCount() {
|
||||
stats()
|
||||
return cooldowns.size
|
||||
},
|
||||
shouldForce,
|
||||
markForce,
|
||||
isThrottled,
|
||||
clear,
|
||||
clearAll,
|
||||
stats,
|
||||
}
|
||||
}
|
||||
|
||||
export type AsinForceThrottle = ReturnType<typeof createAsinForceThrottle>
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createAsinForceThrottle } from '../src/shared/asin-force-throttle.ts'
|
||||
|
||||
function clock(start = 1000) {
|
||||
let now = start
|
||||
return {
|
||||
now: () => now,
|
||||
tick: (ms: number) => {
|
||||
now += ms
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('test_task_085_asin_polling_normal_default_path', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
assert.equal(throttle.shouldForce(101), true)
|
||||
throttle.markForce(101)
|
||||
assert.equal(throttle.shouldForce(101), false)
|
||||
assert.equal(throttle.isThrottled(101), true)
|
||||
c.tick(29_999)
|
||||
assert.equal(throttle.shouldForce(101), false)
|
||||
c.tick(2)
|
||||
assert.equal(throttle.shouldForce(101), true)
|
||||
assert.equal(throttle.isThrottled(101), false)
|
||||
const stats = throttle.stats()
|
||||
assert.equal(stats.forcedCount, 1)
|
||||
assert.equal(stats.skippedCount, 2)
|
||||
assert.equal(stats.activeCount, 0)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_normal_multiple_items', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
const ids = [201, 202, 203, 204, 205]
|
||||
for (const id of ids) throttle.markForce(id)
|
||||
assert.equal(throttle.activeCount, 5)
|
||||
for (const id of ids) assert.equal(throttle.shouldForce(id), false)
|
||||
// 不同 taskId 的 TTL 相互独立
|
||||
c.tick(31_000)
|
||||
for (const id of ids) assert.equal(throttle.shouldForce(id), true)
|
||||
throttle.markForce(202)
|
||||
assert.equal(throttle.shouldForce(202), false)
|
||||
assert.equal(throttle.shouldForce(201), true)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_normal_repeated_operation_is_idempotent', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
throttle.markForce(301)
|
||||
throttle.markForce(301)
|
||||
throttle.markForce(301)
|
||||
assert.equal(throttle.activeCount, 1)
|
||||
assert.equal(throttle.shouldForce(301), false)
|
||||
assert.equal(throttle.stats().forcedCount, 1)
|
||||
// 重复检查不改变状态
|
||||
assert.equal(throttle.shouldForce(301), false)
|
||||
assert.equal(throttle.isThrottled(301), true)
|
||||
assert.equal(throttle.stats().forcedCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_boundary_empty_input', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
assert.equal(throttle.activeCount, 0)
|
||||
assert.equal(throttle.shouldForce(401), true)
|
||||
throttle.clear(401)
|
||||
assert.equal(throttle.activeCount, 0)
|
||||
// clear 不存在的 taskId 无副作用
|
||||
throttle.clearAll()
|
||||
assert.deepEqual(throttle.stats(), { forcedCount: 0, skippedCount: 0, activeCount: 0 })
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_boundary_single_item', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 1000, now: c.now })
|
||||
assert.equal(throttle.shouldForce(501), true)
|
||||
throttle.markForce(501)
|
||||
assert.equal(throttle.isThrottled(501), true)
|
||||
assert.equal(throttle.activeCount, 1)
|
||||
c.tick(1000)
|
||||
assert.equal(throttle.isThrottled(501), false)
|
||||
assert.equal(throttle.activeCount, 0)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_boundary_limit_and_overflow', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
// 大量任务:所有任务都被记录,未标记的任务不被节流
|
||||
for (let i = 1; i <= 200; i++) throttle.markForce(i)
|
||||
assert.equal(throttle.activeCount, 200)
|
||||
assert.equal(throttle.shouldForce(1), false)
|
||||
assert.equal(throttle.shouldForce(200), false)
|
||||
assert.equal(throttle.shouldForce(201), true)
|
||||
// TTL 到期后整批可重新 force(重试窗口),记录不无界增长
|
||||
c.tick(60_001)
|
||||
assert.equal(throttle.shouldForce(1), true)
|
||||
assert.equal(throttle.activeCount, 0)
|
||||
throttle.markForce(1)
|
||||
assert.equal(throttle.activeCount, 1)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_invalid_input_rejected', () => {
|
||||
const c = clock()
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: c.now })
|
||||
assert.throws(() => throttle.markForce(0), /taskId 必须是正整数/)
|
||||
assert.throws(() => throttle.markForce(-1), /taskId 必须是正整数/)
|
||||
assert.throws(() => throttle.markForce(NaN), /taskId 必须是正整数/)
|
||||
assert.throws(() => throttle.markForce(1.5), /taskId 必须是正整数/)
|
||||
// 读取路径宽容:非法 id 安全返回
|
||||
assert.equal(throttle.shouldForce(0), false)
|
||||
assert.equal(throttle.isThrottled(-1), false)
|
||||
assert.equal(throttle.clear(0), false)
|
||||
assert.throws(() => createAsinForceThrottle({ ttlMs: 0 }), /ttlMs 必须为正数/)
|
||||
})
|
||||
|
||||
test('test_task_085_asin_polling_dependency_failure_releases_resources', () => {
|
||||
let now = 1000
|
||||
let broken = false
|
||||
const faultyNow = () => {
|
||||
if (broken) throw new Error('clock down')
|
||||
return now
|
||||
}
|
||||
const throttle = createAsinForceThrottle({ ttlMs: 30_000, now: faultyNow })
|
||||
throttle.markForce(701)
|
||||
assert.equal(throttle.activeCount, 1)
|
||||
// 时钟故障时读取抛错,但记录不丢失
|
||||
broken = true
|
||||
assert.throws(() => throttle.shouldForce(701), /clock down/)
|
||||
assert.throws(() => throttle.isThrottled(701), /clock down/)
|
||||
assert.throws(() => throttle.stats(), /clock down/)
|
||||
// 恢复后同一实例继续工作,记录仍在
|
||||
broken = false
|
||||
assert.equal(throttle.isThrottled(701), true)
|
||||
assert.equal(throttle.shouldForce(701), false)
|
||||
// 终态后 clear 释放记录
|
||||
throttle.clear(701)
|
||||
assert.equal(throttle.activeCount, 0)
|
||||
assert.equal(throttle.shouldForce(701), true)
|
||||
})
|
||||
Reference in New Issue
Block a user