Files
crawler-plugin/frontend-vue/src/shared/polling-state-machine.ts
T

146 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 轮询状态机(Task 95)。
*
* 收敛轮询 UI 的深色主题无关状态语义:错误提示(error)、重试(retry)、
* 终态刷新(markRefreshed)的有界状态转换。
*
* 状态流:idle → start → polling →(fail)→ retrying →(fail…)→ failed
* └→ succeed → done(终态;仅刷新历史,不重复计数)
*
* 有界语义:
* - 重试次数有上限(maxAttempts),超过后进入 failed,不再重试;
* - 终态幂等:重复 succeed 不覆盖状态、不重复计数;markRefreshed 只计一次;
* - 未 start 时 fail/succeed 抛错;isTerminal 依赖抛错时调用失败但状态
* 零变更,恢复后可继续。
*/
export interface PollingStateMachineOptions {
/** 重试上限,必须为正整数,达到上限后失败不再重试 */
maxAttempts: number
/** 判定终态,默认 SUCCESS/FAILED;抛错时调用失败且状态不变 */
isTerminal?: (status: string) => boolean
}
export interface PollingStateMachine {
/** idle | polling | retrying | done | failed */
status: string
/** 是否仍在轮询(polling 或 retrying */
retrying: boolean
attempts: number
errorCount: number
/** 最近一次终态状态字符串(未终态为空串) */
terminalStatus: string
/** 最近一次错误消息 */
lastError: string
/** 是否已刷新历史(终态后至多一次) */
refreshed: boolean
refreshCount: number
start: () => void
/** 记录一次失败;未超上限进入 retrying,超上限进入 failed */
fail: (message: string) => void
/** 记录一次成功;终态后重复调用幂等 */
succeed: (status: string) => void
/** 标记终态历史已刷新;仅 done/failed 后首个调用生效 */
markRefreshed: () => void
/** 已发生的重试序号列表 */
retries: () => number[]
}
export function createPollingStateMachine(options: PollingStateMachineOptions): PollingStateMachine {
if (typeof options !== 'object' || options == null) {
throw new Error('options 必须是对象')
}
if (!(options.maxAttempts > 0)) {
throw new Error('maxAttempts 必须为正数: ' + options.maxAttempts)
}
if (!Number.isInteger(options.maxAttempts)) {
throw new Error('maxAttempts 必须为整数: ' + options.maxAttempts)
}
const isTerminal = options.isTerminal ?? ((status: string) => status === 'SUCCESS' || status === 'FAILED')
if (typeof isTerminal !== 'function') {
throw new Error('isTerminal 必须是函数')
}
let status = 'idle'
let attempts = 0
let errorCount = 0
let terminalStatus = ''
let lastError = ''
let refreshCount = 0
const retrySequence: number[] = []
function start() {
if (status === 'done' || status === 'failed') return
status = 'polling'
attempts = 0
}
function fail(message: string) {
if (status === 'idle') {
throw new Error('未开始轮询')
}
if (status === 'done' || status === 'failed') return
attempts += 1
errorCount += 1
lastError = message
if (attempts >= options.maxAttempts) {
status = 'failed'
} else {
retrySequence.push(attempts)
status = 'retrying'
}
}
function succeed(terminal: string) {
if (status === 'idle') {
throw new Error('未开始轮询')
}
if (status === 'done' || status === 'failed') return
if (!isTerminal(terminal)) {
// 非终态响应:按一次失败计入,沿用重试语义
fail('非终态响应: ' + terminal)
return
}
attempts += 1
terminalStatus = terminal
status = 'done'
}
function markRefreshed() {
if (status !== 'done' && status !== 'failed') return
if (refreshCount > 0) return
refreshCount = 1
}
return {
get status() {
return status
},
get retrying() {
return status === 'polling' || status === 'retrying'
},
get attempts() {
return attempts
},
get errorCount() {
return errorCount
},
get terminalStatus() {
return terminalStatus
},
get lastError() {
return lastError
},
get refreshed() {
return refreshCount > 0
},
get refreshCount() {
return refreshCount
},
start,
fail,
succeed,
markRefreshed,
retries: () => [...retrySequence],
}
}