@mantiq/queue 0.0.1
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/README.md +19 -0
- package/package.json +72 -0
- package/src/Job.ts +122 -0
- package/src/JobBatch.ts +188 -0
- package/src/JobChain.ts +119 -0
- package/src/JobRegistry.ts +37 -0
- package/src/PendingDispatch.ts +80 -0
- package/src/QueueManager.ts +76 -0
- package/src/QueueServiceProvider.ts +88 -0
- package/src/Worker.ts +282 -0
- package/src/commands/MakeJobCommand.ts +29 -0
- package/src/commands/QueueFailedCommand.ts +50 -0
- package/src/commands/QueueFlushCommand.ts +19 -0
- package/src/commands/QueueRetryCommand.ts +52 -0
- package/src/commands/QueueWorkCommand.ts +49 -0
- package/src/commands/ScheduleRunCommand.ts +58 -0
- package/src/contracts/JobContract.ts +64 -0
- package/src/contracts/QueueDriver.ts +70 -0
- package/src/drivers/KafkaDriver.ts +334 -0
- package/src/drivers/RedisDriver.ts +292 -0
- package/src/drivers/SQLiteDriver.ts +280 -0
- package/src/drivers/SqsDriver.ts +280 -0
- package/src/drivers/SyncDriver.ts +142 -0
- package/src/errors/QueueError.ts +22 -0
- package/src/events/QueueEvents.ts +36 -0
- package/src/helpers/queue.ts +76 -0
- package/src/index.ts +85 -0
- package/src/schedule/Schedule.ts +252 -0
- package/src/testing/QueueFake.ts +209 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { QueueDriver } from './contracts/QueueDriver.ts'
|
|
2
|
+
import { QueueError } from './errors/QueueError.ts'
|
|
3
|
+
import type { EventDispatcher } from '@mantiq/core'
|
|
4
|
+
|
|
5
|
+
export interface QueueConnectionConfig {
|
|
6
|
+
driver: string
|
|
7
|
+
[key: string]: any
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QueueConfig {
|
|
11
|
+
default: string
|
|
12
|
+
connections: Record<string, QueueConnectionConfig>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type DriverFactory = (config: QueueConnectionConfig) => QueueDriver
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Manages queue driver instances.
|
|
19
|
+
* Follows the same Manager pattern as DatabaseManager / CacheManager.
|
|
20
|
+
*/
|
|
21
|
+
export class QueueManager {
|
|
22
|
+
private drivers = new Map<string, QueueDriver>()
|
|
23
|
+
private customDrivers = new Map<string, DriverFactory>()
|
|
24
|
+
|
|
25
|
+
/** Optional event dispatcher — set by QueueServiceProvider.boot() */
|
|
26
|
+
static _dispatcher: EventDispatcher | null = null
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly config: QueueConfig,
|
|
30
|
+
private readonly builtInDrivers: Map<string, DriverFactory>,
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
/** Get a queue driver by connection name (lazy-created and cached) */
|
|
34
|
+
driver(name?: string): QueueDriver {
|
|
35
|
+
const connName = name ?? this.config.default
|
|
36
|
+
if (this.drivers.has(connName)) return this.drivers.get(connName)!
|
|
37
|
+
|
|
38
|
+
const connConfig = this.config.connections[connName]
|
|
39
|
+
if (!connConfig) {
|
|
40
|
+
throw new QueueError(`Queue connection "${connName}" is not configured`, { connection: connName })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const driver = this.createDriver(connConfig)
|
|
44
|
+
this.drivers.set(connName, driver)
|
|
45
|
+
return driver
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Register a custom driver factory */
|
|
49
|
+
extend(name: string, factory: DriverFactory): void {
|
|
50
|
+
this.customDrivers.set(name, factory)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Get the default connection name */
|
|
54
|
+
getDefaultDriver(): string {
|
|
55
|
+
return this.config.default
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get the full config */
|
|
59
|
+
getConfig(): QueueConfig {
|
|
60
|
+
return this.config
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private createDriver(config: QueueConnectionConfig): QueueDriver {
|
|
64
|
+
const driverName = config.driver
|
|
65
|
+
|
|
66
|
+
// Check custom drivers first
|
|
67
|
+
const custom = this.customDrivers.get(driverName)
|
|
68
|
+
if (custom) return custom(config)
|
|
69
|
+
|
|
70
|
+
// Check built-in drivers
|
|
71
|
+
const builtIn = this.builtInDrivers.get(driverName)
|
|
72
|
+
if (builtIn) return builtIn(config)
|
|
73
|
+
|
|
74
|
+
throw new QueueError(`Unknown queue driver "${driverName}"`, { driver: driverName })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ServiceProvider } from '@mantiq/core'
|
|
2
|
+
import { QueueManager } from './QueueManager.ts'
|
|
3
|
+
import type { QueueConfig, QueueConnectionConfig } from './QueueManager.ts'
|
|
4
|
+
import { SyncDriver } from './drivers/SyncDriver.ts'
|
|
5
|
+
import { SQLiteDriver } from './drivers/SQLiteDriver.ts'
|
|
6
|
+
import { RedisDriver } from './drivers/RedisDriver.ts'
|
|
7
|
+
import { SqsDriver } from './drivers/SqsDriver.ts'
|
|
8
|
+
import { KafkaDriver } from './drivers/KafkaDriver.ts'
|
|
9
|
+
import { setQueueManager, QUEUE_MANAGER } from './helpers/queue.ts'
|
|
10
|
+
import { setPendingDispatchResolver } from './PendingDispatch.ts'
|
|
11
|
+
import { setChainResolver } from './JobChain.ts'
|
|
12
|
+
import { setBatchResolver } from './JobBatch.ts'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Registers the QueueManager in the container and wires up helpers.
|
|
16
|
+
*
|
|
17
|
+
* @example — with @mantiq/core:
|
|
18
|
+
* ```ts
|
|
19
|
+
* // In your app's providers array:
|
|
20
|
+
* providers: [QueueServiceProvider]
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class QueueServiceProvider extends ServiceProvider {
|
|
24
|
+
override register(): void {
|
|
25
|
+
this.app.singleton(QueueManager, () => {
|
|
26
|
+
const config: QueueConfig = (this.app as any).config?.().get?.('queue') ?? {
|
|
27
|
+
default: 'sync',
|
|
28
|
+
connections: {
|
|
29
|
+
sync: { driver: 'sync' },
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const builtInDrivers = new Map<string, (cfg: QueueConnectionConfig) => any>([
|
|
34
|
+
['sync', () => new SyncDriver()],
|
|
35
|
+
['sqlite', (cfg) => new SQLiteDriver(cfg.database ?? 'queue.sqlite')],
|
|
36
|
+
['redis', (cfg) => new RedisDriver(cfg as any)],
|
|
37
|
+
['sqs', (cfg) => new SqsDriver(cfg as any)],
|
|
38
|
+
['kafka', (cfg) => new KafkaDriver(cfg as any)],
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
const manager = new QueueManager(config, builtInDrivers)
|
|
42
|
+
|
|
43
|
+
// Wire up helpers
|
|
44
|
+
setQueueManager(manager)
|
|
45
|
+
setPendingDispatchResolver(() => manager)
|
|
46
|
+
setChainResolver(() => manager)
|
|
47
|
+
setBatchResolver(() => manager)
|
|
48
|
+
|
|
49
|
+
return manager
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Also register under the symbol for container lookups
|
|
53
|
+
this.app.singleton(QUEUE_MANAGER as any, () => {
|
|
54
|
+
return this.app.make(QueueManager)
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override boot(): void {
|
|
59
|
+
// Wire up the event dispatcher if available
|
|
60
|
+
try {
|
|
61
|
+
const dispatcher = (this.app as any).make?.('events') ?? null
|
|
62
|
+
if (dispatcher) {
|
|
63
|
+
QueueManager._dispatcher = dispatcher
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
// Events package not installed — fine, events are optional
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Standalone factory for using @mantiq/queue without @mantiq/core.
|
|
73
|
+
*/
|
|
74
|
+
export function createQueueManager(config: QueueConfig): QueueManager {
|
|
75
|
+
const builtInDrivers = new Map<string, (cfg: QueueConnectionConfig) => any>([
|
|
76
|
+
['sync', () => new SyncDriver()],
|
|
77
|
+
['sqlite', (cfg) => new SQLiteDriver(cfg.database ?? 'queue.sqlite')],
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
const manager = new QueueManager(config, builtInDrivers)
|
|
81
|
+
|
|
82
|
+
setQueueManager(manager)
|
|
83
|
+
setPendingDispatchResolver(() => manager)
|
|
84
|
+
setChainResolver(() => manager)
|
|
85
|
+
setBatchResolver(() => manager)
|
|
86
|
+
|
|
87
|
+
return manager
|
|
88
|
+
}
|
package/src/Worker.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { QueueManager } from './QueueManager.ts'
|
|
2
|
+
import type { QueueDriver } from './contracts/QueueDriver.ts'
|
|
3
|
+
import type { QueuedJob, SerializedPayload } from './contracts/JobContract.ts'
|
|
4
|
+
import { Job } from './Job.ts'
|
|
5
|
+
import { resolveJob } from './JobRegistry.ts'
|
|
6
|
+
import { MaxAttemptsExceededError, JobTimeoutError } from './errors/QueueError.ts'
|
|
7
|
+
import {
|
|
8
|
+
JobProcessing,
|
|
9
|
+
JobProcessed,
|
|
10
|
+
JobFailed,
|
|
11
|
+
JobExceptionOccurred,
|
|
12
|
+
} from './events/QueueEvents.ts'
|
|
13
|
+
|
|
14
|
+
export interface WorkerOptions {
|
|
15
|
+
/** Queue names to listen on (comma-separated). Default: 'default' */
|
|
16
|
+
queue?: string | undefined
|
|
17
|
+
/** Seconds to sleep when no jobs are available. Default: 3 */
|
|
18
|
+
sleep?: number | undefined
|
|
19
|
+
/** Default max attempts. Overridden by job's own tries setting. Default: 3 */
|
|
20
|
+
tries?: number | undefined
|
|
21
|
+
/** Default timeout in seconds. Overridden by job's own timeout. Default: 60 */
|
|
22
|
+
timeout?: number | undefined
|
|
23
|
+
/** Stop the worker when the queue is empty. Default: false */
|
|
24
|
+
stopWhenEmpty?: boolean | undefined
|
|
25
|
+
/** Maximum number of jobs to process before stopping. Default: 0 (unlimited) */
|
|
26
|
+
maxJobs?: number | undefined
|
|
27
|
+
/** Maximum time in seconds to run before stopping. Default: 0 (unlimited) */
|
|
28
|
+
maxTime?: number | undefined
|
|
29
|
+
/** Connection name to use. Default: manager's default */
|
|
30
|
+
connection?: string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Queue worker — poll loop that pops jobs, executes them, and handles
|
|
35
|
+
* retries, failures, chain continuation, and batch progress.
|
|
36
|
+
*/
|
|
37
|
+
export class Worker {
|
|
38
|
+
private running = false
|
|
39
|
+
private jobsProcessed = 0
|
|
40
|
+
private startedAt = 0
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
private readonly manager: QueueManager,
|
|
44
|
+
private readonly options: WorkerOptions = {},
|
|
45
|
+
) {}
|
|
46
|
+
|
|
47
|
+
/** Start the poll loop */
|
|
48
|
+
async run(): Promise<void> {
|
|
49
|
+
this.running = true
|
|
50
|
+
this.jobsProcessed = 0
|
|
51
|
+
this.startedAt = Math.floor(Date.now() / 1000)
|
|
52
|
+
|
|
53
|
+
const queues = (this.options.queue ?? 'default').split(',').map((q) => q.trim())
|
|
54
|
+
const sleep = (this.options.sleep ?? 3) * 1000
|
|
55
|
+
const connection = this.options.connection
|
|
56
|
+
const driver = this.manager.driver(connection)
|
|
57
|
+
|
|
58
|
+
while (this.running) {
|
|
59
|
+
let processed = false
|
|
60
|
+
|
|
61
|
+
for (const queue of queues) {
|
|
62
|
+
const job = await driver.pop(queue)
|
|
63
|
+
if (job) {
|
|
64
|
+
await this.processJob(job, driver)
|
|
65
|
+
processed = true
|
|
66
|
+
this.jobsProcessed++
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check stop conditions
|
|
72
|
+
if (!processed && this.options.stopWhenEmpty) {
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (this.options.maxJobs && this.jobsProcessed >= this.options.maxJobs) {
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (this.options.maxTime) {
|
|
81
|
+
const elapsed = Math.floor(Date.now() / 1000) - this.startedAt
|
|
82
|
+
if (elapsed >= this.options.maxTime) break
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!processed) {
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, sleep))
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.running = false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Process a single popped job */
|
|
94
|
+
async processJob(queuedJob: QueuedJob, driver: QueueDriver): Promise<void> {
|
|
95
|
+
const { payload } = queuedJob
|
|
96
|
+
|
|
97
|
+
// Check if this is a batch job and the batch is cancelled
|
|
98
|
+
if (payload.batchId) {
|
|
99
|
+
const batch = await driver.findBatch(payload.batchId)
|
|
100
|
+
if (batch?.cancelledAt) {
|
|
101
|
+
await driver.delete(queuedJob)
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve the job class
|
|
107
|
+
const JobClass = resolveJob(payload.jobName)
|
|
108
|
+
if (!JobClass) {
|
|
109
|
+
const error = new Error(`Job class "${payload.jobName}" not found in registry`)
|
|
110
|
+
await driver.fail(queuedJob, error)
|
|
111
|
+
await this.fireEvent(new JobFailed(payload, error))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reconstruct the job instance
|
|
116
|
+
const job = Object.assign(new (JobClass as any)(), payload.data) as Job
|
|
117
|
+
job.queue = payload.queue
|
|
118
|
+
job.connection = payload.connection
|
|
119
|
+
job.tries = payload.tries
|
|
120
|
+
job.backoff = payload.backoff
|
|
121
|
+
job.timeout = payload.timeout
|
|
122
|
+
job.attempts = queuedJob.attempts
|
|
123
|
+
job.jobId = queuedJob.id
|
|
124
|
+
|
|
125
|
+
const maxTries = payload.tries || this.options.tries || 3
|
|
126
|
+
const timeout = payload.timeout || this.options.timeout || 60
|
|
127
|
+
|
|
128
|
+
await this.fireEvent(new JobProcessing(payload))
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
// Execute with timeout
|
|
132
|
+
await this.runWithTimeout(job, timeout)
|
|
133
|
+
|
|
134
|
+
// Success — delete the job
|
|
135
|
+
await driver.delete(queuedJob)
|
|
136
|
+
await this.fireEvent(new JobProcessed(payload))
|
|
137
|
+
|
|
138
|
+
// Handle chain continuation
|
|
139
|
+
await this.handleChainContinuation(payload, driver)
|
|
140
|
+
|
|
141
|
+
// Handle batch progress
|
|
142
|
+
if (payload.batchId) {
|
|
143
|
+
await this.handleBatchProgress(payload.batchId, driver, true)
|
|
144
|
+
}
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
147
|
+
|
|
148
|
+
await this.fireEvent(new JobExceptionOccurred(payload, err))
|
|
149
|
+
|
|
150
|
+
if (queuedJob.attempts >= maxTries) {
|
|
151
|
+
// Permanently failed — preserve the original error (e.g. JobTimeoutError)
|
|
152
|
+
const failError = err instanceof JobTimeoutError
|
|
153
|
+
? err
|
|
154
|
+
: new MaxAttemptsExceededError(payload.jobName, maxTries)
|
|
155
|
+
await driver.fail(queuedJob, failError)
|
|
156
|
+
await this.fireEvent(new JobFailed(payload, failError))
|
|
157
|
+
|
|
158
|
+
// Call job's failed() hook
|
|
159
|
+
if (job.failed) {
|
|
160
|
+
try { await job.failed(failError) } catch { /* ignore */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle chain failure
|
|
164
|
+
await this.handleChainFailure(payload, driver)
|
|
165
|
+
|
|
166
|
+
// Handle batch progress
|
|
167
|
+
if (payload.batchId) {
|
|
168
|
+
await this.handleBatchProgress(payload.batchId, driver, false)
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// Retry
|
|
172
|
+
const delay = job.getBackoffDelay(queuedJob.attempts)
|
|
173
|
+
await driver.release(queuedJob, delay)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Stop the worker gracefully */
|
|
179
|
+
stop(): void {
|
|
180
|
+
this.running = false
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Whether the worker is currently running */
|
|
184
|
+
isRunning(): boolean {
|
|
185
|
+
return this.running
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Number of jobs processed so far */
|
|
189
|
+
getJobsProcessed(): number {
|
|
190
|
+
return this.jobsProcessed
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Private helpers ──────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
private async runWithTimeout(job: Job, timeoutSeconds: number): Promise<void> {
|
|
196
|
+
const timeoutMs = timeoutSeconds * 1000
|
|
197
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
198
|
+
|
|
199
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
200
|
+
timeoutId = setTimeout(() => {
|
|
201
|
+
reject(new JobTimeoutError(job.constructor.name, timeoutSeconds))
|
|
202
|
+
}, timeoutMs)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await Promise.race([job.handle(), timeoutPromise])
|
|
207
|
+
} finally {
|
|
208
|
+
if (timeoutId !== null) clearTimeout(timeoutId)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** After a successful job, dispatch the next job in the chain */
|
|
213
|
+
private async handleChainContinuation(payload: SerializedPayload, driver: QueueDriver): Promise<void> {
|
|
214
|
+
if (!payload.chainedJobs || payload.chainedJobs.length === 0) return
|
|
215
|
+
|
|
216
|
+
const [next, ...remaining] = payload.chainedJobs
|
|
217
|
+
if (!next) return
|
|
218
|
+
|
|
219
|
+
// Pass remaining chain and catch job to the next payload
|
|
220
|
+
if (remaining.length > 0) {
|
|
221
|
+
next.chainedJobs = remaining
|
|
222
|
+
}
|
|
223
|
+
if (payload.chainCatchJob) {
|
|
224
|
+
next.chainCatchJob = payload.chainCatchJob
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
await driver.push(next, next.queue, next.delay)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** When a chained job permanently fails, dispatch the catch handler */
|
|
231
|
+
private async handleChainFailure(payload: SerializedPayload, driver: QueueDriver): Promise<void> {
|
|
232
|
+
if (!payload.chainCatchJob) return
|
|
233
|
+
const catchPayload = payload.chainCatchJob
|
|
234
|
+
await driver.push(catchPayload, catchPayload.queue, catchPayload.delay)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Update batch progress and trigger lifecycle callbacks when complete */
|
|
238
|
+
private async handleBatchProgress(batchId: string, driver: QueueDriver, success: boolean): Promise<void> {
|
|
239
|
+
const updated = await driver.updateBatchProgress(
|
|
240
|
+
batchId,
|
|
241
|
+
success ? 1 : 0,
|
|
242
|
+
success ? 0 : 1,
|
|
243
|
+
)
|
|
244
|
+
if (!updated) return
|
|
245
|
+
|
|
246
|
+
const { totalJobs, processedJobs, failedJobs, options, finishedAt } = updated
|
|
247
|
+
|
|
248
|
+
// Check if batch is complete
|
|
249
|
+
if (processedJobs + failedJobs < totalJobs) return
|
|
250
|
+
if (finishedAt !== null) return // Already handled
|
|
251
|
+
|
|
252
|
+
await driver.markBatchFinished(batchId)
|
|
253
|
+
|
|
254
|
+
const hasFailures = failedJobs > 0
|
|
255
|
+
const allowFailures = options.allowFailures
|
|
256
|
+
|
|
257
|
+
// Dispatch then/catch/finally callbacks
|
|
258
|
+
if (!hasFailures || allowFailures) {
|
|
259
|
+
if (options.thenJob) {
|
|
260
|
+
await driver.push(options.thenJob, options.thenJob.queue, 0)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (hasFailures && !allowFailures) {
|
|
265
|
+
if (options.catchJob) {
|
|
266
|
+
await driver.push(options.catchJob, options.catchJob.queue, 0)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Finally always runs
|
|
271
|
+
if (options.finallyJob) {
|
|
272
|
+
await driver.push(options.finallyJob, options.finallyJob.queue, 0)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private async fireEvent(event: { timestamp: Date }): Promise<void> {
|
|
277
|
+
const dispatcher = QueueManager._dispatcher
|
|
278
|
+
if (dispatcher) {
|
|
279
|
+
await dispatcher.emit(event as any)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { GeneratorCommand } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
|
|
4
|
+
export class MakeJobCommand extends GeneratorCommand {
|
|
5
|
+
override name = 'make:job'
|
|
6
|
+
override description = 'Create a new job class'
|
|
7
|
+
override usage = 'make:job <name>'
|
|
8
|
+
|
|
9
|
+
override directory() { return 'app/Jobs' }
|
|
10
|
+
override suffix() { return '' }
|
|
11
|
+
|
|
12
|
+
override stub(name: string, _args: ParsedArgs): string {
|
|
13
|
+
return `import { Job } from '@mantiq/queue'
|
|
14
|
+
|
|
15
|
+
export class ${name} extends Job {
|
|
16
|
+
override queue = 'default'
|
|
17
|
+
override tries = 3
|
|
18
|
+
|
|
19
|
+
constructor() {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
override async handle(): Promise<void> {
|
|
24
|
+
//
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Command } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
import type { QueueManager } from '../QueueManager.ts'
|
|
4
|
+
|
|
5
|
+
export class QueueFailedCommand extends Command {
|
|
6
|
+
override name = 'queue:failed'
|
|
7
|
+
override description = 'List all failed jobs'
|
|
8
|
+
|
|
9
|
+
constructor(private readonly manager: QueueManager) {
|
|
10
|
+
super()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override async handle(_args: ParsedArgs): Promise<number> {
|
|
14
|
+
const driver = this.manager.driver()
|
|
15
|
+
const failed = await driver.getFailedJobs()
|
|
16
|
+
|
|
17
|
+
if (failed.length === 0) {
|
|
18
|
+
this.io.info('No failed jobs.')
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Table header
|
|
23
|
+
const header = ['ID', 'Queue', 'Job', 'Failed At', 'Error']
|
|
24
|
+
const rows = failed.map((j) => [
|
|
25
|
+
String(j.id),
|
|
26
|
+
j.queue,
|
|
27
|
+
j.payload.jobName,
|
|
28
|
+
new Date(j.failedAt * 1000).toISOString(),
|
|
29
|
+
j.exception.split('\n')[0] ?? '',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
// Simple table output
|
|
33
|
+
const colWidths = header.map((h, i) => {
|
|
34
|
+
return Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const separator = colWidths.map((w) => '-'.repeat(w + 2)).join('+')
|
|
38
|
+
const formatRow = (row: string[]) =>
|
|
39
|
+
row.map((cell, i) => ` ${cell.padEnd(colWidths[i]!)} `).join('|')
|
|
40
|
+
|
|
41
|
+
console.log(formatRow(header))
|
|
42
|
+
console.log(separator)
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
console.log(formatRow(row))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log(`\n${failed.length} failed job(s) total.`)
|
|
48
|
+
return 0
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
import type { QueueManager } from '../QueueManager.ts'
|
|
4
|
+
|
|
5
|
+
export class QueueFlushCommand extends Command {
|
|
6
|
+
override name = 'queue:flush'
|
|
7
|
+
override description = 'Delete all failed jobs'
|
|
8
|
+
|
|
9
|
+
constructor(private readonly manager: QueueManager) {
|
|
10
|
+
super()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
override async handle(_args: ParsedArgs): Promise<number> {
|
|
14
|
+
const driver = this.manager.driver()
|
|
15
|
+
await driver.flushFailedJobs()
|
|
16
|
+
this.io.success('All failed jobs deleted.')
|
|
17
|
+
return 0
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Command } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
import type { QueueManager } from '../QueueManager.ts'
|
|
4
|
+
|
|
5
|
+
export class QueueRetryCommand extends Command {
|
|
6
|
+
override name = 'queue:retry'
|
|
7
|
+
override description = 'Retry failed job(s)'
|
|
8
|
+
override usage = 'queue:retry <id|all>'
|
|
9
|
+
|
|
10
|
+
constructor(private readonly manager: QueueManager) {
|
|
11
|
+
super()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override async handle(args: ParsedArgs): Promise<number> {
|
|
15
|
+
const target = args.args[0]
|
|
16
|
+
if (!target) {
|
|
17
|
+
this.io.error('Please provide a failed job ID or "all".')
|
|
18
|
+
return 1
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const driver = this.manager.driver()
|
|
22
|
+
|
|
23
|
+
if (target === 'all') {
|
|
24
|
+
const failed = await driver.getFailedJobs()
|
|
25
|
+
if (failed.length === 0) {
|
|
26
|
+
this.io.info('No failed jobs to retry.')
|
|
27
|
+
return 0
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const job of failed) {
|
|
31
|
+
await driver.push(job.payload, job.queue)
|
|
32
|
+
await driver.forgetFailedJob(job.id)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
this.io.success(`Retried ${failed.length} failed job(s).`)
|
|
36
|
+
return 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = isNaN(Number(target)) ? target : Number(target)
|
|
40
|
+
const job = await driver.findFailedJob(id)
|
|
41
|
+
if (!job) {
|
|
42
|
+
this.io.error(`Failed job [${target}] not found.`)
|
|
43
|
+
return 1
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await driver.push(job.payload, job.queue)
|
|
47
|
+
await driver.forgetFailedJob(job.id)
|
|
48
|
+
|
|
49
|
+
this.io.success(`Retried failed job [${target}].`)
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Command } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
import { Worker } from '../Worker.ts'
|
|
4
|
+
import type { QueueManager } from '../QueueManager.ts'
|
|
5
|
+
|
|
6
|
+
export class QueueWorkCommand extends Command {
|
|
7
|
+
override name = 'queue:work'
|
|
8
|
+
override description = 'Start processing jobs on the queue'
|
|
9
|
+
override usage = 'queue:work [--queue=default] [--sleep=3] [--tries=3] [--timeout=60] [--stop-when-empty] [--max-jobs=0] [--max-time=0] [--connection=]'
|
|
10
|
+
|
|
11
|
+
constructor(private readonly manager: QueueManager) {
|
|
12
|
+
super()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
override async handle(args: ParsedArgs): Promise<number> {
|
|
16
|
+
const options = {
|
|
17
|
+
queue: String(args.flags['queue'] ?? 'default'),
|
|
18
|
+
sleep: Number(args.flags['sleep'] ?? 3),
|
|
19
|
+
tries: Number(args.flags['tries'] ?? 3),
|
|
20
|
+
timeout: Number(args.flags['timeout'] ?? 60),
|
|
21
|
+
stopWhenEmpty: Boolean(args.flags['stop-when-empty']),
|
|
22
|
+
maxJobs: Number(args.flags['max-jobs'] ?? 0) || undefined,
|
|
23
|
+
maxTime: Number(args.flags['max-time'] ?? 0) || undefined,
|
|
24
|
+
connection: args.flags['connection'] ? String(args.flags['connection']) : undefined,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
this.io.info(`Processing jobs on queue [${options.queue}]...`)
|
|
28
|
+
|
|
29
|
+
const worker = new Worker(this.manager, options)
|
|
30
|
+
|
|
31
|
+
// Graceful shutdown on SIGINT/SIGTERM
|
|
32
|
+
const shutdown = () => {
|
|
33
|
+
this.io.info('Shutting down worker...')
|
|
34
|
+
worker.stop()
|
|
35
|
+
}
|
|
36
|
+
process.on('SIGINT', shutdown)
|
|
37
|
+
process.on('SIGTERM', shutdown)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await worker.run()
|
|
41
|
+
} finally {
|
|
42
|
+
process.off('SIGINT', shutdown)
|
|
43
|
+
process.off('SIGTERM', shutdown)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
this.io.success(`Worker stopped. Processed ${worker.getJobsProcessed()} job(s).`)
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
}
|