@vobs/queue 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/package.json +25 -0
- package/src/index.test.ts +146 -0
- package/src/index.ts +493 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vobs contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @vobs/queue
|
|
2
|
+
|
|
3
|
+
Priority task queue for vobs with concurrency limits, per-task retry, AbortSignal cancellation, and reactive statistics.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vobs/queue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { createTaskQueue } from '@vobs/queue'
|
|
15
|
+
|
|
16
|
+
const queue = createTaskQueue({ concurrency: 2 })
|
|
17
|
+
|
|
18
|
+
const task = queue.add(
|
|
19
|
+
signal => fetch('/api/jobs/42', { signal }).then(res => res.json()),
|
|
20
|
+
{ priority: 'high', retry: 2, retryDelay: attempt => attempt * 250 }
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
task.status.value // 'pending', then 'running', 'retrying', 'success', 'error', or 'cancelled'
|
|
24
|
+
await task.promise
|
|
25
|
+
queue.completed.value // counters: pending, processing, completed, failed, total
|
|
26
|
+
|
|
27
|
+
task.cancel() // aborts queued or running tasks through their signal
|
|
28
|
+
await task.retry() // re-run a failed or cancelled task
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Tasks run by priority (`critical`, `high`, `normal`, `low`), FIFO within the same priority. Cancelling aborts the task's `AbortSignal` and rejects its promise with a `QueueError` of code `QUEUE_TASK_CANCELLED`. Failed tasks retry up to `retry` times after `retryDelay`, then settle as `error` and invoke the queue's `onError`. `pause()` stops new tasks from starting until `resume()`.
|
|
32
|
+
|
|
33
|
+
## API
|
|
34
|
+
|
|
35
|
+
| Signature | Description |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `createTaskQueue(options?: TaskQueueOptions): TaskQueue` | Options: `concurrency` (default 3, alias `concurrent`), `idFactory`, `onError`. |
|
|
38
|
+
| `queue.add(fn, options?): QueueTask` | `fn` receives an `AbortSignal`; task options: `id`, `priority`, `retry`, `retryDelay`. |
|
|
39
|
+
| `queue.pause() / resume()` | Hold new tasks, then drain the queue again. |
|
|
40
|
+
| `queue.clear()` | Cancel queued tasks; running tasks keep going. |
|
|
41
|
+
| `queue.dispose()` | Cancel and release every task and signal. |
|
|
42
|
+
| `queue.pending / processing / completed / failed / total / paused / tasks` | Reactive queue statistics. |
|
|
43
|
+
| `task.status / attempt / result / error` | Signals tracking one task. |
|
|
44
|
+
| `task.promise` | Resolves with the result or rejects with `QueueError`. |
|
|
45
|
+
| `task.cancel() / task.retry()` | Abort via the signal; resubmit a settled task. |
|
|
46
|
+
| `queuePlugin(options?) / useQueue()` | Provide and inject `QUEUE_KEY` in a vobs app. |
|
|
47
|
+
| `QueueError` | Error with `code`: `INVALID_TASK`, `QUEUE_TASK_CANCELLED`, `QUEUE_TASK_FAILED`, `QUEUE_CONTEXT_DISPOSED`. |
|
|
48
|
+
|
|
49
|
+
## Types
|
|
50
|
+
|
|
51
|
+
TaskPriority, QueueTaskStatus, QueueRetryDelay, QueueTask, QueueTaskFunction, QueueTaskOptions, TaskQueue, TaskQueueOptions, QueuePluginOptions, QueueErrorCode
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"license": "MIT",
|
|
3
|
+
"files": [
|
|
4
|
+
"src",
|
|
5
|
+
"README.md",
|
|
6
|
+
"LICENSE"
|
|
7
|
+
],
|
|
8
|
+
"name": "@vobs/queue",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"description": "Controlled asynchronous task queues for Vobs applications.",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "src/index.ts",
|
|
13
|
+
"types": "src/index.ts",
|
|
14
|
+
"sideEffects": false,
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@vobs/vobs": "1.0.0",
|
|
20
|
+
"@vobs/reactivity": "1.0.0"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "vitest --environment jsdom"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createText, createVobs } from '@vobs/vobs'
|
|
3
|
+
import {
|
|
4
|
+
QUEUE_KEY,
|
|
5
|
+
QueueError,
|
|
6
|
+
createTaskQueue,
|
|
7
|
+
queuePlugin,
|
|
8
|
+
useQueue
|
|
9
|
+
} from './index'
|
|
10
|
+
|
|
11
|
+
describe('@vobs/queue', () => {
|
|
12
|
+
it('按优先级调度任务,并暴露响应式统计', async () => {
|
|
13
|
+
const calls: string[] = []
|
|
14
|
+
const queue = createTaskQueue({ concurrency: 1 })
|
|
15
|
+
const first = queue.add(async () => {
|
|
16
|
+
calls.push('normal-1')
|
|
17
|
+
return 1
|
|
18
|
+
})
|
|
19
|
+
const high = queue.add(async () => {
|
|
20
|
+
calls.push('high')
|
|
21
|
+
return 2
|
|
22
|
+
}, { priority: 'high' })
|
|
23
|
+
const last = queue.add(async () => {
|
|
24
|
+
calls.push('normal-2')
|
|
25
|
+
return 3
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await expect(first.promise).resolves.toBe(1)
|
|
29
|
+
await expect(high.promise).resolves.toBe(2)
|
|
30
|
+
await expect(last.promise).resolves.toBe(3)
|
|
31
|
+
expect(calls).toEqual(['normal-1', 'high', 'normal-2'])
|
|
32
|
+
expect(queue.pending.value).toBe(0)
|
|
33
|
+
expect(queue.processing.value).toBe(0)
|
|
34
|
+
expect(queue.completed.value).toBe(3)
|
|
35
|
+
expect(queue.failed.value).toBe(0)
|
|
36
|
+
expect(queue.total.value).toBe(3)
|
|
37
|
+
queue.dispose()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('限制并发,并在暂停期间不启动新任务', async () => {
|
|
41
|
+
const resolvers: Array<() => void> = []
|
|
42
|
+
let running = 0
|
|
43
|
+
let maxRunning = 0
|
|
44
|
+
const queue = createTaskQueue({ concurrent: 2 })
|
|
45
|
+
queue.pause()
|
|
46
|
+
const tasks = [1, 2, 3].map(id => queue.add(async () => new Promise(resolve => {
|
|
47
|
+
running++
|
|
48
|
+
maxRunning = Math.max(maxRunning, running)
|
|
49
|
+
resolvers.push(() => {
|
|
50
|
+
running--
|
|
51
|
+
resolve(id)
|
|
52
|
+
})
|
|
53
|
+
})))
|
|
54
|
+
|
|
55
|
+
expect(queue.paused.value).toBe(true)
|
|
56
|
+
expect(queue.pending.value).toBe(3)
|
|
57
|
+
expect(resolvers).toHaveLength(0)
|
|
58
|
+
queue.resume()
|
|
59
|
+
await vi.waitFor(() => expect(resolvers).toHaveLength(2))
|
|
60
|
+
resolvers.shift()?.()
|
|
61
|
+
resolvers.shift()?.()
|
|
62
|
+
await vi.waitFor(() => expect(resolvers).toHaveLength(1))
|
|
63
|
+
resolvers.shift()?.()
|
|
64
|
+
await Promise.all(tasks.map(task => task.promise))
|
|
65
|
+
expect(maxRunning).toBe(2)
|
|
66
|
+
queue.dispose()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('失败后按 retry 和 retryDelay 重试,最终拒绝并通知错误', async () => {
|
|
70
|
+
let attempts = 0
|
|
71
|
+
const onError = vi.fn()
|
|
72
|
+
const queue = createTaskQueue({ onError })
|
|
73
|
+
const task = queue.add(async () => {
|
|
74
|
+
attempts++
|
|
75
|
+
throw new Error(`failure-${attempts}`)
|
|
76
|
+
}, { retry: 2, retryDelay: 0 })
|
|
77
|
+
|
|
78
|
+
await expect(task.promise).rejects.toMatchObject({ code: 'QUEUE_TASK_FAILED' })
|
|
79
|
+
expect(attempts).toBe(3)
|
|
80
|
+
expect(task.attempt.value).toBe(3)
|
|
81
|
+
expect(task.status.value).toBe('error')
|
|
82
|
+
expect(queue.failed.value).toBe(1)
|
|
83
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'QUEUE_TASK_FAILED' }), task)
|
|
84
|
+
queue.dispose()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('取消排队和运行中的任务,并允许失败任务重新提交', async () => {
|
|
88
|
+
let resolveRunning: (() => void) | undefined
|
|
89
|
+
let aborted = false
|
|
90
|
+
const queue = createTaskQueue({ concurrency: 1 })
|
|
91
|
+
const running = queue.add(signal => new Promise(resolve => {
|
|
92
|
+
resolveRunning = () => resolve('done')
|
|
93
|
+
signal.addEventListener('abort', () => { aborted = true }, { once: true })
|
|
94
|
+
}))
|
|
95
|
+
const pending = queue.add(() => 'pending')
|
|
96
|
+
await vi.waitFor(() => expect(running.status.value).toBe('running'))
|
|
97
|
+
pending.cancel()
|
|
98
|
+
await expect(pending.promise).rejects.toMatchObject({ code: 'QUEUE_TASK_CANCELLED' })
|
|
99
|
+
running.cancel()
|
|
100
|
+
await expect(running.promise).rejects.toMatchObject({ code: 'QUEUE_TASK_CANCELLED' })
|
|
101
|
+
expect(aborted).toBe(true)
|
|
102
|
+
resolveRunning?.()
|
|
103
|
+
|
|
104
|
+
let shouldFail = true
|
|
105
|
+
const recoverable = queue.add(() => {
|
|
106
|
+
if (shouldFail) {
|
|
107
|
+
shouldFail = false
|
|
108
|
+
throw new Error('try again')
|
|
109
|
+
}
|
|
110
|
+
return 'recovered'
|
|
111
|
+
})
|
|
112
|
+
await expect(recoverable.promise).rejects.toBeInstanceOf(QueueError)
|
|
113
|
+
await expect(recoverable.retry()).resolves.toBe('recovered')
|
|
114
|
+
expect(recoverable.status.value).toBe('success')
|
|
115
|
+
queue.dispose()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('clear 只取消排队任务,插件注入并在应用销毁后清理上下文', async () => {
|
|
119
|
+
let injected: ReturnType<typeof createTaskQueue> | undefined
|
|
120
|
+
const app = createVobs({
|
|
121
|
+
render: () => createText('queue'),
|
|
122
|
+
plugins: [
|
|
123
|
+
queuePlugin({ concurrency: 1 }),
|
|
124
|
+
{ name: 'consumer', install(context) { injected = context.inject(QUEUE_KEY) as ReturnType<typeof createTaskQueue> } }
|
|
125
|
+
]
|
|
126
|
+
})
|
|
127
|
+
const first = injected!.add(() => new Promise(() => undefined))
|
|
128
|
+
const second = injected!.add(() => 'never')
|
|
129
|
+
void first.promise.catch(() => undefined)
|
|
130
|
+
await vi.waitFor(() => expect(first.status.value).toBe('running'))
|
|
131
|
+
injected!.clear()
|
|
132
|
+
await expect(second.promise).rejects.toMatchObject({ code: 'QUEUE_TASK_CANCELLED' })
|
|
133
|
+
app.destroy()
|
|
134
|
+
expect(() => injected!.add(() => 'later')).toThrowError(expect.objectContaining({ code: 'QUEUE_CONTEXT_DISPOSED' }))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('未安装插件时 useQueue 给出明确错误', () => {
|
|
138
|
+
const app = createVobs({ render: () => {
|
|
139
|
+
useQueue()
|
|
140
|
+
return createText('')
|
|
141
|
+
} })
|
|
142
|
+
expect(() => app.mount(document.createElement('div'))).toThrowError(
|
|
143
|
+
expect.objectContaining({ code: 'QUEUE_CONTEXT_MISSING' })
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { getCurrentOwner, onDispose, state, type Signal } from '@vobs/reactivity'
|
|
2
|
+
import { createInjectionKey, inject, type InjectionKey, type VobsPlugin } from '@vobs/vobs'
|
|
3
|
+
|
|
4
|
+
export type TaskPriority = 'low' | 'normal' | 'high' | 'critical'
|
|
5
|
+
export type QueueTaskStatus = 'pending' | 'running' | 'retrying' | 'success' | 'error' | 'cancelled'
|
|
6
|
+
export type QueueRetryDelay = number | ((attempt: number, error: Error) => number)
|
|
7
|
+
|
|
8
|
+
export interface QueueTask<T = unknown> {
|
|
9
|
+
readonly id: string
|
|
10
|
+
readonly priority: TaskPriority
|
|
11
|
+
readonly status: Signal<QueueTaskStatus>
|
|
12
|
+
readonly attempt: Signal<number>
|
|
13
|
+
readonly result: Signal<T | null>
|
|
14
|
+
readonly error: Signal<Error | null>
|
|
15
|
+
readonly promise: Promise<T>
|
|
16
|
+
cancel(): void
|
|
17
|
+
retry(): Promise<T>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QueueTaskOptions {
|
|
21
|
+
readonly id?: string
|
|
22
|
+
readonly priority?: TaskPriority
|
|
23
|
+
readonly retry?: number
|
|
24
|
+
readonly retryDelay?: QueueRetryDelay
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type QueueTaskFunction<T> = (signal: AbortSignal) => T | PromiseLike<T>
|
|
28
|
+
|
|
29
|
+
export interface TaskQueueOptions {
|
|
30
|
+
/** Maximum number of task functions running at once. */
|
|
31
|
+
readonly concurrency?: number
|
|
32
|
+
/** Alias retained for the terminology used in the design document. */
|
|
33
|
+
readonly concurrent?: number
|
|
34
|
+
readonly idFactory?: () => string
|
|
35
|
+
readonly onError?: (error: QueueError, task: QueueTask) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface QueuePluginOptions extends TaskQueueOptions {
|
|
39
|
+
readonly queue?: TaskQueue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface TaskQueue {
|
|
43
|
+
readonly pending: Signal<number>
|
|
44
|
+
readonly processing: Signal<number>
|
|
45
|
+
readonly completed: Signal<number>
|
|
46
|
+
readonly failed: Signal<number>
|
|
47
|
+
readonly total: Signal<number>
|
|
48
|
+
readonly paused: Signal<boolean>
|
|
49
|
+
readonly tasks: Signal<readonly QueueTask[]>
|
|
50
|
+
add<T>(fn: QueueTaskFunction<T>, options?: QueueTaskOptions): QueueTask<T>
|
|
51
|
+
pause(): void
|
|
52
|
+
resume(): void
|
|
53
|
+
clear(): void
|
|
54
|
+
dispose(): void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type QueueErrorCode =
|
|
58
|
+
| 'QUEUE_CONTEXT_MISSING'
|
|
59
|
+
| 'QUEUE_CONTEXT_DISPOSED'
|
|
60
|
+
| 'INVALID_QUEUE_OPTIONS'
|
|
61
|
+
| 'INVALID_TASK'
|
|
62
|
+
| 'QUEUE_TASK_CANCELLED'
|
|
63
|
+
| 'QUEUE_TASK_FAILED'
|
|
64
|
+
|
|
65
|
+
export class QueueError extends Error {
|
|
66
|
+
readonly code: QueueErrorCode
|
|
67
|
+
readonly cause: unknown
|
|
68
|
+
|
|
69
|
+
constructor(code: QueueErrorCode, message: string, cause?: unknown) {
|
|
70
|
+
super(message)
|
|
71
|
+
this.name = 'QueueError'
|
|
72
|
+
this.code = code
|
|
73
|
+
this.cause = cause
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const QUEUE_KEY: InjectionKey<TaskQueue> = createInjectionKey<TaskQueue>('vobs.queue')
|
|
78
|
+
|
|
79
|
+
const PRIORITIES: Readonly<Record<TaskPriority, number>> = {
|
|
80
|
+
low: 0,
|
|
81
|
+
normal: 1,
|
|
82
|
+
high: 2,
|
|
83
|
+
critical: 3
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface InternalTask<T> extends QueueTask<T> {
|
|
87
|
+
readonly fn: QueueTaskFunction<T>
|
|
88
|
+
readonly maxRetries: number
|
|
89
|
+
readonly retryDelay: QueueRetryDelay
|
|
90
|
+
readonly sequence: number
|
|
91
|
+
controller: AbortController | null
|
|
92
|
+
settled: boolean
|
|
93
|
+
executing: boolean
|
|
94
|
+
retryQueued: boolean
|
|
95
|
+
resolve: ((value: T) => void) | null
|
|
96
|
+
reject: ((reason: unknown) => void) | null
|
|
97
|
+
run(): Promise<void>
|
|
98
|
+
dispose(): void
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function createTaskQueue(options: TaskQueueOptions = {}): TaskQueue {
|
|
102
|
+
const concurrency = resolveConcurrency(options)
|
|
103
|
+
const tasks = state<readonly QueueTask[]>([])
|
|
104
|
+
const pending = state(0)
|
|
105
|
+
const processing = state(0)
|
|
106
|
+
const completed = state(0)
|
|
107
|
+
const failed = state(0)
|
|
108
|
+
const total = state(0)
|
|
109
|
+
const paused = state(false)
|
|
110
|
+
const queue: InternalTask<any>[] = []
|
|
111
|
+
const ownedTasks = new Set<InternalTask<any>>()
|
|
112
|
+
let sequence = 0
|
|
113
|
+
let active = 0
|
|
114
|
+
let nextId = 0
|
|
115
|
+
let disposed = false
|
|
116
|
+
|
|
117
|
+
const context: TaskQueue = {
|
|
118
|
+
pending,
|
|
119
|
+
processing,
|
|
120
|
+
completed,
|
|
121
|
+
failed,
|
|
122
|
+
total,
|
|
123
|
+
paused,
|
|
124
|
+
tasks,
|
|
125
|
+
|
|
126
|
+
add<T>(fn: QueueTaskFunction<T>, taskOptions: QueueTaskOptions = {}): QueueTask<T> {
|
|
127
|
+
ensureActive()
|
|
128
|
+
if (typeof fn !== 'function') {
|
|
129
|
+
throw new QueueError('INVALID_TASK', 'Vobs Queue: task 必须是函数')
|
|
130
|
+
}
|
|
131
|
+
const id = taskOptions.id ?? options.idFactory?.() ?? `task-${++nextId}`
|
|
132
|
+
if (typeof id !== 'string' || id.trim() === '') {
|
|
133
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: task id 必须是非空字符串')
|
|
134
|
+
}
|
|
135
|
+
if (tasks.value.some(task => task.id === id)) {
|
|
136
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', `Vobs Queue: 已存在任务 ${id}`)
|
|
137
|
+
}
|
|
138
|
+
const task = createTask(fn, id, taskOptions)
|
|
139
|
+
ownedTasks.add(task)
|
|
140
|
+
tasks.value = Object.freeze([...tasks.value, task])
|
|
141
|
+
queue.push(task)
|
|
142
|
+
sortQueue()
|
|
143
|
+
refreshStats()
|
|
144
|
+
drain()
|
|
145
|
+
return task
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
pause(): void {
|
|
149
|
+
ensureActive()
|
|
150
|
+
paused.value = true
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
resume(): void {
|
|
154
|
+
ensureActive()
|
|
155
|
+
if (!paused.value) return
|
|
156
|
+
paused.value = false
|
|
157
|
+
drain()
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
clear(): void {
|
|
161
|
+
ensureActive()
|
|
162
|
+
for (const task of [...queue]) task.cancel()
|
|
163
|
+
queue.length = 0
|
|
164
|
+
refreshStats()
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
dispose(): void {
|
|
168
|
+
if (disposed) return
|
|
169
|
+
disposed = true
|
|
170
|
+
for (const task of [...ownedTasks]) task.cancel()
|
|
171
|
+
queue.length = 0
|
|
172
|
+
for (const task of ownedTasks) task.dispose()
|
|
173
|
+
ownedTasks.clear()
|
|
174
|
+
tasks.value = Object.freeze([])
|
|
175
|
+
pending.dispose()
|
|
176
|
+
processing.dispose()
|
|
177
|
+
completed.dispose()
|
|
178
|
+
failed.dispose()
|
|
179
|
+
total.dispose()
|
|
180
|
+
paused.dispose()
|
|
181
|
+
tasks.dispose()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (getCurrentOwner()) onDispose(context.dispose)
|
|
186
|
+
return context
|
|
187
|
+
|
|
188
|
+
function createTask<T>(fn: QueueTaskFunction<T>, id: string, taskOptions: QueueTaskOptions): InternalTask<T> {
|
|
189
|
+
const priority = taskOptions.priority ?? 'normal'
|
|
190
|
+
if (!(priority in PRIORITIES)) {
|
|
191
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', `Vobs Queue: 不支持任务优先级 ${String(priority)}`)
|
|
192
|
+
}
|
|
193
|
+
const maxRetries = taskOptions.retry ?? 0
|
|
194
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
195
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: retry 必须是大于等于 0 的整数')
|
|
196
|
+
}
|
|
197
|
+
const retryDelay = taskOptions.retryDelay ?? 0
|
|
198
|
+
validateRetryDelay(retryDelay)
|
|
199
|
+
|
|
200
|
+
const status = state<QueueTaskStatus>('pending')
|
|
201
|
+
const attempt = state(0)
|
|
202
|
+
const result = state<T | null>(null)
|
|
203
|
+
const error = state<Error | null>(null)
|
|
204
|
+
let promise!: Promise<T>
|
|
205
|
+
|
|
206
|
+
const task: InternalTask<T> = {
|
|
207
|
+
id,
|
|
208
|
+
priority,
|
|
209
|
+
status,
|
|
210
|
+
attempt,
|
|
211
|
+
result,
|
|
212
|
+
error,
|
|
213
|
+
get promise(): Promise<T> { return promise },
|
|
214
|
+
fn,
|
|
215
|
+
maxRetries,
|
|
216
|
+
retryDelay,
|
|
217
|
+
sequence: sequence++,
|
|
218
|
+
controller: null,
|
|
219
|
+
settled: false,
|
|
220
|
+
executing: false,
|
|
221
|
+
retryQueued: false,
|
|
222
|
+
resolve: null,
|
|
223
|
+
reject: null,
|
|
224
|
+
|
|
225
|
+
cancel(): void {
|
|
226
|
+
if (status.value === 'success' || status.value === 'error' || status.value === 'cancelled') return
|
|
227
|
+
task.controller?.abort()
|
|
228
|
+
removeFromQueue(task)
|
|
229
|
+
status.value = 'cancelled'
|
|
230
|
+
const cancellation = new QueueError('QUEUE_TASK_CANCELLED', `Vobs Queue: 任务 ${id} 已取消`)
|
|
231
|
+
error.value = cancellation
|
|
232
|
+
settleReject(cancellation)
|
|
233
|
+
refreshStats()
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
retry(): Promise<T> {
|
|
237
|
+
ensureActive()
|
|
238
|
+
if (status.value === 'pending' || status.value === 'running' || status.value === 'retrying') return promise
|
|
239
|
+
if (status.value === 'success') return promise
|
|
240
|
+
status.value = 'pending'
|
|
241
|
+
attempt.value = 0
|
|
242
|
+
result.value = null
|
|
243
|
+
error.value = null
|
|
244
|
+
task.controller = null
|
|
245
|
+
task.settled = false
|
|
246
|
+
task.retryQueued = true
|
|
247
|
+
promise = createPromise()
|
|
248
|
+
if (!task.executing) queue.push(task)
|
|
249
|
+
sortQueue()
|
|
250
|
+
refreshStats()
|
|
251
|
+
drain()
|
|
252
|
+
return promise
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
run(): Promise<void> {
|
|
256
|
+
task.retryQueued = false
|
|
257
|
+
task.executing = true
|
|
258
|
+
return execute(task).finally(() => {
|
|
259
|
+
task.executing = false
|
|
260
|
+
if (task.retryQueued && task.status.value === 'pending' && !disposed) {
|
|
261
|
+
task.retryQueued = false
|
|
262
|
+
queue.push(task)
|
|
263
|
+
sortQueue()
|
|
264
|
+
drain()
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
dispose(): void {
|
|
270
|
+
task.controller?.abort()
|
|
271
|
+
status.dispose()
|
|
272
|
+
attempt.dispose()
|
|
273
|
+
result.dispose()
|
|
274
|
+
error.dispose()
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
promise = createPromise()
|
|
278
|
+
return task
|
|
279
|
+
|
|
280
|
+
function createPromise(): Promise<T> {
|
|
281
|
+
return new Promise<T>((resolve, reject) => {
|
|
282
|
+
task.resolve = resolve
|
|
283
|
+
task.reject = reject
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function settleReject(reason: unknown): void {
|
|
288
|
+
if (task.settled) return
|
|
289
|
+
task.settled = true
|
|
290
|
+
task.reject?.(reason)
|
|
291
|
+
task.resolve = null
|
|
292
|
+
task.reject = null
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function execute<T>(task: InternalTask<T>): Promise<void> {
|
|
297
|
+
while (!disposed && (task.status.value === 'pending' || task.status.value === 'retrying')) {
|
|
298
|
+
if (task.status.value === 'retrying') task.status.value = 'pending'
|
|
299
|
+
task.status.value = 'running'
|
|
300
|
+
task.attempt.value++
|
|
301
|
+
const controller = new AbortController()
|
|
302
|
+
task.controller = controller
|
|
303
|
+
refreshStats()
|
|
304
|
+
try {
|
|
305
|
+
const value = await task.fn(controller.signal)
|
|
306
|
+
if (isCancelled(task) || disposed) return
|
|
307
|
+
task.result.value = value
|
|
308
|
+
task.status.value = 'success'
|
|
309
|
+
settleTask(task, value)
|
|
310
|
+
refreshStats()
|
|
311
|
+
return
|
|
312
|
+
} catch (reason) {
|
|
313
|
+
if (isCancelled(task) || disposed) return
|
|
314
|
+
if (isAbortError(reason)) {
|
|
315
|
+
const cancellation = new QueueError('QUEUE_TASK_CANCELLED', `Vobs Queue: 任务 ${task.id} 已取消`, reason)
|
|
316
|
+
task.error.value = cancellation
|
|
317
|
+
task.status.value = 'cancelled'
|
|
318
|
+
settleTask(task, undefined, cancellation)
|
|
319
|
+
refreshStats()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
const failure = toQueueError(reason, task.id)
|
|
323
|
+
let delay: number | undefined
|
|
324
|
+
try {
|
|
325
|
+
if (task.attempt.value <= task.maxRetries) {
|
|
326
|
+
delay = resolveRetryDelay(task.retryDelay, task.attempt.value, failure)
|
|
327
|
+
}
|
|
328
|
+
} catch (delayError) {
|
|
329
|
+
const invalidDelay = toQueueError(delayError, task.id)
|
|
330
|
+
task.error.value = invalidDelay
|
|
331
|
+
task.status.value = 'error'
|
|
332
|
+
settleTask(task, undefined, invalidDelay)
|
|
333
|
+
report(invalidDelay, task)
|
|
334
|
+
refreshStats()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
if (delay !== undefined) {
|
|
338
|
+
task.status.value = 'retrying'
|
|
339
|
+
refreshStats()
|
|
340
|
+
try {
|
|
341
|
+
await wait(delay, controller.signal)
|
|
342
|
+
} catch (delayError) {
|
|
343
|
+
if (isCancelled(task) || isAbortError(delayError)) return
|
|
344
|
+
throw delayError
|
|
345
|
+
}
|
|
346
|
+
if (isCancelled(task) || disposed) return
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
task.error.value = failure
|
|
350
|
+
task.status.value = 'error'
|
|
351
|
+
settleTask(task, undefined, failure)
|
|
352
|
+
report(failure, task)
|
|
353
|
+
refreshStats()
|
|
354
|
+
return
|
|
355
|
+
} finally {
|
|
356
|
+
if (task.controller === controller) task.controller = null
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function drain(): void {
|
|
362
|
+
while (!disposed && !paused.value && active < concurrency && queue.length > 0) {
|
|
363
|
+
const task = queue.shift()!
|
|
364
|
+
if (task.status.value !== 'pending') continue
|
|
365
|
+
active++
|
|
366
|
+
void task.run().finally(() => {
|
|
367
|
+
active--
|
|
368
|
+
refreshStats()
|
|
369
|
+
drain()
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
refreshStats()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sortQueue(): void {
|
|
376
|
+
queue.sort((left, right) => PRIORITIES[right.priority] - PRIORITIES[left.priority] || left.sequence - right.sequence)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function removeFromQueue(task: InternalTask<any>): void {
|
|
380
|
+
const index = queue.indexOf(task)
|
|
381
|
+
if (index >= 0) queue.splice(index, 1)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function refreshStats(): void {
|
|
385
|
+
let nextPending = 0
|
|
386
|
+
let nextProcessing = 0
|
|
387
|
+
let nextCompleted = 0
|
|
388
|
+
let nextFailed = 0
|
|
389
|
+
for (const task of tasks.value) {
|
|
390
|
+
if (task.status.value === 'pending') nextPending++
|
|
391
|
+
else if (task.status.value === 'running' || task.status.value === 'retrying') nextProcessing++
|
|
392
|
+
else if (task.status.value === 'success') nextCompleted++
|
|
393
|
+
else if (task.status.value === 'error') nextFailed++
|
|
394
|
+
}
|
|
395
|
+
pending.value = nextPending
|
|
396
|
+
processing.value = nextProcessing
|
|
397
|
+
completed.value = nextCompleted
|
|
398
|
+
failed.value = nextFailed
|
|
399
|
+
total.value = tasks.value.length
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function settleTask<T>(task: InternalTask<T>, value?: T, reason?: unknown): void {
|
|
403
|
+
if (task.settled) return
|
|
404
|
+
task.settled = true
|
|
405
|
+
if (reason !== undefined) task.reject?.(reason)
|
|
406
|
+
else task.resolve?.(value as T)
|
|
407
|
+
task.resolve = null
|
|
408
|
+
task.reject = null
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function report(queueError: QueueError, task: QueueTask): void {
|
|
412
|
+
try { options.onError?.(queueError, task) } catch { /* observers cannot break queue state */ }
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function ensureActive(): void {
|
|
416
|
+
if (disposed) throw new QueueError('QUEUE_CONTEXT_DISPOSED', 'Vobs Queue: 上下文已销毁')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function isCancelled(task: QueueTask): boolean {
|
|
420
|
+
return task.status.value === 'cancelled'
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function queuePlugin(options: QueuePluginOptions = {}): VobsPlugin {
|
|
425
|
+
return {
|
|
426
|
+
name: '@vobs/queue',
|
|
427
|
+
version: '0.1.0',
|
|
428
|
+
install(context) {
|
|
429
|
+
const ownedQueue = options.queue ? undefined : createTaskQueue(options)
|
|
430
|
+
context.provide(QUEUE_KEY, options.queue ?? ownedQueue!)
|
|
431
|
+
return () => ownedQueue?.dispose()
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function useQueue(): TaskQueue {
|
|
437
|
+
const queue = inject(QUEUE_KEY)
|
|
438
|
+
if (!queue) throw new QueueError('QUEUE_CONTEXT_MISSING', 'Vobs Queue: 找不到上下文,请安装 queuePlugin')
|
|
439
|
+
return queue
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function resolveConcurrency(options: TaskQueueOptions): number {
|
|
443
|
+
if (options.concurrency !== undefined && options.concurrent !== undefined && options.concurrency !== options.concurrent) {
|
|
444
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: concurrency 与 concurrent 不能设置为不同值')
|
|
445
|
+
}
|
|
446
|
+
const value = options.concurrency ?? options.concurrent ?? 3
|
|
447
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
448
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: concurrency 必须是正整数')
|
|
449
|
+
}
|
|
450
|
+
return value
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function validateRetryDelay(value: QueueRetryDelay): void {
|
|
454
|
+
if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) {
|
|
455
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: retryDelay 必须是大于等于 0 的有限数字或函数')
|
|
456
|
+
}
|
|
457
|
+
if (typeof value !== 'number' && typeof value !== 'function') {
|
|
458
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: retryDelay 必须是数字或函数')
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function resolveRetryDelay(value: QueueRetryDelay, attempt: number, error: Error): number {
|
|
463
|
+
const delay = typeof value === 'function' ? value(attempt, error) : value
|
|
464
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
465
|
+
throw new QueueError('INVALID_QUEUE_OPTIONS', 'Vobs Queue: retryDelay 函数必须返回大于等于 0 的有限数字')
|
|
466
|
+
}
|
|
467
|
+
return delay
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function wait(delay: number, signal: AbortSignal): Promise<void> {
|
|
471
|
+
if (delay === 0) return Promise.resolve()
|
|
472
|
+
return new Promise((resolve, reject) => {
|
|
473
|
+
const timer = setTimeout(done, delay)
|
|
474
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
475
|
+
function done(): void {
|
|
476
|
+
signal.removeEventListener('abort', abort)
|
|
477
|
+
resolve()
|
|
478
|
+
}
|
|
479
|
+
function abort(): void {
|
|
480
|
+
clearTimeout(timer)
|
|
481
|
+
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }))
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function toQueueError(reason: unknown, id: string): QueueError {
|
|
487
|
+
if (reason instanceof QueueError) return reason
|
|
488
|
+
return new QueueError('QUEUE_TASK_FAILED', `Vobs Queue: 任务 ${id} 执行失败`, reason)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isAbortError(value: unknown): boolean {
|
|
492
|
+
return Boolean(value) && typeof value === 'object' && (value as { name?: unknown }).name === 'AbortError'
|
|
493
|
+
}
|