@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
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# @mantiq/queue
|
|
2
|
+
|
|
3
|
+
Job dispatching, chains, batches, and scheduling for MantiqJS — sync, SQLite, Redis, SQS, and Kafka drivers.
|
|
4
|
+
|
|
5
|
+
Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @mantiq/queue
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Documentation
|
|
14
|
+
|
|
15
|
+
See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
|
|
16
|
+
|
|
17
|
+
## License
|
|
18
|
+
|
|
19
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mantiq/queue",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Job dispatching, workers, retry logic",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Abdullah Khan",
|
|
8
|
+
"homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/queue",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/abdullahkhan/mantiq.git",
|
|
12
|
+
"directory": "packages/queue"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/abdullahkhan/mantiq/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"mantiq",
|
|
19
|
+
"mantiqjs",
|
|
20
|
+
"bun",
|
|
21
|
+
"typescript",
|
|
22
|
+
"framework",
|
|
23
|
+
"queue"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.1.0"
|
|
27
|
+
},
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"bun": "./src/index.ts",
|
|
33
|
+
"default": "./src/index.ts"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"src/",
|
|
38
|
+
"package.json",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
44
|
+
"test": "bun test",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"clean": "rm -rf dist"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@mantiq/core": "workspace:*",
|
|
50
|
+
"@mantiq/cli": "workspace:*"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"bun-types": "latest",
|
|
54
|
+
"typescript": "^5.7.0"
|
|
55
|
+
},
|
|
56
|
+
"peerDependencies": {
|
|
57
|
+
"ioredis": ">=5.0.0",
|
|
58
|
+
"@aws-sdk/client-sqs": ">=3.0.0",
|
|
59
|
+
"kafkajs": ">=2.0.0"
|
|
60
|
+
},
|
|
61
|
+
"peerDependenciesMeta": {
|
|
62
|
+
"ioredis": {
|
|
63
|
+
"optional": true
|
|
64
|
+
},
|
|
65
|
+
"@aws-sdk/client-sqs": {
|
|
66
|
+
"optional": true
|
|
67
|
+
},
|
|
68
|
+
"kafkajs": {
|
|
69
|
+
"optional": true
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/Job.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { SerializedPayload } from './contracts/JobContract.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keys that belong to the Job base class config, NOT user data.
|
|
5
|
+
* Used by serialize() to separate job config from user-defined properties.
|
|
6
|
+
*/
|
|
7
|
+
const JOB_BASE_KEYS = new Set([
|
|
8
|
+
'queue', 'connection', 'tries', 'backoff', 'timeout',
|
|
9
|
+
'delay', 'attempts', 'jobId',
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Abstract base class for all queueable jobs.
|
|
14
|
+
*
|
|
15
|
+
* Subclasses define their own properties (the "data") and implement handle().
|
|
16
|
+
* The framework serializes user properties automatically for queue storage.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```ts
|
|
20
|
+
* export class ProcessPayment extends Job {
|
|
21
|
+
* override queue = 'payments'
|
|
22
|
+
* override tries = 5
|
|
23
|
+
* override backoff = 'exponential:30'
|
|
24
|
+
*
|
|
25
|
+
* constructor(public orderId: number, public amount: number) { super() }
|
|
26
|
+
*
|
|
27
|
+
* async handle(): Promise<void> {
|
|
28
|
+
* // process the payment...
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* override async failed(error: Error): Promise<void> {
|
|
32
|
+
* // notify admin of failure
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export abstract class Job {
|
|
38
|
+
/** Queue name this job should be dispatched to */
|
|
39
|
+
queue = 'default'
|
|
40
|
+
|
|
41
|
+
/** Connection name (null = default connection) */
|
|
42
|
+
connection: string | null = null
|
|
43
|
+
|
|
44
|
+
/** Maximum number of attempts before permanent failure */
|
|
45
|
+
tries = 3
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Backoff strategy between retries.
|
|
49
|
+
* - `'0'` — no delay
|
|
50
|
+
* - `'30'` — fixed 30s delay
|
|
51
|
+
* - `'30,60,120'` — custom delays per attempt
|
|
52
|
+
* - `'exponential:30'` — exponential backoff starting at 30s
|
|
53
|
+
*/
|
|
54
|
+
backoff = '0'
|
|
55
|
+
|
|
56
|
+
/** Maximum execution time in seconds */
|
|
57
|
+
timeout = 60
|
|
58
|
+
|
|
59
|
+
/** Delay in seconds before the job becomes available (set by PendingDispatch) */
|
|
60
|
+
delay = 0
|
|
61
|
+
|
|
62
|
+
/** Current attempt number (set by Worker, starts at 0) */
|
|
63
|
+
attempts = 0
|
|
64
|
+
|
|
65
|
+
/** Queue driver's job ID (set after push) */
|
|
66
|
+
jobId: string | number | null = null
|
|
67
|
+
|
|
68
|
+
/** Execute the job logic */
|
|
69
|
+
abstract handle(): Promise<void>
|
|
70
|
+
|
|
71
|
+
/** Called when the job has permanently failed (optional) */
|
|
72
|
+
failed?(error: Error): Promise<void>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Serialize this job for queue storage.
|
|
76
|
+
* User-defined properties (anything not in JOB_BASE_KEYS) become the `data` object.
|
|
77
|
+
*/
|
|
78
|
+
serialize(): SerializedPayload {
|
|
79
|
+
const data: Record<string, any> = {}
|
|
80
|
+
for (const key of Object.keys(this)) {
|
|
81
|
+
if (!JOB_BASE_KEYS.has(key)) {
|
|
82
|
+
data[key] = (this as any)[key]
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
jobName: this.constructor.name,
|
|
88
|
+
data,
|
|
89
|
+
queue: this.queue,
|
|
90
|
+
connection: this.connection,
|
|
91
|
+
tries: this.tries,
|
|
92
|
+
backoff: this.backoff,
|
|
93
|
+
timeout: this.timeout,
|
|
94
|
+
delay: this.delay,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calculate the backoff delay for a given attempt number.
|
|
100
|
+
* @returns delay in seconds
|
|
101
|
+
*/
|
|
102
|
+
getBackoffDelay(attempt: number): number {
|
|
103
|
+
const raw = this.backoff.trim()
|
|
104
|
+
|
|
105
|
+
if (raw === '0' || raw === '') return 0
|
|
106
|
+
|
|
107
|
+
// Exponential: 'exponential:30' → 30, 60, 120, 240, ...
|
|
108
|
+
if (raw.startsWith('exponential:')) {
|
|
109
|
+
const base = parseInt(raw.slice('exponential:'.length), 10) || 1
|
|
110
|
+
return base * Math.pow(2, attempt - 1)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Comma-separated: '30,60,120'
|
|
114
|
+
if (raw.includes(',')) {
|
|
115
|
+
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10))
|
|
116
|
+
return parts[Math.min(attempt - 1, parts.length - 1)] ?? 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fixed: '30'
|
|
120
|
+
return parseInt(raw, 10) || 0
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/JobBatch.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Job } from './Job.ts'
|
|
2
|
+
import type { QueueManager } from './QueueManager.ts'
|
|
3
|
+
import type { BatchRecord, BatchOptions, SerializedPayload } from './contracts/JobContract.ts'
|
|
4
|
+
|
|
5
|
+
/** Resolve the QueueManager lazily */
|
|
6
|
+
let _resolveManager: (() => QueueManager) | null = null
|
|
7
|
+
|
|
8
|
+
export function setBatchResolver(resolver: () => QueueManager): void {
|
|
9
|
+
_resolveManager = resolver
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Represents a dispatched batch — used to check progress and status.
|
|
14
|
+
*/
|
|
15
|
+
export class Batch {
|
|
16
|
+
constructor(private record: BatchRecord, private manager: QueueManager) {}
|
|
17
|
+
|
|
18
|
+
get id(): string { return this.record.id }
|
|
19
|
+
get name(): string { return this.record.name }
|
|
20
|
+
get totalJobs(): number { return this.record.totalJobs }
|
|
21
|
+
get processedJobs(): number { return this.record.processedJobs }
|
|
22
|
+
get failedJobs(): number { return this.record.failedJobs }
|
|
23
|
+
get cancelled(): boolean { return this.record.cancelledAt !== null }
|
|
24
|
+
get createdAt(): number { return this.record.createdAt }
|
|
25
|
+
get finishedAt(): number | null { return this.record.finishedAt }
|
|
26
|
+
|
|
27
|
+
/** Progress as a percentage (0–100) */
|
|
28
|
+
progress(): number {
|
|
29
|
+
if (this.record.totalJobs === 0) return 100
|
|
30
|
+
return Math.round(
|
|
31
|
+
((this.record.processedJobs + this.record.failedJobs) / this.record.totalJobs) * 100,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether all jobs have been processed (success or failure) */
|
|
36
|
+
finished(): boolean {
|
|
37
|
+
return this.record.finishedAt !== null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Whether any jobs in the batch have failed */
|
|
41
|
+
hasFailures(): boolean {
|
|
42
|
+
return this.record.failedJobs > 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Cancel this batch — pending jobs with this batchId will be skipped by the Worker */
|
|
46
|
+
async cancel(): Promise<void> {
|
|
47
|
+
const driver = this.manager.driver(this.record.options.connection ?? undefined)
|
|
48
|
+
await driver.cancelBatch(this.record.id)
|
|
49
|
+
this.record.cancelledAt = Math.floor(Date.now() / 1000)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Refresh the batch status from the driver */
|
|
53
|
+
async fresh(): Promise<Batch> {
|
|
54
|
+
const driver = this.manager.driver(this.record.options.connection ?? undefined)
|
|
55
|
+
const updated = await driver.findBatch(this.record.id)
|
|
56
|
+
if (updated) this.record = updated
|
|
57
|
+
return this
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Fluent builder for dispatching a batch of jobs in parallel.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```ts
|
|
66
|
+
* const batch = await Bus.batch([
|
|
67
|
+
* new ImportCsvChunk(file, 0, 1000),
|
|
68
|
+
* new ImportCsvChunk(file, 1000, 2000),
|
|
69
|
+
* new ImportCsvChunk(file, 2000, 3000),
|
|
70
|
+
* ])
|
|
71
|
+
* .then(new NotifyImportComplete(file))
|
|
72
|
+
* .catch(new NotifyImportFailed(file))
|
|
73
|
+
* .finally(new CleanupTempFiles(file))
|
|
74
|
+
* .name('csv-import')
|
|
75
|
+
* .onQueue('imports')
|
|
76
|
+
* .dispatch()
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
export class PendingBatch {
|
|
80
|
+
private jobs: Job[]
|
|
81
|
+
private thenJob: Job | null = null
|
|
82
|
+
private catchJob: Job | null = null
|
|
83
|
+
private finallyJob: Job | null = null
|
|
84
|
+
private _name = ''
|
|
85
|
+
private _queue = 'default'
|
|
86
|
+
private _connection: string | null = null
|
|
87
|
+
private _allowFailures = false
|
|
88
|
+
|
|
89
|
+
private constructor(jobs: Job[]) {
|
|
90
|
+
this.jobs = jobs
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
static of(jobs: Job[]): PendingBatch {
|
|
94
|
+
if (jobs.length === 0) {
|
|
95
|
+
throw new Error('PendingBatch.of() requires at least one job')
|
|
96
|
+
}
|
|
97
|
+
return new PendingBatch(jobs)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Job to dispatch when all jobs succeed (or if allowFailures is true) */
|
|
101
|
+
then(job: Job): this {
|
|
102
|
+
this.thenJob = job
|
|
103
|
+
return this
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Job to dispatch when any job fails (and allowFailures is false) */
|
|
107
|
+
catch(job: Job): this {
|
|
108
|
+
this.catchJob = job
|
|
109
|
+
return this
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Job to dispatch when the batch completes, regardless of success/failure */
|
|
113
|
+
finally(job: Job): this {
|
|
114
|
+
this.finallyJob = job
|
|
115
|
+
return this
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Set a human-readable name for this batch */
|
|
119
|
+
name(name: string): this {
|
|
120
|
+
this._name = name
|
|
121
|
+
return this
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Override the queue for all batch jobs */
|
|
125
|
+
onQueue(queue: string): this {
|
|
126
|
+
this._queue = queue
|
|
127
|
+
return this
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Override the connection for all batch jobs */
|
|
131
|
+
onConnection(connection: string): this {
|
|
132
|
+
this._connection = connection
|
|
133
|
+
return this
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Allow the batch to succeed even if some jobs fail */
|
|
137
|
+
allowFailures(): this {
|
|
138
|
+
this._allowFailures = true
|
|
139
|
+
return this
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Dispatch all batch jobs and create the batch record */
|
|
143
|
+
async dispatch(): Promise<Batch> {
|
|
144
|
+
if (!_resolveManager) {
|
|
145
|
+
throw new Error('QueueManager not initialized. Call setBatchResolver() first.')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const manager = _resolveManager()
|
|
149
|
+
const driver = manager.driver(this._connection ?? undefined)
|
|
150
|
+
|
|
151
|
+
const batchId = crypto.randomUUID()
|
|
152
|
+
|
|
153
|
+
const options: BatchOptions = {
|
|
154
|
+
thenJob: this.thenJob?.serialize(),
|
|
155
|
+
catchJob: this.catchJob?.serialize(),
|
|
156
|
+
finallyJob: this.finallyJob?.serialize(),
|
|
157
|
+
allowFailures: this._allowFailures,
|
|
158
|
+
queue: this._queue,
|
|
159
|
+
connection: this._connection,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const record: BatchRecord = {
|
|
163
|
+
id: batchId,
|
|
164
|
+
name: this._name,
|
|
165
|
+
totalJobs: this.jobs.length,
|
|
166
|
+
processedJobs: 0,
|
|
167
|
+
failedJobs: 0,
|
|
168
|
+
failedJobIds: [],
|
|
169
|
+
options,
|
|
170
|
+
cancelledAt: null,
|
|
171
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
172
|
+
finishedAt: null,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await driver.createBatch(record)
|
|
176
|
+
|
|
177
|
+
// Push all jobs with the batchId attached
|
|
178
|
+
for (const job of this.jobs) {
|
|
179
|
+
const payload = job.serialize()
|
|
180
|
+
payload.batchId = batchId
|
|
181
|
+
payload.queue = this._queue
|
|
182
|
+
if (this._connection) payload.connection = this._connection
|
|
183
|
+
await driver.push(payload, this._queue, job.delay)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return new Batch(record, manager)
|
|
187
|
+
}
|
|
188
|
+
}
|
package/src/JobChain.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { Job } from './Job.ts'
|
|
2
|
+
import type { QueueManager } from './QueueManager.ts'
|
|
3
|
+
import type { SerializedPayload } from './contracts/JobContract.ts'
|
|
4
|
+
|
|
5
|
+
/** Resolve the QueueManager lazily */
|
|
6
|
+
let _resolveManager: (() => QueueManager) | null = null
|
|
7
|
+
|
|
8
|
+
export function setChainResolver(resolver: () => QueueManager): void {
|
|
9
|
+
_resolveManager = resolver
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sequential job execution — each job runs only after the previous one succeeds.
|
|
14
|
+
* If any job fails permanently, the chain stops and the optional catch handler runs.
|
|
15
|
+
*
|
|
16
|
+
* Chain state is stored in the serialized payload: `chainedJobs` and `chainCatchJob`.
|
|
17
|
+
* The Worker checks these after successful completion and dispatches the next job.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```ts
|
|
21
|
+
* await Chain.of([
|
|
22
|
+
* new ProcessPodcast(podcast),
|
|
23
|
+
* new OptimizeAudio(podcast),
|
|
24
|
+
* new PublishPodcast(podcast),
|
|
25
|
+
* ]).catch(new NotifyFailure(podcast)).dispatch()
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export class Chain {
|
|
29
|
+
private jobs: Job[]
|
|
30
|
+
private catchJob: Job | null = null
|
|
31
|
+
private _queue: string | null = null
|
|
32
|
+
private _connection: string | null = null
|
|
33
|
+
|
|
34
|
+
private constructor(jobs: Job[]) {
|
|
35
|
+
this.jobs = jobs
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Create a chain from an ordered list of jobs */
|
|
39
|
+
static of(jobs: Job[]): Chain {
|
|
40
|
+
if (jobs.length === 0) {
|
|
41
|
+
throw new Error('Chain.of() requires at least one job')
|
|
42
|
+
}
|
|
43
|
+
return new Chain(jobs)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Set a job to run if any job in the chain fails permanently */
|
|
47
|
+
catch(job: Job): this {
|
|
48
|
+
this.catchJob = job
|
|
49
|
+
return this
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Override the queue name for all jobs in the chain */
|
|
53
|
+
onQueue(queue: string): this {
|
|
54
|
+
this._queue = queue
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Override the connection for all jobs in the chain */
|
|
59
|
+
onConnection(connection: string): this {
|
|
60
|
+
this._connection = connection
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Dispatch the chain.
|
|
66
|
+
* The first job is pushed to the queue with the remaining jobs serialized
|
|
67
|
+
* in its payload as `chainedJobs`. The Worker handles continuation.
|
|
68
|
+
*/
|
|
69
|
+
async dispatch(): Promise<void> {
|
|
70
|
+
if (!_resolveManager) {
|
|
71
|
+
throw new Error('QueueManager not initialized. Call setChainResolver() first.')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const manager = _resolveManager()
|
|
75
|
+
const [first, ...rest] = this.jobs
|
|
76
|
+
|
|
77
|
+
// Serialize the first job
|
|
78
|
+
const payload = first!.serialize()
|
|
79
|
+
|
|
80
|
+
// Attach remaining jobs as chained payloads
|
|
81
|
+
if (rest.length > 0) {
|
|
82
|
+
payload.chainedJobs = rest.map((j) => {
|
|
83
|
+
const p = j.serialize()
|
|
84
|
+
if (this._queue) p.queue = this._queue
|
|
85
|
+
if (this._connection) p.connection = this._connection
|
|
86
|
+
return p
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Attach catch handler
|
|
91
|
+
if (this.catchJob) {
|
|
92
|
+
const catchPayload = this.catchJob.serialize()
|
|
93
|
+
if (this._queue) catchPayload.queue = this._queue
|
|
94
|
+
if (this._connection) catchPayload.connection = this._connection
|
|
95
|
+
payload.chainCatchJob = catchPayload
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Apply queue/connection overrides to first job
|
|
99
|
+
if (this._queue) payload.queue = this._queue
|
|
100
|
+
if (this._connection) payload.connection = this._connection
|
|
101
|
+
|
|
102
|
+
const queue = payload.queue
|
|
103
|
+
const connection = payload.connection
|
|
104
|
+
const driver = manager.driver(connection ?? undefined)
|
|
105
|
+
|
|
106
|
+
await driver.push(payload, queue, first!.delay)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Makes Chain thenable so `await Chain.of([...]).dispatch()` and
|
|
111
|
+
* `await Chain.of([...])` both work.
|
|
112
|
+
*/
|
|
113
|
+
then<TResult1 = void, TResult2 = never>(
|
|
114
|
+
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
|
|
115
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
116
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
117
|
+
return this.dispatch().then(onfulfilled, onrejected)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Job } from './Job.ts'
|
|
2
|
+
import type { Constructor } from './contracts/JobContract.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maps job class names to their constructors.
|
|
6
|
+
* Used by the Worker to reconstruct job instances from serialized payloads.
|
|
7
|
+
*
|
|
8
|
+
* Jobs must be registered before the worker starts processing.
|
|
9
|
+
*/
|
|
10
|
+
const registry = new Map<string, Constructor<Job>>()
|
|
11
|
+
|
|
12
|
+
/** Register a job class so it can be deserialized by the worker */
|
|
13
|
+
export function registerJob(jobClass: Constructor<Job>): void {
|
|
14
|
+
registry.set(jobClass.name, jobClass)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Register multiple job classes at once */
|
|
18
|
+
export function registerJobs(jobClasses: Constructor<Job>[]): void {
|
|
19
|
+
for (const cls of jobClasses) {
|
|
20
|
+
registry.set(cls.name, cls)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Look up a job constructor by its class name */
|
|
25
|
+
export function resolveJob(name: string): Constructor<Job> | undefined {
|
|
26
|
+
return registry.get(name)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Get all registered job classes (for debugging/testing) */
|
|
30
|
+
export function getRegisteredJobs(): Map<string, Constructor<Job>> {
|
|
31
|
+
return new Map(registry)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Clear the registry (primarily for testing) */
|
|
35
|
+
export function clearJobRegistry(): void {
|
|
36
|
+
registry.clear()
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Job } from './Job.ts'
|
|
2
|
+
import type { QueueManager } from './QueueManager.ts'
|
|
3
|
+
|
|
4
|
+
/** Resolve the QueueManager lazily to avoid circular deps */
|
|
5
|
+
let _resolveManager: (() => QueueManager) | null = null
|
|
6
|
+
|
|
7
|
+
export function setPendingDispatchResolver(resolver: () => QueueManager): void {
|
|
8
|
+
_resolveManager = resolver
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fluent dispatch builder that is **thenable**.
|
|
13
|
+
*
|
|
14
|
+
* This allows `await dispatch(job).delay(60).onQueue('payments')` to work
|
|
15
|
+
* because PendingDispatch implements `then()` which triggers the actual push.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* // All of these work:
|
|
20
|
+
* await dispatch(new ProcessPayment(order))
|
|
21
|
+
* await dispatch(new ProcessPayment(order)).delay(60)
|
|
22
|
+
* await dispatch(new ProcessPayment(order)).onQueue('payments').delay(30)
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class PendingDispatch {
|
|
26
|
+
private _delay = 0
|
|
27
|
+
private _queue: string | null = null
|
|
28
|
+
private _connection: string | null = null
|
|
29
|
+
|
|
30
|
+
constructor(private readonly job: Job) {}
|
|
31
|
+
|
|
32
|
+
/** Set the delay in seconds before the job becomes available */
|
|
33
|
+
delay(seconds: number): this {
|
|
34
|
+
this._delay = seconds
|
|
35
|
+
return this
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Override the queue name for this dispatch */
|
|
39
|
+
onQueue(queue: string): this {
|
|
40
|
+
this._queue = queue
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Override the connection name for this dispatch */
|
|
45
|
+
onConnection(connection: string): this {
|
|
46
|
+
this._connection = connection
|
|
47
|
+
return this
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Push the job to the queue. Called automatically by then(). */
|
|
51
|
+
async send(): Promise<void> {
|
|
52
|
+
if (!_resolveManager) {
|
|
53
|
+
throw new Error('QueueManager not initialized. Call setPendingDispatchResolver() first.')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const manager = _resolveManager()
|
|
57
|
+
const connection = this._connection ?? this.job.connection
|
|
58
|
+
const driver = manager.driver(connection ?? undefined)
|
|
59
|
+
|
|
60
|
+
const payload = this.job.serialize()
|
|
61
|
+
if (this._queue) payload.queue = this._queue
|
|
62
|
+
if (this._connection) payload.connection = this._connection
|
|
63
|
+
|
|
64
|
+
const queue = this._queue ?? this.job.queue
|
|
65
|
+
const delay = this._delay || this.job.delay
|
|
66
|
+
|
|
67
|
+
await driver.push(payload, queue, delay)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Makes PendingDispatch thenable so `await dispatch(job)` works.
|
|
72
|
+
* This is the magic that lets the fluent API work with await.
|
|
73
|
+
*/
|
|
74
|
+
then<TResult1 = void, TResult2 = never>(
|
|
75
|
+
onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null,
|
|
76
|
+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null,
|
|
77
|
+
): PromiseLike<TResult1 | TResult2> {
|
|
78
|
+
return this.send().then(onfulfilled, onrejected)
|
|
79
|
+
}
|
|
80
|
+
}
|