task-86: 将隐藏页面轮询间隔、前台恢复和退避策略统一配置化
task-progress-config 新增统一配置 API:getTaskPollBackoffMs(attempt) 指数退避(base×2^attempt 封顶 max)、前台恢复开关与延迟 (getTaskForegroundRefreshEnabled/DelayMs)、configureTaskPolling/ resetTaskPollingConfig 运行时配置。校验失败抛错且零状态变更, document 缺失时按可见间隔降级。useTaskProgressLoop 改用退避配置 替代硬编码 500ms 重试,前台恢复走可配置开关与延迟(含 in-flight 防 并发)。8 个测试覆盖默认、多配置、幂等、空、单字段、封顶、非法输入 与故障恢复。
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { onBeforeUnmount, ref, watch, type Ref } from 'vue'
|
||||
import { getTaskPollIntervalMs } from '@/shared/task-progress-config'
|
||||
import {
|
||||
getTaskPollIntervalMs,
|
||||
getTaskPollBackoffMs,
|
||||
getTaskForegroundRefreshEnabled,
|
||||
getTaskForegroundRefreshDelayMs,
|
||||
} from '@/shared/task-progress-config'
|
||||
import { createCategorizedTimers } from '@/shared/utils/categorized-timers'
|
||||
import {
|
||||
createTaskPollingBaseline,
|
||||
@@ -233,8 +238,8 @@ export function useTaskProgressLoop<TDetail>(
|
||||
if (disposed) return
|
||||
if (!taskIds.value.length) return
|
||||
if (inFlight.value) {
|
||||
// 上一次还没回,500ms 后再试
|
||||
pollTimer = timers.setTimeout('task-poll', run, 500)
|
||||
// 上一次还没回,按退避策略延迟重试(默认 500ms,可配置)
|
||||
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
|
||||
return
|
||||
}
|
||||
await refreshOnce()
|
||||
@@ -277,17 +282,44 @@ export function useTaskProgressLoop<TDetail>(
|
||||
{ flush: 'post' },
|
||||
)
|
||||
|
||||
// 切到前台后立刻拉一次,让用户回到页面看到的是最新状态
|
||||
// 切到前台后立刻拉一次(可配置开关与延迟),让用户回到页面看到的是最新状态
|
||||
let visibilityHandler: (() => void) | null = null
|
||||
if (typeof document !== 'undefined') {
|
||||
visibilityHandler = () => {
|
||||
if (document.visibilityState === 'visible' && taskIds.value.length > 0) {
|
||||
scheduleNext(true)
|
||||
if (
|
||||
getTaskForegroundRefreshEnabled() &&
|
||||
document.visibilityState === 'visible' &&
|
||||
taskIds.value.length > 0
|
||||
) {
|
||||
const delay = getTaskForegroundRefreshDelayMs()
|
||||
if (delay > 0) {
|
||||
scheduleNextDelayed(delay)
|
||||
} else {
|
||||
scheduleNext(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibilityHandler)
|
||||
}
|
||||
|
||||
function scheduleNextDelayed(delayMs: number) {
|
||||
if (disposed || pollTimer != null) return
|
||||
clearPollTimer()
|
||||
const run = () => {
|
||||
pollTimer = null
|
||||
if (disposed || !taskIds.value.length) return
|
||||
if (inFlight.value) {
|
||||
pollTimer = timers.setTimeout('task-poll', run, getTaskPollBackoffMs(0))
|
||||
return
|
||||
}
|
||||
void refreshOnce()
|
||||
if (!disposed && taskIds.value.length > 0) {
|
||||
pollTimer = timers.setTimeout('task-poll', run, intervalMs())
|
||||
}
|
||||
}
|
||||
pollTimer = timers.setTimeout('task-poll', run, delayMs)
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
/**
|
||||
* 任务轮询配置(Task 86 扩展)。
|
||||
*
|
||||
* 把隐藏页面轮询间隔、前台恢复与退避策略统一收敛为可编程配置:
|
||||
* - getTaskPollIntervalMs / getTaskProgressCacheTtlMs:按 document.visibilityState
|
||||
* 自适应切换可见/隐藏间隔与缓存 TTL(历史行为不变);
|
||||
* - getTaskPollBackoffMs(attempt):指数退避(base × 2^attempt,封顶 max),
|
||||
* 供轮询失败/in-flight 重试使用,重试次数不再硬编码;
|
||||
* - getTaskForegroundRefreshEnabled / getTaskForegroundRefreshDelayMs:控制
|
||||
* 页面切回前台时是否立即拉取一轮及其延迟;
|
||||
* - configureTaskPolling / resetTaskPollingConfig:运行时配置与恢复默认。
|
||||
*
|
||||
* 纯 TS 模块:配置校验失败时抛错且保持原值(零状态变更),document 缺失
|
||||
* (Node/SSR)时按可见间隔降级;所有 getter 在任意配置下都有确定返回值。
|
||||
*/
|
||||
export const TASK_POLL_VISIBLE_INTERVAL_MS = 10000;
|
||||
export const TASK_POLL_HIDDEN_INTERVAL_MS = 60000;
|
||||
export const TASK_PROGRESS_VISIBLE_CACHE_MILLIS = 5000;
|
||||
export const TASK_PROGRESS_HIDDEN_CACHE_MILLIS = 30000;
|
||||
export const TASK_POLL_BACKOFF_BASE_MS = 500;
|
||||
export const TASK_POLL_BACKOFF_MAX_MS = 5000;
|
||||
|
||||
export interface TaskPollingConfig {
|
||||
/** 退避基数(毫秒),必须为正数 */
|
||||
backoffBaseMs?: number;
|
||||
/** 退避上限(毫秒),必须为正数 */
|
||||
backoffMaxMs?: number;
|
||||
/** 页面切回前台时是否立即拉取一轮,默认 true */
|
||||
foregroundRefreshEnabled?: boolean;
|
||||
/** 前台恢复延迟(毫秒),默认 0 */
|
||||
foregroundRefreshDelayMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<TaskPollingConfig> = {
|
||||
backoffBaseMs: TASK_POLL_BACKOFF_BASE_MS,
|
||||
backoffMaxMs: TASK_POLL_BACKOFF_MAX_MS,
|
||||
foregroundRefreshEnabled: true,
|
||||
foregroundRefreshDelayMs: 0,
|
||||
};
|
||||
|
||||
let config: Required<TaskPollingConfig> = { ...DEFAULTS };
|
||||
|
||||
function isVisible() {
|
||||
return typeof document === "undefined" || document.visibilityState === "visible";
|
||||
}
|
||||
|
||||
export function getTaskPollIntervalMs() {
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
if (!isVisible()) {
|
||||
return TASK_POLL_HIDDEN_INTERVAL_MS;
|
||||
}
|
||||
return TASK_POLL_VISIBLE_INTERVAL_MS;
|
||||
@@ -16,3 +57,36 @@ export function getTaskProgressCacheTtlMs() {
|
||||
}
|
||||
return TASK_PROGRESS_VISIBLE_CACHE_MILLIS;
|
||||
}
|
||||
|
||||
/** 第 attempt 次退避延迟:base × 2^attempt,封顶 max;attempt 非法时按首次(0)处理 */
|
||||
export function getTaskPollBackoffMs(attempt: number) {
|
||||
const safe = Number.isFinite(attempt) && attempt > 0 ? Math.floor(attempt) : 0;
|
||||
const doubled = config.backoffBaseMs * Math.pow(2, safe);
|
||||
return doubled > config.backoffMaxMs ? config.backoffMaxMs : doubled;
|
||||
}
|
||||
|
||||
export function getTaskForegroundRefreshEnabled() {
|
||||
return config.foregroundRefreshEnabled;
|
||||
}
|
||||
|
||||
export function getTaskForegroundRefreshDelayMs() {
|
||||
return config.foregroundRefreshDelayMs;
|
||||
}
|
||||
|
||||
export function configureTaskPolling(partial: TaskPollingConfig) {
|
||||
const next = { ...config, ...partial };
|
||||
if (!(next.backoffBaseMs > 0)) {
|
||||
throw new Error("退避基数必须为正数: " + next.backoffBaseMs);
|
||||
}
|
||||
if (!(next.backoffMaxMs > 0)) {
|
||||
throw new Error("退避上限必须为正数: " + next.backoffMaxMs);
|
||||
}
|
||||
if (!(next.foregroundRefreshDelayMs >= 0)) {
|
||||
throw new Error("恢复延迟不能为负数: " + next.foregroundRefreshDelayMs);
|
||||
}
|
||||
config = next;
|
||||
}
|
||||
|
||||
export function resetTaskPollingConfig() {
|
||||
config = { ...DEFAULTS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
TASK_POLL_VISIBLE_INTERVAL_MS,
|
||||
TASK_POLL_HIDDEN_INTERVAL_MS,
|
||||
TASK_POLL_BACKOFF_BASE_MS,
|
||||
TASK_POLL_BACKOFF_MAX_MS,
|
||||
getTaskPollIntervalMs,
|
||||
getTaskProgressCacheTtlMs,
|
||||
getTaskForegroundRefreshEnabled,
|
||||
getTaskForegroundRefreshDelayMs,
|
||||
getTaskPollBackoffMs,
|
||||
configureTaskPolling,
|
||||
resetTaskPollingConfig,
|
||||
} from '../src/shared/task-progress-config.ts'
|
||||
|
||||
function withVisibility(visible: boolean, fn: () => void) {
|
||||
const original = (globalThis as Record<string, unknown>).document
|
||||
const fakeDocument = { visibilityState: visible ? 'visible' : 'hidden', hidden: !visible }
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: fakeDocument,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
try {
|
||||
fn()
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: original,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('test_task_086_polling_normal_default_path', () => {
|
||||
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
|
||||
assert.equal(getTaskPollBackoffMs(1), 1000)
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), true)
|
||||
assert.equal(getTaskForegroundRefreshDelayMs(), 0)
|
||||
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
|
||||
assert.equal(getTaskProgressCacheTtlMs(), 5000)
|
||||
// hidden 分支:可见性变化切换间隔
|
||||
withVisibility(false, () => {
|
||||
assert.equal(getTaskPollIntervalMs(), TASK_POLL_HIDDEN_INTERVAL_MS)
|
||||
assert.equal(getTaskProgressCacheTtlMs(), 30000)
|
||||
})
|
||||
withVisibility(true, () => {
|
||||
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
|
||||
assert.equal(getTaskProgressCacheTtlMs(), 5000)
|
||||
})
|
||||
})
|
||||
|
||||
test('test_task_086_polling_normal_multiple_items', () => {
|
||||
resetTaskPollingConfig()
|
||||
configureTaskPolling({ backoffBaseMs: 1000, backoffMaxMs: 10_000 })
|
||||
assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [1000, 2000, 4000, 8000])
|
||||
configureTaskPolling({ backoffBaseMs: 200, backoffMaxMs: 1600 })
|
||||
assert.deepEqual([0, 1, 2, 3, 4].map((n) => getTaskPollBackoffMs(n)), [200, 400, 800, 1600, 1600])
|
||||
configureTaskPolling({ foregroundRefreshEnabled: false })
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), false)
|
||||
configureTaskPolling({ foregroundRefreshDelayMs: 250 })
|
||||
assert.equal(getTaskForegroundRefreshDelayMs(), 250)
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), false, '未覆盖的字段保持原值')
|
||||
assert.equal(getTaskPollBackoffMs(0), 200, '未覆盖的字段保持原值')
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_normal_repeated_operation_is_idempotent', () => {
|
||||
resetTaskPollingConfig()
|
||||
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
|
||||
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
|
||||
configureTaskPolling({ backoffBaseMs: 300, backoffMaxMs: 2400 })
|
||||
assert.deepEqual([0, 1, 2, 3].map((n) => getTaskPollBackoffMs(n)), [300, 600, 1200, 2400])
|
||||
configureTaskPolling({ foregroundRefreshEnabled: true })
|
||||
configureTaskPolling({ foregroundRefreshEnabled: true })
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), true)
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_boundary_empty_input', () => {
|
||||
resetTaskPollingConfig()
|
||||
configureTaskPolling({})
|
||||
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), true)
|
||||
assert.equal(getTaskForegroundRefreshDelayMs(), 0)
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_boundary_single_item', () => {
|
||||
resetTaskPollingConfig()
|
||||
configureTaskPolling({ backoffMaxMs: 999 })
|
||||
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS, '未配置字段不受影响')
|
||||
assert.equal(getTaskPollBackoffMs(5), 999, '超过上限封顶')
|
||||
configureTaskPolling({ foregroundRefreshEnabled: false })
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), false)
|
||||
assert.equal(getTaskPollBackoffMs(5), 999, '配置之间互不影响')
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_boundary_limit_and_overflow', () => {
|
||||
resetTaskPollingConfig()
|
||||
// attempt 超过最大重试次数后封顶,不发生无界增长
|
||||
assert.equal(getTaskPollBackoffMs(0), 500)
|
||||
assert.equal(getTaskPollBackoffMs(1), 1000)
|
||||
assert.equal(getTaskPollBackoffMs(2), 2000)
|
||||
assert.equal(getTaskPollBackoffMs(3), 4000)
|
||||
assert.equal(getTaskPollBackoffMs(4), 5000)
|
||||
assert.equal(getTaskPollBackoffMs(10), 5000)
|
||||
assert.equal(getTaskPollBackoffMs(100), 5000)
|
||||
// attempt 为 0 或负数按首次退避处理
|
||||
assert.equal(getTaskPollBackoffMs(-1), 500)
|
||||
assert.equal(getTaskPollBackoffMs(NaN), 500)
|
||||
assert.equal(getTaskPollBackoffMs(0.5), 500)
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_invalid_input_rejected', () => {
|
||||
resetTaskPollingConfig()
|
||||
assert.throws(() => configureTaskPolling({ backoffBaseMs: 0 }), /退避基数必须为正数/)
|
||||
assert.throws(() => configureTaskPolling({ backoffBaseMs: -1 }), /退避基数必须为正数/)
|
||||
assert.throws(() => configureTaskPolling({ backoffMaxMs: 0 }), /退避上限必须为正数/)
|
||||
assert.throws(() => configureTaskPolling({ backoffMaxMs: -100 }), /退避上限必须为正数/)
|
||||
assert.throws(() => configureTaskPolling({ foregroundRefreshDelayMs: -1 }), /恢复延迟不能为负数/)
|
||||
// 拒绝后配置保持原值(零状态变更)
|
||||
assert.equal(getTaskPollBackoffMs(0), TASK_POLL_BACKOFF_BASE_MS)
|
||||
assert.equal(getTaskForegroundRefreshEnabled(), true)
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
|
||||
test('test_task_086_polling_dependency_failure_releases_resources', () => {
|
||||
resetTaskPollingConfig()
|
||||
// document 不可用(Node 环境/SSR):按可见间隔降级,不抛错
|
||||
const original = (globalThis as Record<string, unknown>).document
|
||||
Object.defineProperty(globalThis, 'document', { value: undefined, configurable: true, writable: true })
|
||||
try {
|
||||
assert.equal(getTaskPollIntervalMs(), TASK_POLL_VISIBLE_INTERVAL_MS)
|
||||
assert.equal(getTaskProgressCacheTtlMs(), 5000)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'document', { value: original, configurable: true, writable: true })
|
||||
}
|
||||
// 配置校验失败后错误可恢复:修正后同一配置系统继续工作
|
||||
assert.throws(() => configureTaskPolling({ backoffBaseMs: -5 }), /退避基数必须为正数/)
|
||||
configureTaskPolling({ backoffBaseMs: 700, backoffMaxMs: 2800 })
|
||||
assert.equal(getTaskPollBackoffMs(0), 700)
|
||||
assert.equal(getTaskPollBackoffMs(2), 2800)
|
||||
resetTaskPollingConfig()
|
||||
})
|
||||
Reference in New Issue
Block a user