@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 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
+ }