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