@igniter-js/jobs 0.1.0 → 0.1.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/AGENTS.md +115 -541
- package/CHANGELOG.md +5 -0
- package/README.md +118 -356
- package/dist/adapter-PiDCQWQd.d.mts +529 -0
- package/dist/adapter-PiDCQWQd.d.ts +529 -0
- package/dist/adapters/bullmq.adapter.d.mts +55 -110
- package/dist/adapters/bullmq.adapter.d.ts +55 -110
- package/dist/adapters/bullmq.adapter.js +431 -529
- package/dist/adapters/bullmq.adapter.js.map +1 -1
- package/dist/adapters/bullmq.adapter.mjs +431 -529
- package/dist/adapters/bullmq.adapter.mjs.map +1 -1
- package/dist/adapters/index.d.mts +3 -3
- package/dist/adapters/index.d.ts +3 -3
- package/dist/adapters/index.js +889 -960
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/index.mjs +888 -959
- package/dist/adapters/index.mjs.map +1 -1
- package/dist/adapters/memory.adapter.d.mts +52 -98
- package/dist/adapters/memory.adapter.d.ts +52 -98
- package/dist/adapters/memory.adapter.js +471 -473
- package/dist/adapters/memory.adapter.js.map +1 -1
- package/dist/adapters/memory.adapter.mjs +471 -473
- package/dist/adapters/memory.adapter.mjs.map +1 -1
- package/dist/index.d.mts +485 -958
- package/dist/index.d.ts +485 -958
- package/dist/index.js +2053 -917
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2051 -917
- package/dist/index.mjs.map +1 -1
- package/package.json +34 -16
- package/dist/adapter-CcQCatSa.d.mts +0 -1411
- package/dist/adapter-CcQCatSa.d.ts +0 -1411
package/dist/index.mjs
CHANGED
|
@@ -1,1000 +1,1065 @@
|
|
|
1
1
|
import { IgniterError } from '@igniter-js/core';
|
|
2
|
+
import { createBullMQAdapter } from '@igniter-js/adapter-bullmq';
|
|
2
3
|
|
|
3
4
|
// src/errors/igniter-jobs.error.ts
|
|
4
5
|
var IGNITER_JOBS_ERROR_CODES = {
|
|
5
|
-
// Configuration errors
|
|
6
6
|
JOBS_ADAPTER_REQUIRED: "JOBS_ADAPTER_REQUIRED",
|
|
7
|
+
JOBS_SERVICE_REQUIRED: "JOBS_SERVICE_REQUIRED",
|
|
7
8
|
JOBS_CONTEXT_REQUIRED: "JOBS_CONTEXT_REQUIRED",
|
|
8
|
-
JOBS_QUEUE_REQUIRED: "JOBS_QUEUE_REQUIRED",
|
|
9
9
|
JOBS_CONFIGURATION_INVALID: "JOBS_CONFIGURATION_INVALID",
|
|
10
|
-
// Queue errors
|
|
11
10
|
JOBS_QUEUE_NOT_FOUND: "JOBS_QUEUE_NOT_FOUND",
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
JOBS_CRON_ALREADY_EXISTS: "JOBS_CRON_ALREADY_EXISTS",
|
|
27
|
-
JOBS_CRON_EXPRESSION_INVALID: "JOBS_CRON_EXPRESSION_INVALID",
|
|
28
|
-
JOBS_CRON_HANDLER_REQUIRED: "JOBS_CRON_HANDLER_REQUIRED",
|
|
29
|
-
// Dispatch errors
|
|
30
|
-
JOBS_DISPATCH_FAILED: "JOBS_DISPATCH_FAILED",
|
|
31
|
-
JOBS_SCHEDULE_FAILED: "JOBS_SCHEDULE_FAILED",
|
|
32
|
-
JOBS_INPUT_REQUIRED: "JOBS_INPUT_REQUIRED",
|
|
33
|
-
JOBS_INPUT_VALIDATION_FAILED: "JOBS_INPUT_VALIDATION_FAILED",
|
|
34
|
-
// Scope errors
|
|
35
|
-
JOBS_SCOPE_REQUIRED: "JOBS_SCOPE_REQUIRED",
|
|
11
|
+
JOBS_QUEUE_DUPLICATE: "JOBS_QUEUE_DUPLICATE",
|
|
12
|
+
JOBS_QUEUE_OPERATION_FAILED: "JOBS_QUEUE_OPERATION_FAILED",
|
|
13
|
+
JOBS_INVALID_DEFINITION: "JOBS_INVALID_DEFINITION",
|
|
14
|
+
JOBS_HANDLER_REQUIRED: "JOBS_HANDLER_REQUIRED",
|
|
15
|
+
JOBS_DUPLICATE_JOB: "JOBS_DUPLICATE_JOB",
|
|
16
|
+
JOBS_NOT_FOUND: "JOBS_NOT_FOUND",
|
|
17
|
+
JOBS_NOT_REGISTERED: "JOBS_NOT_REGISTERED",
|
|
18
|
+
JOBS_EXECUTION_FAILED: "JOBS_EXECUTION_FAILED",
|
|
19
|
+
JOBS_TIMEOUT: "JOBS_TIMEOUT",
|
|
20
|
+
JOBS_CONTEXT_FACTORY_FAILED: "JOBS_CONTEXT_FACTORY_FAILED",
|
|
21
|
+
JOBS_VALIDATION_FAILED: "JOBS_VALIDATION_FAILED",
|
|
22
|
+
JOBS_INVALID_INPUT: "JOBS_INVALID_INPUT",
|
|
23
|
+
JOBS_INVALID_CRON: "JOBS_INVALID_CRON",
|
|
24
|
+
JOBS_INVALID_SCHEDULE: "JOBS_INVALID_SCHEDULE",
|
|
36
25
|
JOBS_SCOPE_ALREADY_DEFINED: "JOBS_SCOPE_ALREADY_DEFINED",
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
// Job management errors
|
|
42
|
-
JOBS_GET_FAILED: "JOBS_GET_FAILED",
|
|
43
|
-
JOBS_RETRY_FAILED: "JOBS_RETRY_FAILED",
|
|
44
|
-
JOBS_REMOVE_FAILED: "JOBS_REMOVE_FAILED",
|
|
45
|
-
JOBS_PROMOTE_FAILED: "JOBS_PROMOTE_FAILED",
|
|
46
|
-
JOBS_MOVE_FAILED: "JOBS_MOVE_FAILED",
|
|
47
|
-
JOBS_STATE_FAILED: "JOBS_STATE_FAILED",
|
|
48
|
-
JOBS_PROGRESS_FAILED: "JOBS_PROGRESS_FAILED",
|
|
49
|
-
JOBS_LOGS_FAILED: "JOBS_LOGS_FAILED",
|
|
50
|
-
// Worker errors
|
|
51
|
-
JOBS_WORKER_CREATE_FAILED: "JOBS_WORKER_CREATE_FAILED",
|
|
52
|
-
JOBS_WORKER_START_FAILED: "JOBS_WORKER_START_FAILED",
|
|
53
|
-
JOBS_WORKER_STOP_FAILED: "JOBS_WORKER_STOP_FAILED",
|
|
54
|
-
JOBS_WORKER_NOT_FOUND: "JOBS_WORKER_NOT_FOUND",
|
|
55
|
-
JOBS_WORKER_ALREADY_RUNNING: "JOBS_WORKER_ALREADY_RUNNING",
|
|
56
|
-
// Event/Subscribe errors
|
|
57
|
-
JOBS_SUBSCRIBE_FAILED: "JOBS_SUBSCRIBE_FAILED",
|
|
58
|
-
JOBS_UNSUBSCRIBE_FAILED: "JOBS_UNSUBSCRIBE_FAILED",
|
|
59
|
-
JOBS_EVENT_EMIT_FAILED: "JOBS_EVENT_EMIT_FAILED",
|
|
60
|
-
// Search errors
|
|
61
|
-
JOBS_SEARCH_FAILED: "JOBS_SEARCH_FAILED",
|
|
62
|
-
JOBS_SEARCH_INVALID_TARGET: "JOBS_SEARCH_INVALID_TARGET",
|
|
63
|
-
// Shutdown errors
|
|
64
|
-
JOBS_SHUTDOWN_FAILED: "JOBS_SHUTDOWN_FAILED",
|
|
65
|
-
// Handler errors
|
|
66
|
-
JOBS_HANDLER_FAILED: "JOBS_HANDLER_FAILED",
|
|
67
|
-
JOBS_HANDLER_TIMEOUT: "JOBS_HANDLER_TIMEOUT"
|
|
26
|
+
JOBS_WORKER_FAILED: "JOBS_WORKER_FAILED",
|
|
27
|
+
JOBS_ADAPTER_ERROR: "JOBS_ADAPTER_ERROR",
|
|
28
|
+
JOBS_ADAPTER_CONNECTION_FAILED: "JOBS_ADAPTER_CONNECTION_FAILED",
|
|
29
|
+
JOBS_SUBSCRIBE_FAILED: "JOBS_SUBSCRIBE_FAILED"
|
|
68
30
|
};
|
|
69
|
-
var IgniterJobsError = class
|
|
31
|
+
var IgniterJobsError = class extends IgniterError {
|
|
70
32
|
constructor(options) {
|
|
71
|
-
super(
|
|
72
|
-
code: options.code,
|
|
73
|
-
message: options.message,
|
|
74
|
-
statusCode: options.statusCode ?? 500,
|
|
75
|
-
causer: "@igniter-js/jobs",
|
|
76
|
-
cause: options.cause,
|
|
77
|
-
details: options.details,
|
|
78
|
-
logger: options.logger
|
|
79
|
-
});
|
|
80
|
-
this.code = options.code;
|
|
81
|
-
this.details = options.details;
|
|
82
|
-
this.name = "IgniterJobsError";
|
|
83
|
-
if (Error.captureStackTrace) {
|
|
84
|
-
Error.captureStackTrace(this, _IgniterJobsError);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Convert error to a plain object for serialization.
|
|
89
|
-
*/
|
|
90
|
-
toJSON() {
|
|
91
|
-
return {
|
|
92
|
-
name: this.name,
|
|
93
|
-
code: this.code,
|
|
94
|
-
message: this.message,
|
|
95
|
-
statusCode: this.statusCode,
|
|
96
|
-
details: this.details,
|
|
97
|
-
stack: this.stack
|
|
98
|
-
};
|
|
33
|
+
super(options);
|
|
99
34
|
}
|
|
100
35
|
};
|
|
101
36
|
|
|
102
37
|
// src/builders/igniter-worker.builder.ts
|
|
103
|
-
var IgniterWorkerBuilder = class {
|
|
104
|
-
constructor(
|
|
105
|
-
this.adapter = adapter;
|
|
106
|
-
this.
|
|
107
|
-
this.availableQueues = availableQueues;
|
|
38
|
+
var IgniterWorkerBuilder = class _IgniterWorkerBuilder {
|
|
39
|
+
constructor(params) {
|
|
40
|
+
this.adapter = params.adapter;
|
|
41
|
+
this.allowedQueues = params.allowedQueues;
|
|
108
42
|
this.state = {
|
|
109
|
-
queues: [],
|
|
110
|
-
concurrency:
|
|
43
|
+
queues: params.state?.queues ?? [],
|
|
44
|
+
concurrency: params.state?.concurrency,
|
|
45
|
+
limiter: params.state?.limiter,
|
|
46
|
+
handlers: params.state?.handlers ?? {}
|
|
111
47
|
};
|
|
112
48
|
}
|
|
49
|
+
clone(patch) {
|
|
50
|
+
return new _IgniterWorkerBuilder({
|
|
51
|
+
adapter: this.adapter,
|
|
52
|
+
allowedQueues: this.allowedQueues,
|
|
53
|
+
state: { ...this.state, ...patch }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
113
56
|
/**
|
|
114
|
-
*
|
|
115
|
-
* If not called, worker processes all queues.
|
|
116
|
-
*
|
|
117
|
-
* @param queues - Queue names to process
|
|
118
|
-
* @returns The builder for chaining
|
|
57
|
+
* Adds a queue to the worker.
|
|
119
58
|
*
|
|
120
|
-
* @
|
|
121
|
-
* ```typescript
|
|
122
|
-
* .forQueues('email', 'payment')
|
|
123
|
-
* ```
|
|
59
|
+
* @param queue - Queue name registered on the jobs instance.
|
|
124
60
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
statusCode: 400,
|
|
132
|
-
details: { queue, availableQueues: this.availableQueues }
|
|
133
|
-
});
|
|
134
|
-
}
|
|
61
|
+
addQueue(queue) {
|
|
62
|
+
if (!this.allowedQueues.includes(queue)) {
|
|
63
|
+
throw new IgniterJobsError({
|
|
64
|
+
code: "JOBS_QUEUE_NOT_FOUND",
|
|
65
|
+
message: `Queue "${queue}" is not registered on this jobs instance.`
|
|
66
|
+
});
|
|
135
67
|
}
|
|
136
|
-
this.state.queues
|
|
137
|
-
return this;
|
|
68
|
+
if (this.state.queues.includes(queue)) return this;
|
|
69
|
+
return this.clone({ queues: [...this.state.queues, queue] });
|
|
138
70
|
}
|
|
139
71
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* @param concurrency - Number of parallel jobs
|
|
143
|
-
* @returns The builder for chaining
|
|
144
|
-
*
|
|
145
|
-
* @example
|
|
146
|
-
* ```typescript
|
|
147
|
-
* .withConcurrency(10)
|
|
148
|
-
* ```
|
|
72
|
+
* Sets the worker concurrency.
|
|
149
73
|
*/
|
|
150
74
|
withConcurrency(concurrency) {
|
|
151
|
-
if (concurrency
|
|
75
|
+
if (!Number.isFinite(concurrency) || concurrency <= 0) {
|
|
152
76
|
throw new IgniterJobsError({
|
|
153
77
|
code: "JOBS_CONFIGURATION_INVALID",
|
|
154
|
-
message: "
|
|
155
|
-
statusCode: 400,
|
|
156
|
-
details: { concurrency }
|
|
78
|
+
message: "Worker concurrency must be a positive number."
|
|
157
79
|
});
|
|
158
80
|
}
|
|
159
|
-
this.
|
|
160
|
-
return this;
|
|
81
|
+
return this.clone({ concurrency });
|
|
161
82
|
}
|
|
162
83
|
/**
|
|
163
|
-
*
|
|
164
|
-
* Jobs are locked for this duration while being processed.
|
|
165
|
-
*
|
|
166
|
-
* @param duration - Lock duration in milliseconds
|
|
167
|
-
* @returns The builder for chaining
|
|
168
|
-
*
|
|
169
|
-
* @example
|
|
170
|
-
* ```typescript
|
|
171
|
-
* .withLockDuration(30000) // 30 seconds
|
|
172
|
-
* ```
|
|
84
|
+
* Sets a worker-level rate limiter.
|
|
173
85
|
*/
|
|
174
|
-
|
|
175
|
-
if (duration
|
|
86
|
+
withLimiter(limiter) {
|
|
87
|
+
if (!limiter || !Number.isFinite(limiter.max) || !Number.isFinite(limiter.duration) || limiter.max <= 0 || limiter.duration <= 0) {
|
|
176
88
|
throw new IgniterJobsError({
|
|
177
89
|
code: "JOBS_CONFIGURATION_INVALID",
|
|
178
|
-
message: "
|
|
179
|
-
statusCode: 400,
|
|
180
|
-
details: { duration }
|
|
90
|
+
message: "Limiter must include positive max and duration."
|
|
181
91
|
});
|
|
182
92
|
}
|
|
183
|
-
this.
|
|
184
|
-
|
|
93
|
+
return this.clone({ limiter });
|
|
94
|
+
}
|
|
95
|
+
onActive(handler) {
|
|
96
|
+
return this.clone({ handlers: { ...this.state.handlers, onActive: handler } });
|
|
97
|
+
}
|
|
98
|
+
onSuccess(handler) {
|
|
99
|
+
return this.clone({ handlers: { ...this.state.handlers, onSuccess: handler } });
|
|
100
|
+
}
|
|
101
|
+
onFailure(handler) {
|
|
102
|
+
return this.clone({ handlers: { ...this.state.handlers, onFailure: handler } });
|
|
103
|
+
}
|
|
104
|
+
onIdle(handler) {
|
|
105
|
+
return this.clone({ handlers: { ...this.state.handlers, onIdle: handler } });
|
|
185
106
|
}
|
|
186
107
|
/**
|
|
187
|
-
*
|
|
108
|
+
* Builds and starts the worker.
|
|
188
109
|
*
|
|
189
|
-
*
|
|
190
|
-
* @returns The builder for chaining
|
|
191
|
-
*
|
|
192
|
-
* @example
|
|
193
|
-
* ```typescript
|
|
194
|
-
* .withLimiter({ max: 100, duration: 60000 }) // 100 jobs per minute
|
|
195
|
-
* ```
|
|
110
|
+
* If no queues are added explicitly, the adapter should interpret it as "all queues".
|
|
196
111
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
if (config.duration < 1) {
|
|
207
|
-
throw new IgniterJobsError({
|
|
208
|
-
code: "JOBS_CONFIGURATION_INVALID",
|
|
209
|
-
message: "Limiter duration must be at least 1ms",
|
|
210
|
-
statusCode: 400,
|
|
211
|
-
details: { duration: config.duration }
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
this.state.limiter = config;
|
|
215
|
-
return this;
|
|
112
|
+
async start() {
|
|
113
|
+
const concurrency = this.state.concurrency ?? 1;
|
|
114
|
+
return this.adapter.createWorker({
|
|
115
|
+
queues: this.state.queues,
|
|
116
|
+
concurrency,
|
|
117
|
+
limiter: this.state.limiter,
|
|
118
|
+
handlers: this.state.handlers
|
|
119
|
+
});
|
|
216
120
|
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// src/utils/prefix.ts
|
|
124
|
+
var _IgniterJobsPrefix = class _IgniterJobsPrefix {
|
|
217
125
|
/**
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* @param callback - Idle callback
|
|
221
|
-
* @returns The builder for chaining
|
|
126
|
+
* Builds a normalized queue name using the global prefix and queue id.
|
|
222
127
|
*
|
|
223
128
|
* @example
|
|
224
129
|
* ```typescript
|
|
225
|
-
*
|
|
130
|
+
* const name = IgniterJobsPrefix.buildQueueName('email')
|
|
131
|
+
* // -> igniter:jobs:email
|
|
226
132
|
* ```
|
|
227
133
|
*/
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return this;
|
|
134
|
+
static buildQueueName(queue) {
|
|
135
|
+
return `${_IgniterJobsPrefix.BASE_PREFIX}:${queue}`;
|
|
231
136
|
}
|
|
232
137
|
/**
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* @returns Worker handle for management
|
|
138
|
+
* Builds the event channel used for pub/sub.
|
|
236
139
|
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
* const worker = await jobs.worker
|
|
240
|
-
* .create()
|
|
241
|
-
* .forQueues('email')
|
|
242
|
-
* .start()
|
|
243
|
-
*
|
|
244
|
-
* // Later
|
|
245
|
-
* await worker.pause()
|
|
246
|
-
* await worker.close()
|
|
247
|
-
* ```
|
|
140
|
+
* Unscoped events are published to a global channel per service/environment.
|
|
141
|
+
* Scoped events are also published to an additional channel for that scope.
|
|
248
142
|
*/
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
{
|
|
254
|
-
queues: queuesToProcess,
|
|
255
|
-
concurrency: this.state.concurrency,
|
|
256
|
-
lockDuration: this.state.lockDuration,
|
|
257
|
-
limiter: this.state.limiter,
|
|
258
|
-
onIdle: this.state.onIdle
|
|
259
|
-
},
|
|
260
|
-
this.jobHandler
|
|
261
|
-
);
|
|
262
|
-
return {
|
|
263
|
-
id: handle.id,
|
|
264
|
-
pause: () => handle.pause(),
|
|
265
|
-
resume: () => handle.resume(),
|
|
266
|
-
close: () => handle.close(),
|
|
267
|
-
isRunning: () => handle.isRunning(),
|
|
268
|
-
isPaused: () => handle.isPaused(),
|
|
269
|
-
getMetrics: () => handle.getMetrics()
|
|
270
|
-
};
|
|
271
|
-
} catch (error) {
|
|
272
|
-
throw new IgniterJobsError({
|
|
273
|
-
code: "JOBS_WORKER_START_FAILED",
|
|
274
|
-
message: "Failed to start worker",
|
|
275
|
-
statusCode: 500,
|
|
276
|
-
cause: error instanceof Error ? error : void 0,
|
|
277
|
-
details: { queues: queuesToProcess }
|
|
278
|
-
});
|
|
279
|
-
}
|
|
143
|
+
static buildEventsChannel(params) {
|
|
144
|
+
const base = `${_IgniterJobsPrefix.BASE_PREFIX}:events:${params.environment}:${params.service}`;
|
|
145
|
+
if (!params.scope) return base;
|
|
146
|
+
return `${base}:scope:${params.scope.type}:${params.scope.id}`;
|
|
280
147
|
}
|
|
281
148
|
};
|
|
149
|
+
_IgniterJobsPrefix.BASE_PREFIX = "igniter:jobs";
|
|
150
|
+
var IgniterJobsPrefix = _IgniterJobsPrefix;
|
|
282
151
|
|
|
283
|
-
// src/
|
|
284
|
-
var
|
|
285
|
-
function getFullQueueName(queueName) {
|
|
286
|
-
return `${IGNITER_JOBS_PREFIX}:${queueName}`;
|
|
287
|
-
}
|
|
288
|
-
var IgniterJobsRuntime = class {
|
|
289
|
-
constructor(config) {
|
|
290
|
-
this.config = config;
|
|
291
|
-
this.queueNames = Object.keys(config.queues);
|
|
292
|
-
return this.createProxy();
|
|
293
|
-
}
|
|
152
|
+
// src/utils/events.utils.ts
|
|
153
|
+
var IgniterJobsEventsUtils = class {
|
|
294
154
|
/**
|
|
295
|
-
*
|
|
155
|
+
* Constructs a standardized job event type string.
|
|
156
|
+
*
|
|
157
|
+
* @param queue - The queue name.
|
|
158
|
+
* @param jobName - The job name.
|
|
159
|
+
* @param event - The specific event name (e.g., 'started', 'completed').
|
|
160
|
+
* @returns A string in the format `queue:jobName:event`.
|
|
296
161
|
*/
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
return new Proxy({}, {
|
|
300
|
-
get(_, prop) {
|
|
301
|
-
if (prop === "subscribe") {
|
|
302
|
-
return self.createGlobalSubscribe();
|
|
303
|
-
}
|
|
304
|
-
if (prop === "search") {
|
|
305
|
-
return self.createSearch();
|
|
306
|
-
}
|
|
307
|
-
if (prop === "worker") {
|
|
308
|
-
return self.createWorkerBuilder();
|
|
309
|
-
}
|
|
310
|
-
if (prop === "shutdown") {
|
|
311
|
-
return () => self.shutdown();
|
|
312
|
-
}
|
|
313
|
-
if (self.queueNames.includes(prop)) {
|
|
314
|
-
return self.createQueueProxy(prop);
|
|
315
|
-
}
|
|
316
|
-
return void 0;
|
|
317
|
-
},
|
|
318
|
-
has(_, prop) {
|
|
319
|
-
return ["subscribe", "search", "worker", "shutdown", ...self.queueNames].includes(prop);
|
|
320
|
-
},
|
|
321
|
-
ownKeys() {
|
|
322
|
-
return ["subscribe", "search", "worker", "shutdown", ...self.queueNames];
|
|
323
|
-
},
|
|
324
|
-
getOwnPropertyDescriptor(_, prop) {
|
|
325
|
-
if (["subscribe", "search", "worker", "shutdown", ...self.queueNames].includes(prop)) {
|
|
326
|
-
return { configurable: true, enumerable: true };
|
|
327
|
-
}
|
|
328
|
-
return void 0;
|
|
329
|
-
}
|
|
330
|
-
});
|
|
162
|
+
static buildJobEventType(queue, jobName, event) {
|
|
163
|
+
return `${queue}:${jobName}:${event}`;
|
|
331
164
|
}
|
|
332
165
|
/**
|
|
333
|
-
*
|
|
166
|
+
* Publishes a job event to the configured adapter.
|
|
167
|
+
* Handles publishing to both the base channel and the scope-specific channel if a scope is provided.
|
|
168
|
+
*
|
|
169
|
+
* @param params - Configuration parameters for publishing the event.
|
|
334
170
|
*/
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
return new Proxy({}, {
|
|
340
|
-
get(_, prop) {
|
|
341
|
-
if (prop === "subscribe") {
|
|
342
|
-
return self.createQueueSubscribe(queueName);
|
|
343
|
-
}
|
|
344
|
-
if (prop === "list") {
|
|
345
|
-
return (options) => self.listQueueJobs(queueName, options);
|
|
346
|
-
}
|
|
347
|
-
if (prop === "get") {
|
|
348
|
-
return () => self.createQueueManagement(queueName);
|
|
349
|
-
}
|
|
350
|
-
if (jobNames.includes(prop)) {
|
|
351
|
-
return self.createJobProxy(queueName, prop);
|
|
352
|
-
}
|
|
353
|
-
return void 0;
|
|
354
|
-
}
|
|
171
|
+
static async publishJobsEvent(params) {
|
|
172
|
+
const baseChannel = IgniterJobsPrefix.buildEventsChannel({
|
|
173
|
+
service: params.service,
|
|
174
|
+
environment: params.environment
|
|
355
175
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
return {
|
|
363
|
-
dispatch: (input, options) => self.dispatchJob(queueName, jobName, input, options),
|
|
364
|
-
schedule: (params) => self.scheduleJob(queueName, jobName, params),
|
|
365
|
-
get: (jobId) => self.createJobManagement(queueName, jobId),
|
|
366
|
-
many: (jobIds) => self.createJobBatchManagement(queueName, jobIds),
|
|
367
|
-
pause: () => self.pauseJobType(queueName, jobName),
|
|
368
|
-
resume: () => self.resumeJobType(queueName, jobName),
|
|
369
|
-
subscribe: (handler) => self.subscribeToJob(queueName, jobName, handler)
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Dispatch a job.
|
|
374
|
-
*/
|
|
375
|
-
async dispatchJob(queueName, jobName, input, options) {
|
|
376
|
-
const queueConfig = this.config.queues[queueName];
|
|
377
|
-
const jobDef = queueConfig.jobs[jobName];
|
|
378
|
-
if (jobDef.input) {
|
|
379
|
-
try {
|
|
380
|
-
const result = await jobDef.input["~standard"].validate(input);
|
|
381
|
-
if (result.issues) {
|
|
382
|
-
throw new IgniterJobsError({
|
|
383
|
-
code: "JOBS_INPUT_VALIDATION_FAILED",
|
|
384
|
-
message: `Input validation failed for job "${jobName}"`,
|
|
385
|
-
statusCode: 400,
|
|
386
|
-
details: { issues: result.issues }
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
input = result.value;
|
|
390
|
-
} catch (error) {
|
|
391
|
-
if (error instanceof IgniterJobsError) throw error;
|
|
392
|
-
if (typeof jobDef.input.parse === "function") {
|
|
393
|
-
input = jobDef.input.parse(input);
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (this.config.scope?.options?.required && !options?.scope) {
|
|
398
|
-
throw new IgniterJobsError({
|
|
399
|
-
code: "JOBS_SCOPE_REQUIRED",
|
|
400
|
-
message: `Scope "${this.config.scope.key}" is required for job dispatch`,
|
|
401
|
-
statusCode: 400,
|
|
402
|
-
details: { queue: queueName, job: jobName }
|
|
403
|
-
});
|
|
404
|
-
}
|
|
405
|
-
const params = {
|
|
406
|
-
queue: getFullQueueName(queueName),
|
|
407
|
-
name: jobName,
|
|
408
|
-
data: input,
|
|
409
|
-
jobId: options?.jobId,
|
|
410
|
-
delay: options?.delay,
|
|
411
|
-
priority: options?.priority ?? jobDef.priority,
|
|
412
|
-
attempts: jobDef.retry?.attempts ?? this.config.defaults?.retry?.attempts ?? 3,
|
|
413
|
-
backoff: jobDef.retry?.backoff ?? this.config.defaults?.retry?.backoff,
|
|
414
|
-
removeOnComplete: jobDef.removeOnComplete ?? this.config.defaults?.removeOnComplete,
|
|
415
|
-
removeOnFail: jobDef.removeOnFail ?? this.config.defaults?.removeOnFail,
|
|
416
|
-
scope: options?.scope,
|
|
417
|
-
actor: options?.actor
|
|
418
|
-
};
|
|
419
|
-
try {
|
|
420
|
-
const jobId = await this.config.adapter.dispatch(params);
|
|
421
|
-
if (this.config.telemetry) {
|
|
422
|
-
}
|
|
423
|
-
return jobId;
|
|
424
|
-
} catch (error) {
|
|
425
|
-
throw new IgniterJobsError({
|
|
426
|
-
code: "JOBS_DISPATCH_FAILED",
|
|
427
|
-
message: `Failed to dispatch job "${jobName}" to queue "${queueName}"`,
|
|
428
|
-
statusCode: 500,
|
|
429
|
-
cause: error instanceof Error ? error : void 0,
|
|
430
|
-
details: { queue: queueName, job: jobName }
|
|
431
|
-
});
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
/**
|
|
435
|
-
* Schedule a job for future execution.
|
|
436
|
-
*/
|
|
437
|
-
async scheduleJob(queueName, jobName, params) {
|
|
438
|
-
const queueConfig = this.config.queues[queueName];
|
|
439
|
-
const jobDef = queueConfig.jobs[jobName];
|
|
440
|
-
let validatedInput = params.input;
|
|
441
|
-
if (jobDef.input) {
|
|
442
|
-
try {
|
|
443
|
-
const result = await jobDef.input["~standard"].validate(params.input);
|
|
444
|
-
if (result.issues) {
|
|
445
|
-
throw new IgniterJobsError({
|
|
446
|
-
code: "JOBS_INPUT_VALIDATION_FAILED",
|
|
447
|
-
message: `Input validation failed for job "${jobName}"`,
|
|
448
|
-
statusCode: 400,
|
|
449
|
-
details: { issues: result.issues }
|
|
450
|
-
});
|
|
451
|
-
}
|
|
452
|
-
validatedInput = result.value;
|
|
453
|
-
} catch (error) {
|
|
454
|
-
if (error instanceof IgniterJobsError) throw error;
|
|
455
|
-
if (typeof jobDef.input.parse === "function") {
|
|
456
|
-
validatedInput = jobDef.input.parse(params.input);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
const scheduleParams = {
|
|
461
|
-
queue: getFullQueueName(queueName),
|
|
462
|
-
name: jobName,
|
|
463
|
-
data: validatedInput,
|
|
464
|
-
jobId: params.jobId,
|
|
465
|
-
at: params.at,
|
|
466
|
-
cron: params.cron,
|
|
467
|
-
every: params.every,
|
|
468
|
-
timezone: params.timezone,
|
|
469
|
-
attempts: jobDef.retry?.attempts ?? this.config.defaults?.retry?.attempts ?? 3,
|
|
470
|
-
backoff: jobDef.retry?.backoff ?? this.config.defaults?.retry?.backoff,
|
|
471
|
-
removeOnComplete: jobDef.removeOnComplete ?? this.config.defaults?.removeOnComplete,
|
|
472
|
-
removeOnFail: jobDef.removeOnFail ?? this.config.defaults?.removeOnFail,
|
|
473
|
-
scope: params.scope,
|
|
474
|
-
actor: params.actor
|
|
475
|
-
};
|
|
476
|
-
try {
|
|
477
|
-
return await this.config.adapter.schedule(scheduleParams);
|
|
478
|
-
} catch (error) {
|
|
479
|
-
throw new IgniterJobsError({
|
|
480
|
-
code: "JOBS_SCHEDULE_FAILED",
|
|
481
|
-
message: `Failed to schedule job "${jobName}" in queue "${queueName}"`,
|
|
482
|
-
statusCode: 500,
|
|
483
|
-
cause: error instanceof Error ? error : void 0,
|
|
484
|
-
details: { queue: queueName, job: jobName }
|
|
176
|
+
await params.adapter.publishEvent(baseChannel, params.event);
|
|
177
|
+
if (params.scope) {
|
|
178
|
+
const scopeChannel = IgniterJobsPrefix.buildEventsChannel({
|
|
179
|
+
service: params.service,
|
|
180
|
+
environment: params.environment,
|
|
181
|
+
scope: { type: params.scope.type, id: params.scope.id }
|
|
485
182
|
});
|
|
183
|
+
await params.adapter.publishEvent(scopeChannel, params.event);
|
|
486
184
|
}
|
|
487
185
|
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// src/utils/scope.ts
|
|
189
|
+
var IgniterJobsScopeUtils = class {
|
|
488
190
|
/**
|
|
489
|
-
*
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
return {
|
|
495
|
-
retrieve: () => adapter.getJob(fullQueueName, jobId),
|
|
496
|
-
retry: () => adapter.retryJob(fullQueueName, jobId),
|
|
497
|
-
remove: () => adapter.removeJob(fullQueueName, jobId),
|
|
498
|
-
state: () => adapter.getJobState(fullQueueName, jobId),
|
|
499
|
-
progress: () => adapter.getJobProgress(fullQueueName, jobId),
|
|
500
|
-
logs: () => adapter.getJobLogs(fullQueueName, jobId),
|
|
501
|
-
promote: () => adapter.promoteJob(fullQueueName, jobId),
|
|
502
|
-
move: (state, reason) => adapter.moveJob(fullQueueName, jobId, state, reason)
|
|
503
|
-
};
|
|
504
|
-
}
|
|
505
|
-
/**
|
|
506
|
-
* Create batch job management methods.
|
|
507
|
-
*/
|
|
508
|
-
createJobBatchManagement(queueName, jobIds) {
|
|
509
|
-
const adapter = this.config.adapter;
|
|
510
|
-
const fullQueueName = getFullQueueName(queueName);
|
|
511
|
-
return {
|
|
512
|
-
retry: () => adapter.retryJobs(fullQueueName, jobIds),
|
|
513
|
-
remove: () => adapter.removeJobs(fullQueueName, jobIds)
|
|
514
|
-
};
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Create queue management methods.
|
|
191
|
+
* Merges a scope entry into a metadata object.
|
|
192
|
+
*
|
|
193
|
+
* @param metadata - Existing metadata object.
|
|
194
|
+
* @param scope - The scope entry to merge.
|
|
195
|
+
* @returns A new metadata object containing the scope information, or the original metadata if no scope is provided.
|
|
518
196
|
*/
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const fullQueueName = getFullQueueName(queueName);
|
|
197
|
+
static mergeMetadataWithScope(metadata, scope) {
|
|
198
|
+
if (!scope) return metadata;
|
|
522
199
|
return {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
resume: () => adapter.resumeQueue(fullQueueName),
|
|
526
|
-
drain: () => adapter.drainQueue(fullQueueName),
|
|
527
|
-
clean: (options) => adapter.cleanQueue(fullQueueName, {
|
|
528
|
-
status: options.status,
|
|
529
|
-
olderThan: options.olderThan,
|
|
530
|
-
limit: options.limit
|
|
531
|
-
}),
|
|
532
|
-
obliterate: (options) => adapter.obliterateQueue(fullQueueName, options),
|
|
533
|
-
retryAll: () => adapter.retryAllFailed(fullQueueName)
|
|
534
|
-
};
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Pause a specific job type.
|
|
538
|
-
*/
|
|
539
|
-
async pauseJobType(queueName, jobName) {
|
|
540
|
-
const fullQueueName = getFullQueueName(queueName);
|
|
541
|
-
await this.config.adapter.pauseJobType(fullQueueName, jobName);
|
|
542
|
-
}
|
|
543
|
-
/**
|
|
544
|
-
* Resume a specific job type.
|
|
545
|
-
*/
|
|
546
|
-
async resumeJobType(queueName, jobName) {
|
|
547
|
-
const fullQueueName = getFullQueueName(queueName);
|
|
548
|
-
await this.config.adapter.resumeJobType(fullQueueName, jobName);
|
|
549
|
-
}
|
|
550
|
-
/**
|
|
551
|
-
* Subscribe to events for a specific job.
|
|
552
|
-
*/
|
|
553
|
-
async subscribeToJob(queueName, jobName, handler) {
|
|
554
|
-
const pattern = `${queueName}:${jobName}:*`;
|
|
555
|
-
return this.config.adapter.subscribe(pattern, handler);
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Create queue-level subscribe.
|
|
559
|
-
*/
|
|
560
|
-
createQueueSubscribe(queueName) {
|
|
561
|
-
return async (handler) => {
|
|
562
|
-
const pattern = `${queueName}:*`;
|
|
563
|
-
return this.config.adapter.subscribe(pattern, handler);
|
|
200
|
+
...metadata ?? {},
|
|
201
|
+
[this.SCOPE_METADATA_KEY]: scope
|
|
564
202
|
};
|
|
565
203
|
}
|
|
566
204
|
/**
|
|
567
|
-
*
|
|
205
|
+
* Extracts a scope entry from a metadata object.
|
|
206
|
+
*
|
|
207
|
+
* @param metadata - The metadata object to inspect.
|
|
208
|
+
* @returns The extracted scope entry, or undefined if not found.
|
|
568
209
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
210
|
+
static extractScopeFromMetadata(metadata) {
|
|
211
|
+
if (!metadata) return void 0;
|
|
212
|
+
const value = metadata[this.SCOPE_METADATA_KEY];
|
|
213
|
+
if (!value || typeof value !== "object") return void 0;
|
|
214
|
+
if (!("type" in value) || !("id" in value)) return void 0;
|
|
215
|
+
return value;
|
|
575
216
|
}
|
|
217
|
+
};
|
|
218
|
+
/**
|
|
219
|
+
* The key used to store scope information in job metadata.
|
|
220
|
+
*/
|
|
221
|
+
IgniterJobsScopeUtils.SCOPE_METADATA_KEY = "__igniter_jobs_scope";
|
|
222
|
+
|
|
223
|
+
// src/utils/telemetry.ts
|
|
224
|
+
var IgniterJobsTelemetryUtils = class {
|
|
576
225
|
/**
|
|
577
|
-
*
|
|
226
|
+
* Emits a telemetry event if telemetry is configured.
|
|
227
|
+
* This is a fire-and-forget operation - telemetry errors are silently ignored
|
|
228
|
+
* to avoid affecting job processing.
|
|
229
|
+
*
|
|
230
|
+
* @param telemetry - The telemetry instance (optional).
|
|
231
|
+
* @param eventName - The name of the event to emit.
|
|
232
|
+
* @param attributes - Attributes to attach to the event.
|
|
233
|
+
* @param level - The log level for the event (default: 'info').
|
|
578
234
|
*/
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
235
|
+
static emitTelemetry(telemetry, eventName, attributes, level = "info") {
|
|
236
|
+
if (!telemetry) return;
|
|
237
|
+
try {
|
|
238
|
+
telemetry.emit(eventName, { attributes, level });
|
|
239
|
+
} catch {
|
|
240
|
+
}
|
|
583
241
|
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// src/utils/validation.ts
|
|
245
|
+
var IgniterJobsValidationUtils = class {
|
|
584
246
|
/**
|
|
585
|
-
*
|
|
247
|
+
* Checks if a value conforms to the Standard Schema V1 interface.
|
|
248
|
+
*
|
|
249
|
+
* @param value - The value to check.
|
|
250
|
+
* @returns True if the value is a Standard Schema.
|
|
586
251
|
*/
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
case "queues":
|
|
592
|
-
return adapter.searchQueues(filter || {});
|
|
593
|
-
case "jobs":
|
|
594
|
-
return adapter.searchJobs(filter || {});
|
|
595
|
-
case "workers":
|
|
596
|
-
return [];
|
|
597
|
-
default:
|
|
598
|
-
throw new IgniterJobsError({
|
|
599
|
-
code: "JOBS_SEARCH_INVALID_TARGET",
|
|
600
|
-
message: `Invalid search target "${target}". Use "queues", "jobs", or "workers".`,
|
|
601
|
-
statusCode: 400
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
};
|
|
252
|
+
static isStandardSchema(value) {
|
|
253
|
+
return Boolean(
|
|
254
|
+
value && typeof value === "object" && "~standard" in value
|
|
255
|
+
);
|
|
605
256
|
}
|
|
606
257
|
/**
|
|
607
|
-
*
|
|
258
|
+
* Checks if a value conforms to a Zod-like schema interface.
|
|
259
|
+
*
|
|
260
|
+
* @param value - The value to check.
|
|
261
|
+
* @returns True if the value is a Zod-like schema.
|
|
608
262
|
*/
|
|
609
|
-
|
|
610
|
-
return
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
this.createJobHandler(),
|
|
614
|
-
this.queueNames.map((q) => getFullQueueName(q))
|
|
615
|
-
)
|
|
616
|
-
};
|
|
263
|
+
static isZodLikeSchema(value) {
|
|
264
|
+
return Boolean(
|
|
265
|
+
value && typeof value === "object" && "parse" in value
|
|
266
|
+
);
|
|
617
267
|
}
|
|
618
268
|
/**
|
|
619
|
-
*
|
|
269
|
+
* Validates input against a provided schema (Standard Schema or Zod-like).
|
|
270
|
+
*
|
|
271
|
+
* @param schema - The schema definition.
|
|
272
|
+
* @param input - The input data to validate.
|
|
273
|
+
* @returns The validated (and possibly transformed) data.
|
|
274
|
+
* @throws {IgniterJobsError} If validation fails.
|
|
620
275
|
*/
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (!queueConfig) {
|
|
276
|
+
static async validateInput(schema, input) {
|
|
277
|
+
if (this.isStandardSchema(schema)) {
|
|
278
|
+
const result = await schema["~standard"].validate(input);
|
|
279
|
+
if ("issues" in result && result.issues) {
|
|
280
|
+
const message = result.issues.map((i) => i.message).join("; ");
|
|
627
281
|
throw new IgniterJobsError({
|
|
628
|
-
code: "
|
|
629
|
-
message: `
|
|
630
|
-
|
|
282
|
+
code: "JOBS_VALIDATION_FAILED",
|
|
283
|
+
message: `Input validation failed: ${message}`,
|
|
284
|
+
details: { issues: result.issues },
|
|
285
|
+
statusCode: 400
|
|
631
286
|
});
|
|
632
287
|
}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
288
|
+
return result.value;
|
|
289
|
+
}
|
|
290
|
+
if (this.isZodLikeSchema(schema)) {
|
|
291
|
+
if (typeof schema.safeParse === "function") {
|
|
292
|
+
const result = schema.safeParse(input);
|
|
293
|
+
if (!result.success) {
|
|
294
|
+
throw new IgniterJobsError({
|
|
295
|
+
code: "JOBS_VALIDATION_FAILED",
|
|
296
|
+
message: "Input validation failed.",
|
|
297
|
+
details: { error: result.error },
|
|
298
|
+
statusCode: 400
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return result.data;
|
|
640
302
|
}
|
|
641
|
-
const context = await self.config.context();
|
|
642
|
-
const ctx = {
|
|
643
|
-
input: job.data,
|
|
644
|
-
context,
|
|
645
|
-
job: {
|
|
646
|
-
id: job.id,
|
|
647
|
-
name: job.name,
|
|
648
|
-
queue: queueName,
|
|
649
|
-
timestamp: job.timestamp
|
|
650
|
-
},
|
|
651
|
-
attempt: job.attempt,
|
|
652
|
-
scope: job.scope,
|
|
653
|
-
actor: job.actor,
|
|
654
|
-
log: job.log,
|
|
655
|
-
updateProgress: job.updateProgress
|
|
656
|
-
};
|
|
657
303
|
try {
|
|
658
|
-
|
|
659
|
-
if (jobDef.onComplete) {
|
|
660
|
-
await jobDef.onComplete(ctx, result);
|
|
661
|
-
}
|
|
662
|
-
return result;
|
|
304
|
+
return schema.parse(input);
|
|
663
305
|
} catch (error) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
306
|
+
throw new IgniterJobsError({
|
|
307
|
+
code: "JOBS_VALIDATION_FAILED",
|
|
308
|
+
message: "Input validation failed.",
|
|
309
|
+
details: { error },
|
|
310
|
+
statusCode: 400
|
|
311
|
+
});
|
|
668
312
|
}
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
/**
|
|
672
|
-
* Shutdown the jobs instance.
|
|
673
|
-
*/
|
|
674
|
-
async shutdown() {
|
|
675
|
-
try {
|
|
676
|
-
await this.config.adapter.shutdown();
|
|
677
|
-
} catch (error) {
|
|
678
|
-
throw new IgniterJobsError({
|
|
679
|
-
code: "JOBS_SHUTDOWN_FAILED",
|
|
680
|
-
message: "Failed to shutdown jobs instance",
|
|
681
|
-
statusCode: 500,
|
|
682
|
-
cause: error instanceof Error ? error : void 0
|
|
683
|
-
});
|
|
684
313
|
}
|
|
314
|
+
return input;
|
|
685
315
|
}
|
|
686
316
|
};
|
|
687
317
|
|
|
688
|
-
// src/
|
|
689
|
-
var
|
|
690
|
-
constructor(state) {
|
|
691
|
-
this.state = state;
|
|
692
|
-
}
|
|
318
|
+
// src/core/igniter-jobs.ts
|
|
319
|
+
var IgniterJobs = class {
|
|
693
320
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
* @returns A new IgniterJobsBuilder instance
|
|
321
|
+
* Starts the fluent builder API for jobs.
|
|
697
322
|
*
|
|
698
323
|
* @example
|
|
699
324
|
* ```typescript
|
|
700
325
|
* const jobs = IgniterJobs.create<AppContext>()
|
|
701
|
-
* .withAdapter(
|
|
326
|
+
* .withAdapter(IgniterJobsMemoryAdapter.create())
|
|
327
|
+
* .withService('my-api')
|
|
328
|
+
* .withEnvironment('test')
|
|
329
|
+
* .withContext(async () => ({ db }))
|
|
330
|
+
* .addQueue(emailQueue)
|
|
702
331
|
* .build()
|
|
703
332
|
* ```
|
|
704
333
|
*/
|
|
705
334
|
static create() {
|
|
706
|
-
return
|
|
707
|
-
queues: {}
|
|
708
|
-
});
|
|
335
|
+
return IgniterJobsBuilder.create();
|
|
709
336
|
}
|
|
710
337
|
/**
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
* @param adapter - The queue adapter (e.g., BullMQAdapter)
|
|
714
|
-
* @returns The builder with adapter configured
|
|
715
|
-
*
|
|
716
|
-
* @example
|
|
717
|
-
* ```typescript
|
|
718
|
-
* .withAdapter(BullMQAdapter.create({ redis }))
|
|
719
|
-
* ```
|
|
338
|
+
* Creates a runtime instance from a validated configuration.
|
|
720
339
|
*/
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
adapter
|
|
725
|
-
});
|
|
340
|
+
static fromConfig(config) {
|
|
341
|
+
const internal = createInternalState(config);
|
|
342
|
+
return createRuntime(internal, void 0);
|
|
726
343
|
}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
* cache: redis,
|
|
740
|
-
* }))
|
|
741
|
-
* ```
|
|
742
|
-
*/
|
|
743
|
-
withContext(contextFn) {
|
|
744
|
-
return new _IgniterJobsBuilder({
|
|
745
|
-
...this.state,
|
|
746
|
-
context: contextFn
|
|
747
|
-
});
|
|
344
|
+
};
|
|
345
|
+
function createInternalState(config) {
|
|
346
|
+
return {
|
|
347
|
+
config,
|
|
348
|
+
adapter: config.adapter,
|
|
349
|
+
registered: false
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
function createRuntime(internal, boundScope) {
|
|
353
|
+
if (!internal.registered) {
|
|
354
|
+
registerAll(internal.config, internal.adapter);
|
|
355
|
+
internal.registered = true;
|
|
748
356
|
}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
357
|
+
const baseChannel = IgniterJobsPrefix.buildEventsChannel({
|
|
358
|
+
service: internal.config.service,
|
|
359
|
+
environment: internal.config.environment
|
|
360
|
+
});
|
|
361
|
+
const scopeChannel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
|
|
362
|
+
service: internal.config.service,
|
|
363
|
+
environment: internal.config.environment,
|
|
364
|
+
scope: { type: boundScope.type, id: boundScope.id }
|
|
365
|
+
}) : void 0;
|
|
366
|
+
const runtime = {
|
|
367
|
+
config: internal.config,
|
|
368
|
+
async subscribe(handler) {
|
|
369
|
+
const channel = boundScope ? scopeChannel : baseChannel;
|
|
370
|
+
return internal.adapter.subscribeEvent(channel, async (payload) => {
|
|
371
|
+
await handler(payload);
|
|
372
|
+
});
|
|
373
|
+
},
|
|
374
|
+
async search(target, filter) {
|
|
375
|
+
switch (target) {
|
|
376
|
+
case "jobs":
|
|
377
|
+
return internal.adapter.searchJobs(filter);
|
|
378
|
+
case "queues":
|
|
379
|
+
return internal.adapter.searchQueues(filter);
|
|
380
|
+
case "workers":
|
|
381
|
+
return internal.adapter.searchWorkers(filter);
|
|
382
|
+
default:
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async shutdown() {
|
|
387
|
+
await internal.adapter.shutdown();
|
|
388
|
+
},
|
|
389
|
+
worker: {
|
|
390
|
+
create: () => new IgniterWorkerBuilder({
|
|
391
|
+
adapter: internal.adapter,
|
|
392
|
+
allowedQueues: Object.keys(internal.config.queues)
|
|
393
|
+
})
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
if (internal.config.scopeDefinition) {
|
|
397
|
+
runtime.scope = (type, id, tags) => {
|
|
398
|
+
const scope = { type, id, tags };
|
|
399
|
+
return createRuntime(internal, scope);
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
for (const [queueName, queueConfig] of Object.entries(
|
|
403
|
+
internal.config.queues
|
|
404
|
+
)) {
|
|
405
|
+
runtime[queueName] = createQueueAccessor({
|
|
406
|
+
internal,
|
|
407
|
+
boundScope,
|
|
408
|
+
queueName,
|
|
409
|
+
queueConfig
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return runtime;
|
|
413
|
+
}
|
|
414
|
+
function registerAll(config, adapter) {
|
|
415
|
+
for (const [queueName, queue] of Object.entries(
|
|
416
|
+
config.queues
|
|
417
|
+
)) {
|
|
418
|
+
for (const [jobName, def] of Object.entries(
|
|
419
|
+
queue.jobs
|
|
420
|
+
)) {
|
|
421
|
+
adapter.registerJob(
|
|
422
|
+
queueName,
|
|
423
|
+
jobName,
|
|
424
|
+
wrapJobDefinition({
|
|
425
|
+
config,
|
|
426
|
+
adapter,
|
|
427
|
+
queueName,
|
|
428
|
+
jobName,
|
|
429
|
+
definition: def
|
|
430
|
+
})
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
for (const [cronName, cron] of Object.entries(
|
|
434
|
+
queue.crons
|
|
435
|
+
)) {
|
|
436
|
+
adapter.registerCron(
|
|
437
|
+
queueName,
|
|
438
|
+
cronName,
|
|
439
|
+
wrapCronDefinition({
|
|
440
|
+
config,
|
|
441
|
+
adapter,
|
|
442
|
+
queueName,
|
|
443
|
+
cronName,
|
|
444
|
+
definition: cron
|
|
445
|
+
})
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function wrapCronDefinition(params) {
|
|
451
|
+
const { config, adapter, queueName, cronName, definition } = params;
|
|
452
|
+
const buildExecutionContext = async (ctx) => {
|
|
453
|
+
const realContext = await config.contextFactory();
|
|
454
|
+
const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
|
|
455
|
+
ctx.job?.metadata
|
|
456
|
+
);
|
|
457
|
+
return {
|
|
458
|
+
...ctx,
|
|
459
|
+
context: realContext,
|
|
460
|
+
job: { ...ctx.job, name: cronName, queue: queueName },
|
|
461
|
+
scope
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
const publishLifecycle = async (event, ctx, data) => {
|
|
465
|
+
const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
|
|
466
|
+
ctx.job?.metadata
|
|
467
|
+
);
|
|
468
|
+
await IgniterJobsEventsUtils.publishJobsEvent({
|
|
469
|
+
adapter,
|
|
470
|
+
service: config.service,
|
|
471
|
+
environment: config.environment,
|
|
472
|
+
scope,
|
|
473
|
+
event: {
|
|
474
|
+
type: IgniterJobsEventsUtils.buildJobEventType(
|
|
475
|
+
queueName,
|
|
476
|
+
cronName,
|
|
477
|
+
event
|
|
478
|
+
),
|
|
479
|
+
data,
|
|
480
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
481
|
+
scope
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
};
|
|
485
|
+
return {
|
|
486
|
+
...definition,
|
|
487
|
+
handler: async (ctx) => {
|
|
488
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
489
|
+
await publishLifecycle("started", enhanced, {
|
|
490
|
+
jobId: enhanced.job?.id,
|
|
491
|
+
jobName: cronName,
|
|
492
|
+
queue: queueName,
|
|
493
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
494
|
+
});
|
|
495
|
+
try {
|
|
496
|
+
const result = await definition.handler(enhanced);
|
|
497
|
+
await publishLifecycle("completed", enhanced, {
|
|
498
|
+
jobId: enhanced.job?.id,
|
|
499
|
+
jobName: cronName,
|
|
500
|
+
queue: queueName,
|
|
501
|
+
result,
|
|
502
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
503
|
+
});
|
|
504
|
+
return result;
|
|
505
|
+
} catch (error) {
|
|
506
|
+
await publishLifecycle("failed", enhanced, {
|
|
507
|
+
jobId: enhanced.job?.id,
|
|
508
|
+
jobName: cronName,
|
|
509
|
+
queue: queueName,
|
|
510
|
+
error: { message: error?.message ?? String(error) },
|
|
511
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
512
|
+
});
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
function wrapJobDefinition(params) {
|
|
519
|
+
const { config, queueName, jobName, definition } = params;
|
|
520
|
+
const buildExecutionContext = async (ctx) => {
|
|
521
|
+
const realContext = await config.contextFactory();
|
|
522
|
+
const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(ctx.job.metadata);
|
|
523
|
+
return {
|
|
524
|
+
...ctx,
|
|
525
|
+
context: realContext,
|
|
526
|
+
job: { ...ctx.job, name: jobName, queue: queueName },
|
|
527
|
+
scope
|
|
528
|
+
};
|
|
529
|
+
};
|
|
530
|
+
const publishLifecycle = async (event, ctx, data) => {
|
|
531
|
+
const scope = ctx.scope ?? IgniterJobsScopeUtils.extractScopeFromMetadata(
|
|
532
|
+
ctx.job?.metadata
|
|
533
|
+
);
|
|
534
|
+
await IgniterJobsEventsUtils.publishJobsEvent({
|
|
535
|
+
adapter: params.adapter,
|
|
536
|
+
service: config.service,
|
|
537
|
+
environment: config.environment,
|
|
538
|
+
scope,
|
|
539
|
+
event: {
|
|
540
|
+
type: IgniterJobsEventsUtils.buildJobEventType(
|
|
541
|
+
queueName,
|
|
542
|
+
jobName,
|
|
543
|
+
event
|
|
544
|
+
),
|
|
545
|
+
data,
|
|
546
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
547
|
+
scope
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
};
|
|
551
|
+
return {
|
|
552
|
+
...definition,
|
|
553
|
+
handler: async (ctx) => {
|
|
554
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
555
|
+
if (definition.input) {
|
|
556
|
+
const validated = await IgniterJobsValidationUtils.validateInput(
|
|
557
|
+
definition.input,
|
|
558
|
+
enhanced.input
|
|
559
|
+
);
|
|
560
|
+
enhanced.input = validated;
|
|
561
|
+
}
|
|
562
|
+
return definition.handler(enhanced);
|
|
563
|
+
},
|
|
564
|
+
onStart: async (ctx) => {
|
|
565
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
566
|
+
await publishLifecycle("started", enhanced, {
|
|
567
|
+
jobId: enhanced.job.id,
|
|
568
|
+
jobName,
|
|
569
|
+
queue: queueName,
|
|
570
|
+
attemptsMade: enhanced.job.attemptsMade,
|
|
571
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
572
|
+
});
|
|
573
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
574
|
+
config.telemetry,
|
|
575
|
+
"igniter.jobs.job.started",
|
|
576
|
+
{
|
|
577
|
+
"ctx.job.id": enhanced.job.id,
|
|
578
|
+
"ctx.job.name": jobName,
|
|
579
|
+
"ctx.job.queue": queueName,
|
|
580
|
+
"ctx.job.attempt": enhanced.job.attemptsMade,
|
|
581
|
+
"ctx.job.maxAttempts": definition.attempts ?? 3
|
|
582
|
+
}
|
|
583
|
+
);
|
|
584
|
+
await definition.onStart?.(enhanced);
|
|
585
|
+
},
|
|
586
|
+
onSuccess: async (ctx) => {
|
|
587
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
588
|
+
const duration = ctx.duration ?? ctx.executionTime ?? 0;
|
|
589
|
+
await publishLifecycle(
|
|
590
|
+
"completed",
|
|
591
|
+
{ ...enhanced},
|
|
592
|
+
{
|
|
593
|
+
jobId: enhanced.job.id,
|
|
594
|
+
jobName,
|
|
595
|
+
queue: queueName,
|
|
596
|
+
result: ctx.result,
|
|
597
|
+
duration,
|
|
598
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
599
|
+
}
|
|
600
|
+
);
|
|
601
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
602
|
+
config.telemetry,
|
|
603
|
+
"igniter.jobs.job.completed",
|
|
604
|
+
{
|
|
605
|
+
"ctx.job.id": enhanced.job.id,
|
|
606
|
+
"ctx.job.name": jobName,
|
|
607
|
+
"ctx.job.queue": queueName,
|
|
608
|
+
"ctx.job.duration": typeof duration === "number" ? duration : 0
|
|
609
|
+
}
|
|
610
|
+
);
|
|
611
|
+
await definition.onSuccess?.(enhanced);
|
|
612
|
+
},
|
|
613
|
+
onFailure: async (ctx) => {
|
|
614
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
615
|
+
const duration = ctx.duration ?? ctx.executionTime ?? 0;
|
|
616
|
+
const isFinalAttempt = Boolean(ctx.isFinalAttempt);
|
|
617
|
+
const errorMessage = ctx.error?.message ?? String(ctx.error);
|
|
618
|
+
const errorCode = ctx.error?.code;
|
|
619
|
+
const maxAttempts = definition.attempts ?? 3;
|
|
620
|
+
await publishLifecycle(
|
|
621
|
+
"failed",
|
|
622
|
+
{ ...enhanced},
|
|
623
|
+
{
|
|
624
|
+
jobId: enhanced.job.id,
|
|
625
|
+
jobName,
|
|
626
|
+
queue: queueName,
|
|
627
|
+
error: { message: errorMessage },
|
|
628
|
+
attemptsMade: enhanced.job.attemptsMade,
|
|
629
|
+
isFinalAttempt,
|
|
630
|
+
duration,
|
|
631
|
+
failedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
632
|
+
}
|
|
633
|
+
);
|
|
634
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
635
|
+
config.telemetry,
|
|
636
|
+
"igniter.jobs.job.failed",
|
|
637
|
+
{
|
|
638
|
+
"ctx.job.id": enhanced.job.id,
|
|
639
|
+
"ctx.job.name": jobName,
|
|
640
|
+
"ctx.job.queue": queueName,
|
|
641
|
+
"ctx.job.error.message": errorMessage,
|
|
642
|
+
"ctx.job.error.code": errorCode ?? null,
|
|
643
|
+
"ctx.job.attempt": enhanced.job.attemptsMade,
|
|
644
|
+
"ctx.job.maxAttempts": maxAttempts,
|
|
645
|
+
"ctx.job.isFinalAttempt": isFinalAttempt
|
|
646
|
+
},
|
|
647
|
+
"error"
|
|
648
|
+
);
|
|
649
|
+
await definition.onFailure?.(enhanced);
|
|
650
|
+
},
|
|
651
|
+
onProgress: definition.onProgress ? async (ctx) => {
|
|
652
|
+
const enhanced = await buildExecutionContext(ctx);
|
|
653
|
+
const progress = ctx.progress ?? 0;
|
|
654
|
+
const message = ctx.message;
|
|
655
|
+
await publishLifecycle("progress", enhanced, {
|
|
656
|
+
jobId: enhanced.job.id,
|
|
657
|
+
jobName,
|
|
658
|
+
queue: queueName,
|
|
659
|
+
progress,
|
|
660
|
+
message,
|
|
661
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
662
|
+
});
|
|
663
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
664
|
+
config.telemetry,
|
|
665
|
+
"igniter.jobs.job.progress",
|
|
666
|
+
{
|
|
667
|
+
"ctx.job.id": enhanced.job.id,
|
|
668
|
+
"ctx.job.name": jobName,
|
|
669
|
+
"ctx.job.queue": queueName,
|
|
670
|
+
"ctx.job.progress": typeof progress === "number" ? progress : 0,
|
|
671
|
+
"ctx.job.progress.message": message ?? null
|
|
672
|
+
}
|
|
673
|
+
);
|
|
674
|
+
await definition.onProgress?.(enhanced);
|
|
675
|
+
} : void 0
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
function createQueueAccessor(params) {
|
|
679
|
+
const { internal, boundScope, queueName, queueConfig } = params;
|
|
680
|
+
const queueAccessor = {
|
|
681
|
+
async list(filter) {
|
|
682
|
+
return internal.adapter.queues.getJobs(queueName, filter);
|
|
683
|
+
},
|
|
684
|
+
get() {
|
|
685
|
+
return {
|
|
686
|
+
retrieve: () => internal.adapter.getQueueInfo(queueName),
|
|
687
|
+
pause: () => internal.adapter.pauseQueue(queueName),
|
|
688
|
+
resume: () => internal.adapter.resumeQueue(queueName),
|
|
689
|
+
drain: () => internal.adapter.drainQueue(queueName),
|
|
690
|
+
clean: (options) => internal.adapter.cleanQueue(queueName, options),
|
|
691
|
+
obliterate: (options) => internal.adapter.obliterateQueue(queueName, options),
|
|
692
|
+
retryAll: () => internal.adapter.retryAllInQueue(queueName)
|
|
693
|
+
};
|
|
694
|
+
},
|
|
695
|
+
async subscribe(handler) {
|
|
696
|
+
const channel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
|
|
697
|
+
service: internal.config.service,
|
|
698
|
+
environment: internal.config.environment,
|
|
699
|
+
scope: { type: boundScope.type, id: boundScope.id }
|
|
700
|
+
}) : IgniterJobsPrefix.buildEventsChannel({
|
|
701
|
+
service: internal.config.service,
|
|
702
|
+
environment: internal.config.environment
|
|
703
|
+
});
|
|
704
|
+
return internal.adapter.subscribeEvent(channel, async (event) => {
|
|
705
|
+
const typed = event;
|
|
706
|
+
if (typeof typed?.type === "string" && typed.type.startsWith(`${queueName}:`)) {
|
|
707
|
+
await handler(typed);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
},
|
|
711
|
+
jobs: {}
|
|
712
|
+
};
|
|
713
|
+
for (const jobName of Object.keys(queueConfig.jobs)) {
|
|
714
|
+
queueAccessor.jobs[jobName] = createJobAccessor({
|
|
715
|
+
internal,
|
|
716
|
+
boundScope,
|
|
717
|
+
queueName,
|
|
718
|
+
jobName
|
|
719
|
+
});
|
|
720
|
+
queueAccessor[jobName] = queueAccessor.jobs[jobName];
|
|
721
|
+
}
|
|
722
|
+
return queueAccessor;
|
|
723
|
+
}
|
|
724
|
+
function createJobAccessor(params) {
|
|
725
|
+
const { internal, boundScope, queueName, jobName } = params;
|
|
726
|
+
const resolveScope = (paramsScope) => {
|
|
727
|
+
if (!internal.config.scopeDefinition) return void 0;
|
|
728
|
+
const required = Object.values(
|
|
729
|
+
internal.config.scopeDefinition
|
|
730
|
+
)[0]?.required ?? false;
|
|
731
|
+
const effective = boundScope ?? paramsScope;
|
|
732
|
+
if (required && !effective) {
|
|
769
733
|
throw new IgniterJobsError({
|
|
770
|
-
code: "
|
|
771
|
-
message:
|
|
772
|
-
|
|
773
|
-
|
|
734
|
+
code: "JOBS_CONFIGURATION_INVALID",
|
|
735
|
+
message: "Scope is required for this jobs instance."
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
if (boundScope && paramsScope) {
|
|
739
|
+
if (boundScope.type !== paramsScope.type || boundScope.id !== paramsScope.id) {
|
|
740
|
+
throw new IgniterJobsError({
|
|
741
|
+
code: "JOBS_CONFIGURATION_INVALID",
|
|
742
|
+
message: "Cannot override scope on a scoped jobs instance."
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return effective;
|
|
747
|
+
};
|
|
748
|
+
const publish = async (event) => {
|
|
749
|
+
await IgniterJobsEventsUtils.publishJobsEvent({
|
|
750
|
+
adapter: internal.adapter,
|
|
751
|
+
service: internal.config.service,
|
|
752
|
+
environment: internal.config.environment,
|
|
753
|
+
scope: event.scope,
|
|
754
|
+
event
|
|
755
|
+
});
|
|
756
|
+
};
|
|
757
|
+
const getDefinition = () => {
|
|
758
|
+
const q = internal.config.queues[queueName];
|
|
759
|
+
return q?.jobs?.[jobName];
|
|
760
|
+
};
|
|
761
|
+
return {
|
|
762
|
+
async dispatch(params2) {
|
|
763
|
+
const definition = getDefinition();
|
|
764
|
+
if (definition?.input) {
|
|
765
|
+
await IgniterJobsValidationUtils.validateInput(
|
|
766
|
+
definition.input,
|
|
767
|
+
params2.input
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
const scope = resolveScope(params2.scope);
|
|
771
|
+
const metadata = IgniterJobsScopeUtils.mergeMetadataWithScope(
|
|
772
|
+
params2.metadata,
|
|
773
|
+
scope
|
|
774
|
+
);
|
|
775
|
+
const jobId = await internal.adapter.dispatch({
|
|
776
|
+
queue: queueName,
|
|
777
|
+
jobName,
|
|
778
|
+
...params2,
|
|
779
|
+
scope,
|
|
780
|
+
metadata
|
|
781
|
+
});
|
|
782
|
+
await publish({
|
|
783
|
+
type: IgniterJobsEventsUtils.buildJobEventType(
|
|
784
|
+
queueName,
|
|
785
|
+
jobName,
|
|
786
|
+
"enqueued"
|
|
787
|
+
),
|
|
788
|
+
data: { jobId, queue: queueName, jobName },
|
|
789
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
790
|
+
scope
|
|
791
|
+
});
|
|
792
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
793
|
+
internal.config.telemetry,
|
|
794
|
+
"igniter.jobs.job.enqueued",
|
|
795
|
+
{
|
|
796
|
+
"ctx.job.id": jobId,
|
|
797
|
+
"ctx.job.name": jobName,
|
|
798
|
+
"ctx.job.queue": queueName,
|
|
799
|
+
"ctx.job.priority": params2.priority ?? null,
|
|
800
|
+
"ctx.job.delay": params2.delay ?? null
|
|
801
|
+
}
|
|
802
|
+
);
|
|
803
|
+
return jobId;
|
|
804
|
+
},
|
|
805
|
+
async schedule(params2) {
|
|
806
|
+
const definition = getDefinition();
|
|
807
|
+
if (definition?.input) {
|
|
808
|
+
await IgniterJobsValidationUtils.validateInput(
|
|
809
|
+
definition.input,
|
|
810
|
+
params2.input
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const scope = resolveScope(params2.scope);
|
|
814
|
+
const metadata = IgniterJobsScopeUtils.mergeMetadataWithScope(
|
|
815
|
+
params2.metadata,
|
|
816
|
+
scope
|
|
817
|
+
);
|
|
818
|
+
const jobId = await internal.adapter.schedule({
|
|
819
|
+
queue: queueName,
|
|
820
|
+
jobName,
|
|
821
|
+
...params2,
|
|
822
|
+
scope,
|
|
823
|
+
metadata
|
|
824
|
+
});
|
|
825
|
+
await publish({
|
|
826
|
+
type: IgniterJobsEventsUtils.buildJobEventType(
|
|
827
|
+
queueName,
|
|
828
|
+
jobName,
|
|
829
|
+
"scheduled"
|
|
830
|
+
),
|
|
831
|
+
data: { jobId, queue: queueName, jobName },
|
|
832
|
+
timestamp: /* @__PURE__ */ new Date(),
|
|
833
|
+
scope
|
|
834
|
+
});
|
|
835
|
+
IgniterJobsTelemetryUtils.emitTelemetry(
|
|
836
|
+
internal.config.telemetry,
|
|
837
|
+
"igniter.jobs.job.scheduled",
|
|
838
|
+
{
|
|
839
|
+
"ctx.job.id": jobId,
|
|
840
|
+
"ctx.job.name": jobName,
|
|
841
|
+
"ctx.job.queue": queueName,
|
|
842
|
+
"ctx.job.scheduledAt": params2.runAt?.toISOString?.() ?? null,
|
|
843
|
+
"ctx.job.cron": params2.cron ?? null
|
|
844
|
+
}
|
|
845
|
+
);
|
|
846
|
+
return jobId;
|
|
847
|
+
},
|
|
848
|
+
get(id) {
|
|
849
|
+
return {
|
|
850
|
+
retrieve: () => internal.adapter.getJob(id, queueName),
|
|
851
|
+
retry: () => internal.adapter.retryJob(id, queueName),
|
|
852
|
+
remove: () => internal.adapter.removeJob(id, queueName),
|
|
853
|
+
promote: () => internal.adapter.promoteJob(id, queueName),
|
|
854
|
+
move: (state, reason) => {
|
|
855
|
+
if (state !== "failed") return Promise.resolve();
|
|
856
|
+
return internal.adapter.moveJobToFailed(id, reason, queueName);
|
|
857
|
+
},
|
|
858
|
+
state: () => internal.adapter.getJobState(id, queueName),
|
|
859
|
+
progress: () => internal.adapter.getJobProgress(id, queueName),
|
|
860
|
+
logs: () => internal.adapter.getJobLogs(id, queueName)
|
|
861
|
+
};
|
|
862
|
+
},
|
|
863
|
+
many(ids) {
|
|
864
|
+
return {
|
|
865
|
+
retry: () => internal.adapter.retryManyJobs(ids, queueName),
|
|
866
|
+
remove: () => internal.adapter.removeManyJobs(ids, queueName)
|
|
867
|
+
};
|
|
868
|
+
},
|
|
869
|
+
pause: () => internal.adapter.pauseJobType(queueName, jobName),
|
|
870
|
+
resume: () => internal.adapter.resumeJobType(queueName, jobName),
|
|
871
|
+
async subscribe(handler) {
|
|
872
|
+
const channel = boundScope ? IgniterJobsPrefix.buildEventsChannel({
|
|
873
|
+
service: internal.config.service,
|
|
874
|
+
environment: internal.config.environment,
|
|
875
|
+
scope: { type: boundScope.type, id: boundScope.id }
|
|
876
|
+
}) : IgniterJobsPrefix.buildEventsChannel({
|
|
877
|
+
service: internal.config.service,
|
|
878
|
+
environment: internal.config.environment
|
|
879
|
+
});
|
|
880
|
+
return internal.adapter.subscribeEvent(channel, async (event) => {
|
|
881
|
+
const typed = event;
|
|
882
|
+
if (typeof typed?.type === "string" && typed.type.startsWith(`${queueName}:${jobName}:`)) {
|
|
883
|
+
await handler(typed);
|
|
884
|
+
}
|
|
774
885
|
});
|
|
775
886
|
}
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// src/builders/igniter-jobs.builder.ts
|
|
891
|
+
var IgniterJobsBuilder = class _IgniterJobsBuilder {
|
|
892
|
+
constructor(state) {
|
|
893
|
+
this.state = {
|
|
894
|
+
queues: state?.queues ?? {},
|
|
895
|
+
...state
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Creates the initial builder with no configuration.
|
|
900
|
+
*/
|
|
901
|
+
static create() {
|
|
902
|
+
return new _IgniterJobsBuilder({ queues: {} });
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Returns a new builder with updated state while preserving generics.
|
|
906
|
+
*/
|
|
907
|
+
clone(patch) {
|
|
776
908
|
return new _IgniterJobsBuilder({
|
|
777
909
|
...this.state,
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
}
|
|
910
|
+
...patch,
|
|
911
|
+
queues: patch.queues ?? this.state.queues,
|
|
912
|
+
scope: patch.scope ?? this.state.scope
|
|
782
913
|
});
|
|
783
914
|
}
|
|
784
915
|
/**
|
|
785
|
-
*
|
|
786
|
-
* Scopes are used to isolate jobs by organization, tenant, etc.
|
|
916
|
+
* Attaches the jobs adapter.
|
|
787
917
|
*
|
|
788
|
-
* @param
|
|
789
|
-
|
|
790
|
-
|
|
918
|
+
* @param adapter - Backend adapter implementation (BullMQ, memory, etc.).
|
|
919
|
+
*/
|
|
920
|
+
withAdapter(adapter) {
|
|
921
|
+
return this.clone({ adapter });
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Sets the service identifier for telemetry and metrics.
|
|
791
925
|
*
|
|
792
|
-
* @
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
926
|
+
* @param service - Service name (e.g., "my-api").
|
|
927
|
+
*/
|
|
928
|
+
withService(service) {
|
|
929
|
+
return this.clone({ service });
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Sets the environment name (e.g., development, staging, production).
|
|
933
|
+
*/
|
|
934
|
+
withEnvironment(environment) {
|
|
935
|
+
return this.clone({ environment });
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Provides a context factory used when executing jobs.
|
|
939
|
+
*/
|
|
940
|
+
withContext(factory) {
|
|
941
|
+
return this.clone({ contextFactory: factory });
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* Adds a scope definition (single scope supported).
|
|
796
945
|
*/
|
|
797
|
-
addScope(
|
|
946
|
+
addScope(name, options) {
|
|
798
947
|
if (this.state.scope) {
|
|
799
948
|
throw new IgniterJobsError({
|
|
800
949
|
code: "JOBS_SCOPE_ALREADY_DEFINED",
|
|
801
|
-
message:
|
|
802
|
-
statusCode: 400,
|
|
803
|
-
details: { existingScope: this.state.scope.key, newScope: key }
|
|
950
|
+
message: "Only one scope can be defined for IgniterJobs."
|
|
804
951
|
});
|
|
805
952
|
}
|
|
806
|
-
return
|
|
807
|
-
...this.state,
|
|
808
|
-
scope: { key, options }
|
|
809
|
-
});
|
|
953
|
+
return this.clone({ scope: { name, options } });
|
|
810
954
|
}
|
|
811
955
|
/**
|
|
812
|
-
*
|
|
813
|
-
* Actors are used to track who initiated jobs.
|
|
814
|
-
*
|
|
815
|
-
* @param key - Actor key (e.g., 'user', 'system')
|
|
816
|
-
* @param options - Optional actor configuration
|
|
817
|
-
* @returns The builder with actor configured
|
|
818
|
-
*
|
|
819
|
-
* @example
|
|
820
|
-
* ```typescript
|
|
821
|
-
* .addActor('user', { description: 'The user who initiated the job' })
|
|
822
|
-
* ```
|
|
956
|
+
* Registers a queue definition on the builder.
|
|
823
957
|
*/
|
|
824
|
-
|
|
825
|
-
if (this.state.
|
|
958
|
+
addQueue(queue) {
|
|
959
|
+
if (this.state.queues[queue.name]) {
|
|
826
960
|
throw new IgniterJobsError({
|
|
827
|
-
code: "
|
|
828
|
-
message: `
|
|
829
|
-
statusCode: 400,
|
|
830
|
-
details: { existingActor: this.state.actor.key, newActor: key }
|
|
961
|
+
code: "JOBS_QUEUE_DUPLICATE",
|
|
962
|
+
message: `Queue "${queue.name}" is already registered.`
|
|
831
963
|
});
|
|
832
964
|
}
|
|
833
|
-
|
|
834
|
-
...this.state,
|
|
835
|
-
|
|
836
|
-
}
|
|
965
|
+
const nextQueues = {
|
|
966
|
+
...this.state.queues,
|
|
967
|
+
[queue.name]: queue
|
|
968
|
+
};
|
|
969
|
+
return this.clone({ queues: nextQueues });
|
|
837
970
|
}
|
|
838
971
|
/**
|
|
839
|
-
*
|
|
840
|
-
*
|
|
841
|
-
* @param telemetry - IgniterTelemetry instance
|
|
842
|
-
* @returns The builder with telemetry configured
|
|
843
|
-
*
|
|
844
|
-
* @example
|
|
845
|
-
* ```typescript
|
|
846
|
-
* .withTelemetry(telemetry)
|
|
847
|
-
* ```
|
|
972
|
+
* Applies default job options to all queues.
|
|
848
973
|
*/
|
|
849
|
-
|
|
850
|
-
return
|
|
851
|
-
...this.state,
|
|
852
|
-
telemetry
|
|
853
|
-
});
|
|
974
|
+
withQueueDefaults(defaults) {
|
|
975
|
+
return this.clone({ queueDefaults: defaults });
|
|
854
976
|
}
|
|
855
977
|
/**
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
* @param logger - Logger instance
|
|
859
|
-
* @returns The builder with logger configured
|
|
860
|
-
*
|
|
861
|
-
* @example
|
|
862
|
-
* ```typescript
|
|
863
|
-
* .withLogger(customLogger)
|
|
864
|
-
* ```
|
|
978
|
+
* Applies default worker options.
|
|
865
979
|
*/
|
|
866
|
-
|
|
867
|
-
return
|
|
868
|
-
...this.state,
|
|
869
|
-
logger
|
|
870
|
-
});
|
|
980
|
+
withWorkerDefaults(defaults) {
|
|
981
|
+
return this.clone({ workerDefaults: defaults });
|
|
871
982
|
}
|
|
872
983
|
/**
|
|
873
|
-
*
|
|
874
|
-
*
|
|
875
|
-
* @param defaults - Default job configuration
|
|
876
|
-
* @returns The builder with defaults configured
|
|
877
|
-
*
|
|
878
|
-
* @example
|
|
879
|
-
* ```typescript
|
|
880
|
-
* .withDefaults({
|
|
881
|
-
* retry: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
|
882
|
-
* timeout: 30000,
|
|
883
|
-
* removeOnComplete: { count: 1000 },
|
|
884
|
-
* })
|
|
885
|
-
* ```
|
|
984
|
+
* Configures automatic worker startup.
|
|
886
985
|
*/
|
|
887
|
-
|
|
888
|
-
return
|
|
889
|
-
...this.state,
|
|
890
|
-
defaults
|
|
891
|
-
});
|
|
986
|
+
withAutoStartWorker(config) {
|
|
987
|
+
return this.clone({ autoStartWorker: config });
|
|
892
988
|
}
|
|
893
989
|
/**
|
|
894
|
-
*
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
*
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
*
|
|
907
|
-
* .withContext(() => context)
|
|
908
|
-
* .addQueue(emailQueue)
|
|
909
|
-
* .build()
|
|
910
|
-
*
|
|
911
|
-
* // Now use with full type safety
|
|
912
|
-
* await jobs.email.sendWelcome.dispatch({ userId: '123' })
|
|
913
|
-
* ```
|
|
990
|
+
* Attaches telemetry support.
|
|
991
|
+
*/
|
|
992
|
+
withTelemetry(telemetry) {
|
|
993
|
+
return this.clone({ telemetry });
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Attaches a custom logger.
|
|
997
|
+
*/
|
|
998
|
+
withLogger(logger) {
|
|
999
|
+
return this.clone({ logger });
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Finalizes the configuration and returns the runtime instance.
|
|
914
1003
|
*/
|
|
915
1004
|
build() {
|
|
916
1005
|
if (!this.state.adapter) {
|
|
917
1006
|
throw new IgniterJobsError({
|
|
918
1007
|
code: "JOBS_ADAPTER_REQUIRED",
|
|
919
|
-
message: "
|
|
920
|
-
statusCode: 400
|
|
1008
|
+
message: "Jobs adapter is required. Call withAdapter() before build()."
|
|
921
1009
|
});
|
|
922
1010
|
}
|
|
923
|
-
if (!this.state.
|
|
1011
|
+
if (!this.state.service) {
|
|
924
1012
|
throw new IgniterJobsError({
|
|
925
|
-
code: "
|
|
926
|
-
message: "
|
|
927
|
-
statusCode: 400
|
|
1013
|
+
code: "JOBS_SERVICE_REQUIRED",
|
|
1014
|
+
message: "Service name is required. Call withService() before build()."
|
|
928
1015
|
});
|
|
929
1016
|
}
|
|
930
|
-
if (
|
|
1017
|
+
if (!this.state.environment) {
|
|
931
1018
|
throw new IgniterJobsError({
|
|
932
|
-
code: "
|
|
933
|
-
message: "
|
|
934
|
-
statusCode: 400
|
|
1019
|
+
code: "JOBS_CONFIGURATION_INVALID",
|
|
1020
|
+
message: "Environment is required. Call withEnvironment() before build()."
|
|
935
1021
|
});
|
|
936
1022
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1023
|
+
if (!this.state.contextFactory) {
|
|
1024
|
+
throw new IgniterJobsError({
|
|
1025
|
+
code: "JOBS_CONTEXT_REQUIRED",
|
|
1026
|
+
message: "Context factory is required. Call withContext() before build()."
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
const config = {
|
|
1030
|
+
adapter: this.state.adapter,
|
|
1031
|
+
service: this.state.service,
|
|
1032
|
+
environment: this.state.environment,
|
|
1033
|
+
contextFactory: this.state.contextFactory,
|
|
1034
|
+
queues: this.state.queues,
|
|
1035
|
+
scopeDefinition: this.state.scope ? {
|
|
1036
|
+
[this.state.scope.name]: this.state.scope.options ?? {}
|
|
1037
|
+
} : void 0,
|
|
1038
|
+
queueDefaults: this.state.queueDefaults,
|
|
1039
|
+
workerDefaults: this.state.workerDefaults,
|
|
1040
|
+
autoStartWorker: this.state.autoStartWorker,
|
|
1041
|
+
logger: this.state.logger,
|
|
1042
|
+
telemetry: this.state.telemetry
|
|
1043
|
+
};
|
|
1044
|
+
return IgniterJobs.fromConfig(config);
|
|
940
1045
|
}
|
|
941
1046
|
};
|
|
942
|
-
var IgniterJobs = {
|
|
943
|
-
create: IgniterJobsBuilder.create
|
|
944
|
-
};
|
|
945
1047
|
|
|
946
1048
|
// src/builders/igniter-queue.builder.ts
|
|
947
|
-
function validateQueueName(name) {
|
|
948
|
-
if (!name || typeof name !== "string") {
|
|
949
|
-
throw new IgniterJobsError({
|
|
950
|
-
code: "JOBS_QUEUE_NAME_INVALID",
|
|
951
|
-
message: "Queue name must be a non-empty string",
|
|
952
|
-
statusCode: 400
|
|
953
|
-
});
|
|
954
|
-
}
|
|
955
|
-
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
|
|
956
|
-
throw new IgniterJobsError({
|
|
957
|
-
code: "JOBS_QUEUE_NAME_INVALID",
|
|
958
|
-
message: `Queue name "${name}" must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens`,
|
|
959
|
-
statusCode: 400,
|
|
960
|
-
details: { name }
|
|
961
|
-
});
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
function validateJobName(name) {
|
|
965
|
-
if (!name || typeof name !== "string") {
|
|
966
|
-
throw new IgniterJobsError({
|
|
967
|
-
code: "JOBS_JOB_NAME_INVALID",
|
|
968
|
-
message: "Job name must be a non-empty string",
|
|
969
|
-
statusCode: 400
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) {
|
|
973
|
-
throw new IgniterJobsError({
|
|
974
|
-
code: "JOBS_JOB_NAME_INVALID",
|
|
975
|
-
message: `Job name "${name}" must start with a letter and contain only letters and numbers (camelCase recommended)`,
|
|
976
|
-
statusCode: 400,
|
|
977
|
-
details: { name }
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
1049
|
var IgniterQueueBuilder = class _IgniterQueueBuilder {
|
|
982
1050
|
constructor(state) {
|
|
983
1051
|
this.state = state;
|
|
984
1052
|
}
|
|
985
1053
|
/**
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
* @param name - Queue name (kebab-case, e.g., 'email', 'payment-processing')
|
|
989
|
-
* @returns A new IgniterQueueBuilder instance
|
|
990
|
-
*
|
|
991
|
-
* @example
|
|
992
|
-
* ```typescript
|
|
993
|
-
* const emailQueue = IgniterQueue.create<AppContext>('email')
|
|
994
|
-
* ```
|
|
1054
|
+
* Creates a new queue builder for the given queue name.
|
|
995
1055
|
*/
|
|
996
1056
|
static create(name) {
|
|
997
|
-
|
|
1057
|
+
if (!name || typeof name !== "string") {
|
|
1058
|
+
throw new IgniterJobsError({
|
|
1059
|
+
code: "JOBS_CONFIGURATION_INVALID",
|
|
1060
|
+
message: "Queue name must be a non-empty string."
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
998
1063
|
return new _IgniterQueueBuilder({
|
|
999
1064
|
name,
|
|
1000
1065
|
jobs: {},
|
|
@@ -1002,127 +1067,1196 @@ var IgniterQueueBuilder = class _IgniterQueueBuilder {
|
|
|
1002
1067
|
});
|
|
1003
1068
|
}
|
|
1004
1069
|
/**
|
|
1005
|
-
*
|
|
1070
|
+
* Re-types this builder with the application context type.
|
|
1006
1071
|
*
|
|
1007
|
-
*
|
|
1008
|
-
* @param definition - Job definition with input schema, handler, retry config, etc.
|
|
1009
|
-
* @returns The builder with the new job added
|
|
1072
|
+
* This is a type-level helper; it does not mutate runtime state.
|
|
1010
1073
|
*
|
|
1011
1074
|
* @example
|
|
1012
1075
|
* ```typescript
|
|
1013
|
-
* .
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
* email: z.string().email(),
|
|
1017
|
-
* }),
|
|
1018
|
-
* retry: { attempts: 3, backoff: { type: 'exponential', delay: 1000 } },
|
|
1019
|
-
* handler: async (ctx) => {
|
|
1020
|
-
* await ctx.context.mailer.send({
|
|
1021
|
-
* to: ctx.input.email,
|
|
1022
|
-
* template: 'welcome',
|
|
1023
|
-
* })
|
|
1024
|
-
* },
|
|
1025
|
-
* })
|
|
1076
|
+
* const queue = IgniterQueue.create('email')
|
|
1077
|
+
* .withContext<AppContext>()
|
|
1078
|
+
* .addJob('send', { handler: async ({ context }) => context.mailer.send() })
|
|
1026
1079
|
* ```
|
|
1027
1080
|
*/
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
code: "JOBS_JOB_ALREADY_EXISTS",
|
|
1033
|
-
message: `Job "${name}" already exists in queue "${this.state.name}"`,
|
|
1034
|
-
statusCode: 400,
|
|
1035
|
-
details: { queue: this.state.name, job: name }
|
|
1036
|
-
});
|
|
1037
|
-
}
|
|
1038
|
-
if (!definition.handler || typeof definition.handler !== "function") {
|
|
1039
|
-
throw new IgniterJobsError({
|
|
1040
|
-
code: "JOBS_JOB_HANDLER_REQUIRED",
|
|
1041
|
-
message: `Job "${name}" must have a handler function`,
|
|
1042
|
-
statusCode: 400,
|
|
1043
|
-
details: { queue: this.state.name, job: name }
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1081
|
+
withContext() {
|
|
1082
|
+
return this;
|
|
1083
|
+
}
|
|
1084
|
+
clone(patch) {
|
|
1046
1085
|
return new _IgniterQueueBuilder({
|
|
1047
1086
|
...this.state,
|
|
1048
|
-
|
|
1049
|
-
...this.state.jobs,
|
|
1050
|
-
[name]: definition
|
|
1051
|
-
}
|
|
1087
|
+
...patch
|
|
1052
1088
|
});
|
|
1053
1089
|
}
|
|
1054
1090
|
/**
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
1057
|
-
* @param name - Cron job name (camelCase)
|
|
1058
|
-
* @param definition - Cron definition with expression, handler, etc.
|
|
1059
|
-
* @returns The builder with the new cron added
|
|
1091
|
+
* Registers a job on the queue.
|
|
1060
1092
|
*
|
|
1061
|
-
* @
|
|
1062
|
-
*
|
|
1063
|
-
* .addCron('cleanupExpired', {
|
|
1064
|
-
* expression: '0 0 * * *', // Every day at midnight
|
|
1065
|
-
* timezone: 'America/New_York',
|
|
1066
|
-
* handler: async (ctx) => {
|
|
1067
|
-
* await ctx.context.db.cleanup.expiredSessions()
|
|
1068
|
-
* },
|
|
1069
|
-
* })
|
|
1070
|
-
* ```
|
|
1093
|
+
* @param jobName - Unique name of the job inside the queue.
|
|
1094
|
+
* @param definition - Job definition (handler, schemas, options, hooks).
|
|
1071
1095
|
*/
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1096
|
+
addJob(jobName, definition) {
|
|
1097
|
+
if (!jobName || typeof jobName !== "string") {
|
|
1098
|
+
throw new IgniterJobsError({
|
|
1099
|
+
code: "JOBS_INVALID_DEFINITION",
|
|
1100
|
+
message: "Job name must be a non-empty string."
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
if (this.state.jobs[jobName]) {
|
|
1104
|
+
throw new IgniterJobsError({
|
|
1105
|
+
code: "JOBS_DUPLICATE_JOB",
|
|
1106
|
+
message: `Job "${jobName}" is already registered in queue "${this.state.name}".`
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
if (this.state.crons[jobName]) {
|
|
1075
1110
|
throw new IgniterJobsError({
|
|
1076
|
-
code: "
|
|
1077
|
-
message: `
|
|
1078
|
-
statusCode: 400,
|
|
1079
|
-
details: { queue: this.state.name, cron: name }
|
|
1111
|
+
code: "JOBS_DUPLICATE_JOB",
|
|
1112
|
+
message: `Job "${jobName}" conflicts with an existing cron in queue "${this.state.name}".`
|
|
1080
1113
|
});
|
|
1081
1114
|
}
|
|
1082
|
-
if (!definition
|
|
1115
|
+
if (!definition || typeof definition !== "object") {
|
|
1083
1116
|
throw new IgniterJobsError({
|
|
1084
|
-
code: "
|
|
1085
|
-
message: `
|
|
1086
|
-
statusCode: 400,
|
|
1087
|
-
details: { queue: this.state.name, cron: name }
|
|
1117
|
+
code: "JOBS_INVALID_DEFINITION",
|
|
1118
|
+
message: `Job "${jobName}" definition must be an object.`
|
|
1088
1119
|
});
|
|
1089
1120
|
}
|
|
1090
1121
|
if (!definition.handler || typeof definition.handler !== "function") {
|
|
1091
1122
|
throw new IgniterJobsError({
|
|
1092
|
-
code: "
|
|
1093
|
-
message: `
|
|
1094
|
-
statusCode: 400,
|
|
1095
|
-
details: { queue: this.state.name, cron: name }
|
|
1123
|
+
code: "JOBS_HANDLER_REQUIRED",
|
|
1124
|
+
message: `Job "${jobName}" handler is required and must be a function.`
|
|
1096
1125
|
});
|
|
1097
1126
|
}
|
|
1098
|
-
|
|
1099
|
-
...this.state,
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1127
|
+
const nextJobs = {
|
|
1128
|
+
...this.state.jobs,
|
|
1129
|
+
[jobName]: definition
|
|
1130
|
+
};
|
|
1131
|
+
return this.clone({
|
|
1132
|
+
jobs: nextJobs
|
|
1104
1133
|
});
|
|
1105
1134
|
}
|
|
1106
1135
|
/**
|
|
1107
|
-
*
|
|
1136
|
+
* Registers a cron task on the queue.
|
|
1108
1137
|
*
|
|
1109
|
-
* @
|
|
1138
|
+
* @param cronName - Unique name of the cron task inside the queue.
|
|
1139
|
+
* @param definition - Cron definition (cron string, tz, handler, options).
|
|
1140
|
+
*/
|
|
1141
|
+
addCron(cronName, definition) {
|
|
1142
|
+
if (!cronName || typeof cronName !== "string") {
|
|
1143
|
+
throw new IgniterJobsError({
|
|
1144
|
+
code: "JOBS_INVALID_CRON",
|
|
1145
|
+
message: "Cron name must be a non-empty string."
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
if (this.state.crons[cronName]) {
|
|
1149
|
+
throw new IgniterJobsError({
|
|
1150
|
+
code: "JOBS_INVALID_CRON",
|
|
1151
|
+
message: `Cron "${cronName}" is already registered in queue "${this.state.name}".`
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
if (this.state.jobs[cronName]) {
|
|
1155
|
+
throw new IgniterJobsError({
|
|
1156
|
+
code: "JOBS_INVALID_CRON",
|
|
1157
|
+
message: `Cron "${cronName}" conflicts with an existing job in queue "${this.state.name}".`
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
if (!definition || typeof definition !== "object") {
|
|
1161
|
+
throw new IgniterJobsError({
|
|
1162
|
+
code: "JOBS_INVALID_CRON",
|
|
1163
|
+
message: `Cron "${cronName}" definition must be an object.`
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
if (!definition.cron || typeof definition.cron !== "string") {
|
|
1167
|
+
throw new IgniterJobsError({
|
|
1168
|
+
code: "JOBS_INVALID_CRON",
|
|
1169
|
+
message: `Cron "${cronName}" must include a valid cron expression string.`
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
if (!definition.handler || typeof definition.handler !== "function") {
|
|
1173
|
+
throw new IgniterJobsError({
|
|
1174
|
+
code: "JOBS_HANDLER_REQUIRED",
|
|
1175
|
+
message: `Cron "${cronName}" handler is required and must be a function.`
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
const nextCrons = {
|
|
1179
|
+
...this.state.crons,
|
|
1180
|
+
[cronName]: definition
|
|
1181
|
+
};
|
|
1182
|
+
return this.clone({
|
|
1183
|
+
crons: nextCrons
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Finalizes the queue definition.
|
|
1188
|
+
*/
|
|
1189
|
+
build() {
|
|
1190
|
+
return {
|
|
1191
|
+
name: this.state.name,
|
|
1192
|
+
jobs: this.state.jobs,
|
|
1193
|
+
crons: this.state.crons
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
// src/core/igniter-queue.ts
|
|
1199
|
+
var IgniterQueue = class {
|
|
1200
|
+
/**
|
|
1201
|
+
* Creates a new queue builder for the given name.
|
|
1110
1202
|
*
|
|
1111
1203
|
* @example
|
|
1112
1204
|
* ```typescript
|
|
1113
|
-
* const
|
|
1114
|
-
* .
|
|
1205
|
+
* const queue = IgniterQueue.create('email')
|
|
1206
|
+
* .withContext<AppContext>()
|
|
1207
|
+
* .addJob('sendWelcome', { handler: async () => {} })
|
|
1115
1208
|
* .build()
|
|
1116
1209
|
* ```
|
|
1117
1210
|
*/
|
|
1118
|
-
|
|
1119
|
-
return
|
|
1211
|
+
static create(name) {
|
|
1212
|
+
return IgniterQueueBuilder.create(name);
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
function toDateArray(values) {
|
|
1216
|
+
if (!values) return void 0;
|
|
1217
|
+
return values.map((v) => v instanceof Date ? v : new Date(v));
|
|
1218
|
+
}
|
|
1219
|
+
var IgniterJobsBullMQAdapter = class _IgniterJobsBullMQAdapter {
|
|
1220
|
+
constructor(options) {
|
|
1221
|
+
this.subscribers = /* @__PURE__ */ new Map();
|
|
1222
|
+
this.coreAdapter = null;
|
|
1223
|
+
this.coreExecutor = null;
|
|
1224
|
+
this.executorDirty = true;
|
|
1225
|
+
this.jobsByQueue = /* @__PURE__ */ new Map();
|
|
1226
|
+
this.cronsByQueue = /* @__PURE__ */ new Map();
|
|
1227
|
+
this.redis = options.redis;
|
|
1228
|
+
this.publisher = this.redis;
|
|
1229
|
+
this.subscriber = this.redis.duplicate();
|
|
1230
|
+
this.client = { redis: this.redis };
|
|
1231
|
+
this.subscriber.on("message", (channel, message) => {
|
|
1232
|
+
const set = this.subscribers.get(channel);
|
|
1233
|
+
if (!set || set.size === 0) return;
|
|
1234
|
+
let payload = message;
|
|
1235
|
+
try {
|
|
1236
|
+
payload = JSON.parse(message);
|
|
1237
|
+
} catch {
|
|
1238
|
+
}
|
|
1239
|
+
for (const handler of set) handler(payload);
|
|
1240
|
+
});
|
|
1241
|
+
this.queues = {
|
|
1242
|
+
list: async () => this.listQueues(),
|
|
1243
|
+
get: async (name) => this.getQueueInfo(name),
|
|
1244
|
+
getJobCounts: async (name) => this.getQueueJobCounts(name),
|
|
1245
|
+
getJobs: async (name, filter) => {
|
|
1246
|
+
const full = this.toCoreQueueName(name);
|
|
1247
|
+
return this.core().queues.getJobs(full, filter);
|
|
1248
|
+
},
|
|
1249
|
+
pause: async (name) => this.pauseQueue(name),
|
|
1250
|
+
resume: async (name) => this.resumeQueue(name),
|
|
1251
|
+
isPaused: async (name) => {
|
|
1252
|
+
const full = this.toCoreQueueName(name);
|
|
1253
|
+
return this.core().queues.isPaused(full);
|
|
1254
|
+
},
|
|
1255
|
+
drain: async (name) => this.drainQueue(name),
|
|
1256
|
+
clean: async (name, options2) => this.cleanQueue(name, options2),
|
|
1257
|
+
obliterate: async (name, options2) => this.obliterateQueue(name, options2)
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
static create(options) {
|
|
1261
|
+
return new _IgniterJobsBullMQAdapter(options);
|
|
1262
|
+
}
|
|
1263
|
+
registerJob(queueName, jobName, definition) {
|
|
1264
|
+
const map = this.jobsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
|
|
1265
|
+
if (map.has(jobName)) {
|
|
1266
|
+
throw new IgniterJobsError({
|
|
1267
|
+
code: "JOBS_DUPLICATE_JOB",
|
|
1268
|
+
message: `Job "${jobName}" is already registered in queue "${queueName}".`
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
map.set(jobName, definition);
|
|
1272
|
+
this.jobsByQueue.set(queueName, map);
|
|
1273
|
+
this.executorDirty = true;
|
|
1274
|
+
}
|
|
1275
|
+
registerCron(queueName, cronName, definition) {
|
|
1276
|
+
const map = this.cronsByQueue.get(queueName) ?? /* @__PURE__ */ new Map();
|
|
1277
|
+
if (map.has(cronName)) {
|
|
1278
|
+
throw new IgniterJobsError({
|
|
1279
|
+
code: "JOBS_INVALID_CRON",
|
|
1280
|
+
message: `Cron "${cronName}" is already registered in queue "${queueName}".`
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
map.set(cronName, definition);
|
|
1284
|
+
this.cronsByQueue.set(queueName, map);
|
|
1285
|
+
this.executorDirty = true;
|
|
1286
|
+
}
|
|
1287
|
+
async dispatch(params) {
|
|
1288
|
+
const executor = await this.executor();
|
|
1289
|
+
const namespace = executor[params.queue];
|
|
1290
|
+
if (!namespace) {
|
|
1291
|
+
throw new IgniterJobsError({
|
|
1292
|
+
code: "JOBS_QUEUE_NOT_FOUND",
|
|
1293
|
+
message: `Queue "${params.queue}" is not registered in the adapter.`
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
return namespace.enqueue({
|
|
1297
|
+
task: params.jobName,
|
|
1298
|
+
input: params.input,
|
|
1299
|
+
jobId: params.jobId,
|
|
1300
|
+
delay: params.delay,
|
|
1301
|
+
priority: params.priority,
|
|
1302
|
+
attempts: params.attempts,
|
|
1303
|
+
metadata: params.metadata,
|
|
1304
|
+
removeOnComplete: params.removeOnComplete,
|
|
1305
|
+
removeOnFail: params.removeOnFail,
|
|
1306
|
+
limiter: params.limiter
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
async schedule(params) {
|
|
1310
|
+
const executor = await this.executor();
|
|
1311
|
+
const namespace = executor[params.queue];
|
|
1312
|
+
if (!namespace) {
|
|
1313
|
+
throw new IgniterJobsError({
|
|
1314
|
+
code: "JOBS_QUEUE_NOT_FOUND",
|
|
1315
|
+
message: `Queue "${params.queue}" is not registered in the adapter.`
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
const schedule = {
|
|
1319
|
+
jobId: params.jobId,
|
|
1320
|
+
delay: params.delay,
|
|
1321
|
+
priority: params.priority,
|
|
1322
|
+
attempts: params.attempts,
|
|
1323
|
+
metadata: params.metadata,
|
|
1324
|
+
removeOnComplete: params.removeOnComplete,
|
|
1325
|
+
removeOnFail: params.removeOnFail,
|
|
1326
|
+
limiter: params.limiter,
|
|
1327
|
+
at: params.at,
|
|
1328
|
+
repeat: params.cron || params.every || params.maxExecutions || params.skipWeekends || params.onlyBusinessHours || params.businessHours || params.onlyWeekdays || params.skipDates ? {
|
|
1329
|
+
cron: params.cron,
|
|
1330
|
+
every: params.every,
|
|
1331
|
+
times: params.maxExecutions,
|
|
1332
|
+
skipWeekends: params.skipWeekends,
|
|
1333
|
+
onlyBusinessHours: params.onlyBusinessHours,
|
|
1334
|
+
businessHours: params.businessHours,
|
|
1335
|
+
onlyWeekdays: params.onlyWeekdays,
|
|
1336
|
+
skipDates: toDateArray(params.skipDates)
|
|
1337
|
+
} : void 0
|
|
1338
|
+
};
|
|
1339
|
+
return namespace.schedule({
|
|
1340
|
+
task: params.jobName,
|
|
1341
|
+
input: params.input,
|
|
1342
|
+
...schedule
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
async getJob(jobId, queue) {
|
|
1346
|
+
const result = await this.core().job.get(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1347
|
+
return result ? this.mapJob(result, queue) : null;
|
|
1348
|
+
}
|
|
1349
|
+
async getJobState(jobId, queue) {
|
|
1350
|
+
const state = await this.core().job.getState(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1351
|
+
return state;
|
|
1352
|
+
}
|
|
1353
|
+
async getJobLogs(jobId, queue) {
|
|
1354
|
+
const logs = await this.core().job.getLogs(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1355
|
+
return logs;
|
|
1356
|
+
}
|
|
1357
|
+
async getJobProgress(jobId, queue) {
|
|
1358
|
+
return this.core().job.getProgress(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1359
|
+
}
|
|
1360
|
+
async retryJob(jobId, queue) {
|
|
1361
|
+
await this.core().job.retry(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1362
|
+
}
|
|
1363
|
+
async removeJob(jobId, queue) {
|
|
1364
|
+
await this.core().job.remove(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1365
|
+
}
|
|
1366
|
+
async promoteJob(jobId, queue) {
|
|
1367
|
+
await this.core().job.promote(jobId, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1368
|
+
}
|
|
1369
|
+
async moveJobToFailed(jobId, reason, queue) {
|
|
1370
|
+
await this.core().job.moveToFailed(jobId, reason, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1371
|
+
}
|
|
1372
|
+
async retryManyJobs(jobIds, queue) {
|
|
1373
|
+
await this.core().job.retryMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1374
|
+
}
|
|
1375
|
+
async removeManyJobs(jobIds, queue) {
|
|
1376
|
+
await this.core().job.removeMany(jobIds, queue ? this.toCoreQueueName(queue) : void 0);
|
|
1377
|
+
}
|
|
1378
|
+
async getQueueInfo(queue) {
|
|
1379
|
+
const info = await this.core().queues.get(this.toCoreQueueName(queue));
|
|
1380
|
+
if (!info) return null;
|
|
1381
|
+
return this.mapQueueInfo(info);
|
|
1382
|
+
}
|
|
1383
|
+
async getQueueJobCounts(queue) {
|
|
1384
|
+
const counts = await this.core().queues.getJobCounts(this.toCoreQueueName(queue));
|
|
1385
|
+
return counts;
|
|
1386
|
+
}
|
|
1387
|
+
async listQueues() {
|
|
1388
|
+
const list = await this.core().queues.list();
|
|
1389
|
+
return list.map((q) => this.mapQueueInfo(q));
|
|
1390
|
+
}
|
|
1391
|
+
async pauseQueue(queue) {
|
|
1392
|
+
await this.core().queues.pause(this.toCoreQueueName(queue));
|
|
1393
|
+
}
|
|
1394
|
+
async resumeQueue(queue) {
|
|
1395
|
+
await this.core().queues.resume(this.toCoreQueueName(queue));
|
|
1396
|
+
}
|
|
1397
|
+
async drainQueue(queue) {
|
|
1398
|
+
return this.core().queues.drain(this.toCoreQueueName(queue));
|
|
1399
|
+
}
|
|
1400
|
+
async cleanQueue(queue, options) {
|
|
1401
|
+
return this.core().queues.clean(this.toCoreQueueName(queue), options);
|
|
1402
|
+
}
|
|
1403
|
+
async obliterateQueue(queue, options) {
|
|
1404
|
+
await this.core().queues.obliterate(this.toCoreQueueName(queue), options);
|
|
1405
|
+
}
|
|
1406
|
+
async retryAllInQueue(queue) {
|
|
1407
|
+
const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status: ["failed"], limit: 1e3 });
|
|
1408
|
+
await Promise.all(jobs.map((j) => this.core().job.retry(j.id, this.toCoreQueueName(queue))));
|
|
1409
|
+
return jobs.length;
|
|
1410
|
+
}
|
|
1411
|
+
async pauseJobType(queue, jobName) {
|
|
1412
|
+
throw new IgniterJobsError({
|
|
1413
|
+
code: "JOBS_QUEUE_OPERATION_FAILED",
|
|
1414
|
+
message: "BullMQ backend does not support pausing a single job type; pause the queue or adjust worker filters."
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
async resumeJobType(queue, jobName) {
|
|
1418
|
+
throw new IgniterJobsError({
|
|
1419
|
+
code: "JOBS_QUEUE_OPERATION_FAILED",
|
|
1420
|
+
message: "BullMQ backend does not support resuming a single job type; resume the queue or adjust worker filters."
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
async searchJobs(filter) {
|
|
1424
|
+
const queue = filter?.queue;
|
|
1425
|
+
const status = filter?.status;
|
|
1426
|
+
const limit = filter?.limit ?? 100;
|
|
1427
|
+
const offset = filter?.offset ?? 0;
|
|
1428
|
+
if (queue) {
|
|
1429
|
+
const jobs = await this.core().queues.getJobs(this.toCoreQueueName(queue), { status, limit, offset });
|
|
1430
|
+
return jobs.map((j) => this.mapJob(j, queue));
|
|
1431
|
+
}
|
|
1432
|
+
const queues = await this.listQueues();
|
|
1433
|
+
const results = [];
|
|
1434
|
+
for (const q of queues) {
|
|
1435
|
+
const jobs = await this.core().queues.getJobs(this.toCoreQueueName(q.name), { status, limit, offset });
|
|
1436
|
+
results.push(...jobs.map((j) => this.mapJob(j, q.name)));
|
|
1437
|
+
if (results.length >= limit) break;
|
|
1438
|
+
}
|
|
1439
|
+
return results.slice(0, limit);
|
|
1440
|
+
}
|
|
1441
|
+
async searchQueues(filter) {
|
|
1442
|
+
const all = await this.listQueues();
|
|
1443
|
+
const name = filter?.name;
|
|
1444
|
+
const isPaused = filter?.isPaused;
|
|
1445
|
+
return all.filter((q) => name ? q.name.includes(name) : true).filter((q) => typeof isPaused === "boolean" ? q.isPaused === isPaused : true);
|
|
1446
|
+
}
|
|
1447
|
+
async searchWorkers(filter) {
|
|
1448
|
+
const queue = filter?.queue;
|
|
1449
|
+
const isRunning = filter?.isRunning;
|
|
1450
|
+
const all = Array.from(this.core().getWorkers().values());
|
|
1451
|
+
return all.filter((w) => {
|
|
1452
|
+
if (!queue) return true;
|
|
1453
|
+
const coreQueue = this.toCoreQueueName(queue);
|
|
1454
|
+
const queues = w.config?.queues ?? [w.queueName];
|
|
1455
|
+
return Array.isArray(queues) ? queues.includes(coreQueue) : false;
|
|
1456
|
+
}).filter((w) => typeof isRunning === "boolean" ? isRunning ? w.isRunning() : !w.isRunning() : true).map((w) => this.mapWorker(w));
|
|
1457
|
+
}
|
|
1458
|
+
async createWorker(config) {
|
|
1459
|
+
await this.executor();
|
|
1460
|
+
const queuesSource = config.queues?.length ? config.queues : Array.from(/* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]));
|
|
1461
|
+
const queues = queuesSource.map((q) => this.toCoreQueueName(q));
|
|
1462
|
+
const coreConfig = {
|
|
1463
|
+
queues,
|
|
1464
|
+
concurrency: config.concurrency ?? 1,
|
|
1465
|
+
limiter: config.limiter,
|
|
1466
|
+
onActive: config.handlers?.onActive,
|
|
1467
|
+
onSuccess: config.handlers?.onSuccess,
|
|
1468
|
+
onFailure: config.handlers?.onFailure,
|
|
1469
|
+
onIdle: config.handlers?.onIdle
|
|
1470
|
+
};
|
|
1471
|
+
const handle = await this.core().worker(coreConfig);
|
|
1472
|
+
return this.mapWorker(handle);
|
|
1473
|
+
}
|
|
1474
|
+
getWorkers() {
|
|
1475
|
+
const out = /* @__PURE__ */ new Map();
|
|
1476
|
+
for (const [id, handle] of this.core().getWorkers()) out.set(id, this.mapWorker(handle));
|
|
1477
|
+
return out;
|
|
1478
|
+
}
|
|
1479
|
+
async publishEvent(channel, payload) {
|
|
1480
|
+
await this.publisher.publish(channel, JSON.stringify(payload));
|
|
1481
|
+
}
|
|
1482
|
+
async subscribeEvent(channel, handler) {
|
|
1483
|
+
const set = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
|
|
1484
|
+
const wrapped = (payload) => void handler(payload);
|
|
1485
|
+
set.add(wrapped);
|
|
1486
|
+
this.subscribers.set(channel, set);
|
|
1487
|
+
if (set.size === 1) {
|
|
1488
|
+
await this.subscriber.subscribe(channel);
|
|
1489
|
+
}
|
|
1490
|
+
return async () => {
|
|
1491
|
+
const current = this.subscribers.get(channel);
|
|
1492
|
+
if (!current) return;
|
|
1493
|
+
current.delete(wrapped);
|
|
1494
|
+
if (current.size === 0) {
|
|
1495
|
+
this.subscribers.delete(channel);
|
|
1496
|
+
await this.subscriber.unsubscribe(channel);
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
async shutdown() {
|
|
1501
|
+
await this.subscriber.quit();
|
|
1502
|
+
}
|
|
1503
|
+
core() {
|
|
1504
|
+
if (!this.coreAdapter) {
|
|
1505
|
+
this.coreAdapter = createBullMQAdapter({
|
|
1506
|
+
store: { client: this.redis }
|
|
1507
|
+
});
|
|
1508
|
+
}
|
|
1509
|
+
return this.coreAdapter;
|
|
1510
|
+
}
|
|
1511
|
+
async executor() {
|
|
1512
|
+
if (!this.executorDirty && this.coreExecutor) return this.coreExecutor;
|
|
1513
|
+
const routers = {};
|
|
1514
|
+
const flattened = {};
|
|
1515
|
+
const allQueues = /* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.cronsByQueue.keys()]);
|
|
1516
|
+
for (const queueName of allQueues) {
|
|
1517
|
+
const coreJobs = {};
|
|
1518
|
+
const jobs = this.jobsByQueue.get(queueName);
|
|
1519
|
+
if (jobs) {
|
|
1520
|
+
for (const [jobName, def] of jobs.entries()) {
|
|
1521
|
+
const queue = def.queue ? `${queueName}.${def.queue}` : queueName;
|
|
1522
|
+
const fullQueue = IgniterJobsPrefix.buildQueueName(queue);
|
|
1523
|
+
coreJobs[jobName] = this.toCoreJobDefinition(queueName, jobName, def, fullQueue);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const crons = this.cronsByQueue.get(queueName);
|
|
1527
|
+
if (crons) {
|
|
1528
|
+
for (const [cronName, def] of crons.entries()) {
|
|
1529
|
+
const fullQueue = IgniterJobsPrefix.buildQueueName(queueName);
|
|
1530
|
+
coreJobs[cronName] = this.toCoreCronJobDefinition(queueName, cronName, def, fullQueue);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (Object.keys(coreJobs).length === 0) continue;
|
|
1534
|
+
routers[queueName] = this.core().router({
|
|
1535
|
+
jobs: coreJobs,
|
|
1536
|
+
namespace: queueName
|
|
1537
|
+
});
|
|
1538
|
+
for (const [jobName, def] of Object.entries(coreJobs)) {
|
|
1539
|
+
flattened[`${queueName}.${jobName}`] = def;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
await this.core().bulkRegister(flattened);
|
|
1543
|
+
this.coreExecutor = this.core().merge(routers);
|
|
1544
|
+
this.executorDirty = false;
|
|
1545
|
+
return this.coreExecutor;
|
|
1546
|
+
}
|
|
1547
|
+
toCoreQueueName(queueName) {
|
|
1548
|
+
return IgniterJobsPrefix.buildQueueName(queueName);
|
|
1549
|
+
}
|
|
1550
|
+
mapQueueInfo(info) {
|
|
1551
|
+
return {
|
|
1552
|
+
name: this.fromCoreQueueName(info.name),
|
|
1553
|
+
isPaused: info.isPaused,
|
|
1554
|
+
jobCounts: info.jobCounts
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
fromCoreQueueName(full) {
|
|
1558
|
+
const prefix = `${IgniterJobsPrefix.BASE_PREFIX}:`;
|
|
1559
|
+
return full.startsWith(prefix) ? full.slice(prefix.length) : full;
|
|
1560
|
+
}
|
|
1561
|
+
mapJob(job, queue) {
|
|
1562
|
+
const q = queue ?? this.fromCoreQueueName(job.metadata?.queue ?? job.queueName ?? "");
|
|
1563
|
+
const scope = job.metadata?.__igniter_jobs_scope;
|
|
1564
|
+
return {
|
|
1565
|
+
id: job.id,
|
|
1566
|
+
name: job.name,
|
|
1567
|
+
queue: q,
|
|
1568
|
+
status: job.status,
|
|
1569
|
+
input: job.payload,
|
|
1570
|
+
result: job.result,
|
|
1571
|
+
error: job.error,
|
|
1572
|
+
progress: 0,
|
|
1573
|
+
attemptsMade: job.attemptsMade ?? 0,
|
|
1574
|
+
priority: job.priority ?? 0,
|
|
1575
|
+
createdAt: job.createdAt,
|
|
1576
|
+
startedAt: job.processedAt,
|
|
1577
|
+
completedAt: job.completedAt,
|
|
1578
|
+
metadata: job.metadata,
|
|
1579
|
+
scope
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
mapWorker(handle) {
|
|
1583
|
+
const queues = handle.config?.queues ?? [handle.queueName];
|
|
1584
|
+
return {
|
|
1585
|
+
id: handle.id,
|
|
1586
|
+
queues: queues.map((q) => this.fromCoreQueueName(q)),
|
|
1587
|
+
pause: () => handle.pause(),
|
|
1588
|
+
resume: () => handle.resume(),
|
|
1589
|
+
close: () => handle.close(),
|
|
1590
|
+
isRunning: () => handle.isRunning(),
|
|
1591
|
+
isPaused: () => handle.isPaused(),
|
|
1592
|
+
isClosed: () => handle.isClosed(),
|
|
1593
|
+
getMetrics: async () => handle.getMetrics()
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
toCoreJobDefinition(queueName, jobName, def, fullQueueName) {
|
|
1597
|
+
const handler = async (ctx) => {
|
|
1598
|
+
return def.handler({
|
|
1599
|
+
input: ctx.input,
|
|
1600
|
+
context: ctx.context,
|
|
1601
|
+
job: {
|
|
1602
|
+
id: ctx.job.id,
|
|
1603
|
+
name: jobName,
|
|
1604
|
+
queue: queueName,
|
|
1605
|
+
attemptsMade: ctx.job.attemptsMade,
|
|
1606
|
+
createdAt: ctx.job.createdAt,
|
|
1607
|
+
metadata: ctx.job.metadata
|
|
1608
|
+
},
|
|
1609
|
+
scope: ctx.job.metadata?.__igniter_jobs_scope
|
|
1610
|
+
});
|
|
1611
|
+
};
|
|
1612
|
+
return {
|
|
1613
|
+
name: jobName,
|
|
1614
|
+
input: def.input,
|
|
1615
|
+
handler,
|
|
1616
|
+
queue: { name: fullQueueName },
|
|
1617
|
+
attempts: def.attempts,
|
|
1618
|
+
priority: def.priority,
|
|
1619
|
+
delay: def.delay,
|
|
1620
|
+
removeOnComplete: def.removeOnComplete,
|
|
1621
|
+
removeOnFail: def.removeOnFail,
|
|
1622
|
+
metadata: def.metadata,
|
|
1623
|
+
limiter: def.limiter,
|
|
1624
|
+
onStart: def.onStart,
|
|
1625
|
+
onSuccess: def.onSuccess,
|
|
1626
|
+
onFailure: def.onFailure,
|
|
1627
|
+
onProgress: def.onProgress
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
toCoreCronJobDefinition(queueName, cronName, def, fullQueueName) {
|
|
1631
|
+
const handler = async (ctx) => {
|
|
1632
|
+
return def.handler({
|
|
1633
|
+
context: ctx.context,
|
|
1634
|
+
job: {
|
|
1635
|
+
id: ctx.job.id,
|
|
1636
|
+
name: cronName,
|
|
1637
|
+
queue: queueName,
|
|
1638
|
+
attemptsMade: ctx.job.attemptsMade,
|
|
1639
|
+
createdAt: ctx.job.createdAt,
|
|
1640
|
+
metadata: ctx.job.metadata
|
|
1641
|
+
},
|
|
1642
|
+
scope: ctx.job.metadata?.__igniter_jobs_scope
|
|
1643
|
+
});
|
|
1644
|
+
};
|
|
1645
|
+
return {
|
|
1646
|
+
name: cronName,
|
|
1647
|
+
handler,
|
|
1648
|
+
queue: { name: fullQueueName },
|
|
1649
|
+
repeat: {
|
|
1650
|
+
cron: def.cron,
|
|
1651
|
+
tz: def.tz,
|
|
1652
|
+
limit: def.maxExecutions,
|
|
1653
|
+
startDate: def.startDate,
|
|
1654
|
+
endDate: def.endDate
|
|
1655
|
+
},
|
|
1656
|
+
metadata: def.onlyBusinessHours || def.skipWeekends || def.businessHours || def.onlyWeekdays || def.skipDates || def.startDate && def.endDate ? {
|
|
1657
|
+
advancedScheduling: {
|
|
1658
|
+
onlyBusinessHours: def.onlyBusinessHours,
|
|
1659
|
+
skipWeekends: def.skipWeekends,
|
|
1660
|
+
businessHours: def.businessHours,
|
|
1661
|
+
skipDates: toDateArray(def.skipDates),
|
|
1662
|
+
onlyWeekdays: def.onlyWeekdays,
|
|
1663
|
+
between: def.startDate && def.endDate ? [def.startDate, def.endDate] : void 0
|
|
1664
|
+
}
|
|
1665
|
+
} : void 0
|
|
1666
|
+
};
|
|
1120
1667
|
}
|
|
1121
1668
|
};
|
|
1122
|
-
|
|
1123
|
-
|
|
1669
|
+
|
|
1670
|
+
// src/utils/id-generator.ts
|
|
1671
|
+
var IgniterJobsIdGenerator = class {
|
|
1672
|
+
/**
|
|
1673
|
+
* Generates a unique identifier with a prefix.
|
|
1674
|
+
*
|
|
1675
|
+
* @example
|
|
1676
|
+
* ```typescript
|
|
1677
|
+
* const jobId = IgniterJobsIdGenerator.generate('job')
|
|
1678
|
+
* ```
|
|
1679
|
+
*/
|
|
1680
|
+
static generate(prefix) {
|
|
1681
|
+
const now = Date.now().toString(36);
|
|
1682
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
1683
|
+
return `${prefix}_${now}_${random}`;
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
|
|
1687
|
+
// src/adapters/memory.adapter.ts
|
|
1688
|
+
var IgniterJobsMemoryAdapter = class _IgniterJobsMemoryAdapter {
|
|
1689
|
+
constructor() {
|
|
1690
|
+
this.client = {
|
|
1691
|
+
type: "memory"
|
|
1692
|
+
};
|
|
1693
|
+
this.jobsById = /* @__PURE__ */ new Map();
|
|
1694
|
+
this.jobsByQueue = /* @__PURE__ */ new Map();
|
|
1695
|
+
this.registeredJobs = /* @__PURE__ */ new Map();
|
|
1696
|
+
this.registeredCrons = /* @__PURE__ */ new Map();
|
|
1697
|
+
this.workers = /* @__PURE__ */ new Map();
|
|
1698
|
+
this.subscribers = /* @__PURE__ */ new Map();
|
|
1699
|
+
this.queues = {
|
|
1700
|
+
list: async () => this.listQueues(),
|
|
1701
|
+
get: async (name) => this.getQueueInfo(name),
|
|
1702
|
+
getJobCounts: async (name) => this.getQueueJobCounts(name),
|
|
1703
|
+
getJobs: async (name, filter) => {
|
|
1704
|
+
const statuses = filter?.status;
|
|
1705
|
+
const limit = filter?.limit ?? 100;
|
|
1706
|
+
const offset = filter?.offset ?? 0;
|
|
1707
|
+
const results = await this.searchJobs({
|
|
1708
|
+
queue: name,
|
|
1709
|
+
status: statuses,
|
|
1710
|
+
limit,
|
|
1711
|
+
offset
|
|
1712
|
+
});
|
|
1713
|
+
return results;
|
|
1714
|
+
},
|
|
1715
|
+
pause: async (name) => this.pauseQueue(name),
|
|
1716
|
+
resume: async (name) => this.resumeQueue(name),
|
|
1717
|
+
isPaused: async (name) => {
|
|
1718
|
+
const info = await this.getQueueInfo(name);
|
|
1719
|
+
return info?.isPaused ?? false;
|
|
1720
|
+
},
|
|
1721
|
+
drain: async (name) => this.drainQueue(name),
|
|
1722
|
+
clean: async (name, options) => this.cleanQueue(name, options),
|
|
1723
|
+
obliterate: async (name, options) => this.obliterateQueue(name, options)
|
|
1724
|
+
};
|
|
1725
|
+
this.pausedQueues = /* @__PURE__ */ new Set();
|
|
1726
|
+
}
|
|
1727
|
+
static create() {
|
|
1728
|
+
return new _IgniterJobsMemoryAdapter();
|
|
1729
|
+
}
|
|
1730
|
+
registerJob(queueName, jobName, definition) {
|
|
1731
|
+
const queueJobs = this.registeredJobs.get(queueName) ?? /* @__PURE__ */ new Map();
|
|
1732
|
+
if (queueJobs.has(jobName)) {
|
|
1733
|
+
throw new IgniterJobsError({
|
|
1734
|
+
code: "JOBS_DUPLICATE_JOB",
|
|
1735
|
+
message: `Job "${jobName}" already registered for queue "${queueName}".`
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
queueJobs.set(jobName, definition);
|
|
1739
|
+
this.registeredJobs.set(queueName, queueJobs);
|
|
1740
|
+
}
|
|
1741
|
+
registerCron(queueName, cronName, definition) {
|
|
1742
|
+
const queueCrons = this.registeredCrons.get(queueName) ?? /* @__PURE__ */ new Map();
|
|
1743
|
+
if (queueCrons.has(cronName)) {
|
|
1744
|
+
throw new IgniterJobsError({
|
|
1745
|
+
code: "JOBS_INVALID_CRON",
|
|
1746
|
+
message: `Cron "${cronName}" already registered for queue "${queueName}".`
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
queueCrons.set(cronName, definition);
|
|
1750
|
+
this.registeredCrons.set(queueName, queueCrons);
|
|
1751
|
+
}
|
|
1752
|
+
async dispatch(params) {
|
|
1753
|
+
const jobId = params.jobId ?? IgniterJobsIdGenerator.generate("job");
|
|
1754
|
+
const maxAttempts = params.attempts ?? 1;
|
|
1755
|
+
const metadata = params.metadata ?? {};
|
|
1756
|
+
const job = {
|
|
1757
|
+
id: jobId,
|
|
1758
|
+
name: params.jobName,
|
|
1759
|
+
queue: params.queue,
|
|
1760
|
+
input: params.input,
|
|
1761
|
+
status: this.pausedQueues.has(params.queue) ? "paused" : params.delay && params.delay > 0 ? "delayed" : "waiting",
|
|
1762
|
+
progress: 0,
|
|
1763
|
+
attemptsMade: 0,
|
|
1764
|
+
maxAttempts,
|
|
1765
|
+
priority: params.priority ?? 0,
|
|
1766
|
+
createdAt: /* @__PURE__ */ new Date(),
|
|
1767
|
+
metadata,
|
|
1768
|
+
scope: params.scope,
|
|
1769
|
+
logs: []
|
|
1770
|
+
};
|
|
1771
|
+
this.jobsById.set(jobId, job);
|
|
1772
|
+
const queueList = this.jobsByQueue.get(params.queue) ?? [];
|
|
1773
|
+
queueList.push(jobId);
|
|
1774
|
+
this.jobsByQueue.set(params.queue, queueList);
|
|
1775
|
+
if (params.delay && params.delay > 0) {
|
|
1776
|
+
setTimeout(() => {
|
|
1777
|
+
const stored = this.jobsById.get(jobId);
|
|
1778
|
+
if (!stored) return;
|
|
1779
|
+
if (!this.pausedQueues.has(params.queue)) stored.status = "waiting";
|
|
1780
|
+
void this.kickWorkers(params.queue);
|
|
1781
|
+
}, params.delay);
|
|
1782
|
+
return jobId;
|
|
1783
|
+
}
|
|
1784
|
+
void this.kickWorkers(params.queue);
|
|
1785
|
+
return jobId;
|
|
1786
|
+
}
|
|
1787
|
+
async schedule(params) {
|
|
1788
|
+
if (params.at) {
|
|
1789
|
+
const delay = params.at.getTime() - Date.now();
|
|
1790
|
+
if (delay <= 0) {
|
|
1791
|
+
throw new IgniterJobsError({
|
|
1792
|
+
code: "JOBS_INVALID_SCHEDULE",
|
|
1793
|
+
message: "Scheduled time must be in the future."
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
return this.dispatch({ ...params, delay });
|
|
1797
|
+
}
|
|
1798
|
+
if (params.cron || params.every) {
|
|
1799
|
+
return this.dispatch({ ...params, delay: params.delay ?? 0 });
|
|
1800
|
+
}
|
|
1801
|
+
return this.dispatch(params);
|
|
1802
|
+
}
|
|
1803
|
+
async getJob(jobId, queue) {
|
|
1804
|
+
const job = this.jobsById.get(jobId);
|
|
1805
|
+
if (!job) return null;
|
|
1806
|
+
if (queue && job.queue !== queue) return null;
|
|
1807
|
+
return this.toSearchResult(job);
|
|
1808
|
+
}
|
|
1809
|
+
async getJobState(jobId, queue) {
|
|
1810
|
+
const job = this.jobsById.get(jobId);
|
|
1811
|
+
if (!job) return null;
|
|
1812
|
+
if (queue && job.queue !== queue) return null;
|
|
1813
|
+
return job.status;
|
|
1814
|
+
}
|
|
1815
|
+
async getJobLogs(jobId, queue) {
|
|
1816
|
+
const job = this.jobsById.get(jobId);
|
|
1817
|
+
if (!job) return [];
|
|
1818
|
+
if (queue && job.queue !== queue) return [];
|
|
1819
|
+
return job.logs;
|
|
1820
|
+
}
|
|
1821
|
+
async getJobProgress(jobId, queue) {
|
|
1822
|
+
const job = this.jobsById.get(jobId);
|
|
1823
|
+
if (!job) return 0;
|
|
1824
|
+
if (queue && job.queue !== queue) return 0;
|
|
1825
|
+
return job.progress;
|
|
1826
|
+
}
|
|
1827
|
+
async retryJob(jobId, queue) {
|
|
1828
|
+
const job = this.jobsById.get(jobId);
|
|
1829
|
+
if (!job) {
|
|
1830
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
|
|
1831
|
+
}
|
|
1832
|
+
if (queue && job.queue !== queue) {
|
|
1833
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
|
|
1834
|
+
}
|
|
1835
|
+
job.status = "waiting";
|
|
1836
|
+
job.error = void 0;
|
|
1837
|
+
job.completedAt = void 0;
|
|
1838
|
+
job.progress = 0;
|
|
1839
|
+
void this.kickWorkers(job.queue);
|
|
1840
|
+
}
|
|
1841
|
+
async removeJob(jobId, queue) {
|
|
1842
|
+
const job = this.jobsById.get(jobId);
|
|
1843
|
+
if (!job) return;
|
|
1844
|
+
if (queue && job.queue !== queue) return;
|
|
1845
|
+
this.jobsById.delete(jobId);
|
|
1846
|
+
const list = this.jobsByQueue.get(job.queue);
|
|
1847
|
+
if (list) this.jobsByQueue.set(job.queue, list.filter((id) => id !== jobId));
|
|
1848
|
+
}
|
|
1849
|
+
async promoteJob(jobId, queue) {
|
|
1850
|
+
const job = this.jobsById.get(jobId);
|
|
1851
|
+
if (!job) {
|
|
1852
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
|
|
1853
|
+
}
|
|
1854
|
+
if (queue && job.queue !== queue) {
|
|
1855
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
|
|
1856
|
+
}
|
|
1857
|
+
if (job.status === "delayed" || job.status === "paused") {
|
|
1858
|
+
job.status = this.pausedQueues.has(job.queue) ? "paused" : "waiting";
|
|
1859
|
+
void this.kickWorkers(job.queue);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
async moveJobToFailed(jobId, reason, queue) {
|
|
1863
|
+
const job = this.jobsById.get(jobId);
|
|
1864
|
+
if (!job) {
|
|
1865
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found.` });
|
|
1866
|
+
}
|
|
1867
|
+
if (queue && job.queue !== queue) {
|
|
1868
|
+
throw new IgniterJobsError({ code: "JOBS_NOT_FOUND", message: `Job "${jobId}" not found in queue "${queue}".` });
|
|
1869
|
+
}
|
|
1870
|
+
job.status = "failed";
|
|
1871
|
+
job.error = reason;
|
|
1872
|
+
job.completedAt = /* @__PURE__ */ new Date();
|
|
1873
|
+
}
|
|
1874
|
+
async retryManyJobs(jobIds, queue) {
|
|
1875
|
+
await Promise.all(jobIds.map((id) => this.retryJob(id, queue)));
|
|
1876
|
+
}
|
|
1877
|
+
async removeManyJobs(jobIds, queue) {
|
|
1878
|
+
await Promise.all(jobIds.map((id) => this.removeJob(id, queue)));
|
|
1879
|
+
}
|
|
1880
|
+
async getQueueInfo(queue) {
|
|
1881
|
+
const counts = await this.getQueueJobCounts(queue);
|
|
1882
|
+
return {
|
|
1883
|
+
name: queue,
|
|
1884
|
+
isPaused: this.pausedQueues.has(queue),
|
|
1885
|
+
jobCounts: counts
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
async getQueueJobCounts(queue) {
|
|
1889
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1890
|
+
const counts = {
|
|
1891
|
+
waiting: 0,
|
|
1892
|
+
active: 0,
|
|
1893
|
+
completed: 0,
|
|
1894
|
+
failed: 0,
|
|
1895
|
+
delayed: 0,
|
|
1896
|
+
paused: 0
|
|
1897
|
+
};
|
|
1898
|
+
for (const id of jobIds) {
|
|
1899
|
+
const job = this.jobsById.get(id);
|
|
1900
|
+
if (!job) continue;
|
|
1901
|
+
if (job.status in counts) {
|
|
1902
|
+
counts[job.status]++;
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
return counts;
|
|
1906
|
+
}
|
|
1907
|
+
async listQueues() {
|
|
1908
|
+
const queues = Array.from(/* @__PURE__ */ new Set([...this.jobsByQueue.keys(), ...this.registeredJobs.keys(), ...this.registeredCrons.keys()]));
|
|
1909
|
+
const result = [];
|
|
1910
|
+
for (const q of queues) {
|
|
1911
|
+
result.push(await this.getQueueInfo(q));
|
|
1912
|
+
}
|
|
1913
|
+
return result;
|
|
1914
|
+
}
|
|
1915
|
+
async pauseQueue(queue) {
|
|
1916
|
+
this.pausedQueues.add(queue);
|
|
1917
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1918
|
+
for (const id of jobIds) {
|
|
1919
|
+
const job = this.jobsById.get(id);
|
|
1920
|
+
if (!job) continue;
|
|
1921
|
+
if (job.status === "waiting") job.status = "paused";
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
async resumeQueue(queue) {
|
|
1925
|
+
this.pausedQueues.delete(queue);
|
|
1926
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1927
|
+
for (const id of jobIds) {
|
|
1928
|
+
const job = this.jobsById.get(id);
|
|
1929
|
+
if (!job) continue;
|
|
1930
|
+
if (job.status === "paused") job.status = "waiting";
|
|
1931
|
+
}
|
|
1932
|
+
void this.kickWorkers(queue);
|
|
1933
|
+
}
|
|
1934
|
+
async drainQueue(queue) {
|
|
1935
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1936
|
+
let removed = 0;
|
|
1937
|
+
for (const id of jobIds) {
|
|
1938
|
+
const job = this.jobsById.get(id);
|
|
1939
|
+
if (!job) continue;
|
|
1940
|
+
if (job.status === "waiting" || job.status === "paused") {
|
|
1941
|
+
this.jobsById.delete(id);
|
|
1942
|
+
removed++;
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
this.jobsByQueue.set(queue, jobIds.filter((id) => this.jobsById.has(id)));
|
|
1946
|
+
return removed;
|
|
1947
|
+
}
|
|
1948
|
+
async cleanQueue(queue, options) {
|
|
1949
|
+
const statuses = Array.isArray(options.status) ? options.status : [options.status];
|
|
1950
|
+
const olderThan = options.olderThan ?? 0;
|
|
1951
|
+
const limit = options.limit ?? Number.POSITIVE_INFINITY;
|
|
1952
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1953
|
+
const now = Date.now();
|
|
1954
|
+
let cleaned = 0;
|
|
1955
|
+
for (const id of [...jobIds]) {
|
|
1956
|
+
if (cleaned >= limit) break;
|
|
1957
|
+
const job = this.jobsById.get(id);
|
|
1958
|
+
if (!job) continue;
|
|
1959
|
+
if (!statuses.includes(job.status)) continue;
|
|
1960
|
+
const ageMs = now - job.createdAt.getTime();
|
|
1961
|
+
if (ageMs < olderThan) continue;
|
|
1962
|
+
this.jobsById.delete(id);
|
|
1963
|
+
cleaned++;
|
|
1964
|
+
}
|
|
1965
|
+
this.jobsByQueue.set(queue, jobIds.filter((id) => this.jobsById.has(id)));
|
|
1966
|
+
return cleaned;
|
|
1967
|
+
}
|
|
1968
|
+
async obliterateQueue(queue, options) {
|
|
1969
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1970
|
+
for (const id of jobIds) this.jobsById.delete(id);
|
|
1971
|
+
this.jobsByQueue.delete(queue);
|
|
1972
|
+
this.registeredJobs.delete(queue);
|
|
1973
|
+
this.registeredCrons.delete(queue);
|
|
1974
|
+
this.pausedQueues.delete(queue);
|
|
1975
|
+
}
|
|
1976
|
+
async retryAllInQueue(queue) {
|
|
1977
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1978
|
+
let retried = 0;
|
|
1979
|
+
for (const id of jobIds) {
|
|
1980
|
+
const job = this.jobsById.get(id);
|
|
1981
|
+
if (!job) continue;
|
|
1982
|
+
if (job.status === "failed") {
|
|
1983
|
+
await this.retryJob(id, queue);
|
|
1984
|
+
retried++;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return retried;
|
|
1988
|
+
}
|
|
1989
|
+
async pauseJobType(queue, jobName) {
|
|
1990
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1991
|
+
for (const id of jobIds) {
|
|
1992
|
+
const job = this.jobsById.get(id);
|
|
1993
|
+
if (!job) continue;
|
|
1994
|
+
if (job.name === jobName && job.status === "waiting") job.status = "paused";
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
async resumeJobType(queue, jobName) {
|
|
1998
|
+
const jobIds = this.jobsByQueue.get(queue) ?? [];
|
|
1999
|
+
for (const id of jobIds) {
|
|
2000
|
+
const job = this.jobsById.get(id);
|
|
2001
|
+
if (!job) continue;
|
|
2002
|
+
if (job.name === jobName && job.status === "paused") job.status = "waiting";
|
|
2003
|
+
}
|
|
2004
|
+
void this.kickWorkers(queue);
|
|
2005
|
+
}
|
|
2006
|
+
async searchJobs(filter) {
|
|
2007
|
+
const queue = filter?.queue;
|
|
2008
|
+
const statuses = filter?.status;
|
|
2009
|
+
const limit = filter?.limit ?? 100;
|
|
2010
|
+
const offset = filter?.offset ?? 0;
|
|
2011
|
+
const all = Array.from(this.jobsById.values()).filter((j) => queue ? j.queue === queue : true).filter((j) => statuses ? statuses.includes(j.status) : true).sort((a, b) => b.priority - a.priority || a.createdAt.getTime() - b.createdAt.getTime());
|
|
2012
|
+
return all.slice(offset, offset + limit).map((j) => this.toSearchResult(j));
|
|
2013
|
+
}
|
|
2014
|
+
async searchQueues(filter) {
|
|
2015
|
+
const name = filter?.name;
|
|
2016
|
+
const isPaused = filter?.isPaused;
|
|
2017
|
+
const all = await this.listQueues();
|
|
2018
|
+
return all.filter((q) => name ? q.name.includes(name) : true).filter((q) => typeof isPaused === "boolean" ? q.isPaused === isPaused : true);
|
|
2019
|
+
}
|
|
2020
|
+
async searchWorkers(filter) {
|
|
2021
|
+
const queue = filter?.queue;
|
|
2022
|
+
const isRunning = filter?.isRunning;
|
|
2023
|
+
return Array.from(this.workers.values()).filter((w) => queue ? w.queues.includes(queue) : true).filter((w) => typeof isRunning === "boolean" ? isRunning ? !w.closed : w.closed : true).map((w) => this.toWorkerHandle(w));
|
|
2024
|
+
}
|
|
2025
|
+
async createWorker(config) {
|
|
2026
|
+
const workerId = IgniterJobsIdGenerator.generate("worker");
|
|
2027
|
+
const state = {
|
|
2028
|
+
id: workerId,
|
|
2029
|
+
queues: config.queues ?? [],
|
|
2030
|
+
concurrency: config.concurrency ?? 1,
|
|
2031
|
+
paused: false,
|
|
2032
|
+
closed: false,
|
|
2033
|
+
startedAt: /* @__PURE__ */ new Date(),
|
|
2034
|
+
metrics: { processed: 0, failed: 0, totalDuration: 0 },
|
|
2035
|
+
handlers: config.handlers
|
|
2036
|
+
};
|
|
2037
|
+
this.workers.set(workerId, state);
|
|
2038
|
+
for (const q of state.queues) void this.kickWorkers(q);
|
|
2039
|
+
return this.toWorkerHandle(state);
|
|
2040
|
+
}
|
|
2041
|
+
getWorkers() {
|
|
2042
|
+
const out = /* @__PURE__ */ new Map();
|
|
2043
|
+
for (const [id, state] of this.workers) out.set(id, this.toWorkerHandle(state));
|
|
2044
|
+
return out;
|
|
2045
|
+
}
|
|
2046
|
+
async publishEvent(channel, payload) {
|
|
2047
|
+
const handlers = this.subscribers.get(channel);
|
|
2048
|
+
if (!handlers) return;
|
|
2049
|
+
await Promise.all(Array.from(handlers).map(async (h) => h(payload)));
|
|
2050
|
+
}
|
|
2051
|
+
async subscribeEvent(channel, handler) {
|
|
2052
|
+
const set = this.subscribers.get(channel) ?? /* @__PURE__ */ new Set();
|
|
2053
|
+
set.add(handler);
|
|
2054
|
+
this.subscribers.set(channel, set);
|
|
2055
|
+
return async () => {
|
|
2056
|
+
const current = this.subscribers.get(channel);
|
|
2057
|
+
if (!current) return;
|
|
2058
|
+
current.delete(handler);
|
|
2059
|
+
if (current.size === 0) this.subscribers.delete(channel);
|
|
2060
|
+
};
|
|
2061
|
+
}
|
|
2062
|
+
async shutdown() {
|
|
2063
|
+
this.workers.clear();
|
|
2064
|
+
this.subscribers.clear();
|
|
2065
|
+
}
|
|
2066
|
+
toSearchResult(job) {
|
|
2067
|
+
return {
|
|
2068
|
+
id: job.id,
|
|
2069
|
+
name: job.name,
|
|
2070
|
+
queue: job.queue,
|
|
2071
|
+
status: job.status,
|
|
2072
|
+
input: job.input,
|
|
2073
|
+
result: job.result,
|
|
2074
|
+
error: job.error,
|
|
2075
|
+
progress: job.progress,
|
|
2076
|
+
attemptsMade: job.attemptsMade,
|
|
2077
|
+
priority: job.priority,
|
|
2078
|
+
createdAt: job.createdAt,
|
|
2079
|
+
startedAt: job.startedAt,
|
|
2080
|
+
completedAt: job.completedAt,
|
|
2081
|
+
metadata: job.metadata,
|
|
2082
|
+
scope: job.scope
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
toWorkerHandle(worker) {
|
|
2086
|
+
return {
|
|
2087
|
+
id: worker.id,
|
|
2088
|
+
queues: worker.queues,
|
|
2089
|
+
pause: async () => {
|
|
2090
|
+
worker.paused = true;
|
|
2091
|
+
},
|
|
2092
|
+
resume: async () => {
|
|
2093
|
+
worker.paused = false;
|
|
2094
|
+
for (const q of worker.queues) void this.kickWorkers(q);
|
|
2095
|
+
},
|
|
2096
|
+
close: async () => {
|
|
2097
|
+
worker.closed = true;
|
|
2098
|
+
},
|
|
2099
|
+
isRunning: () => !worker.closed && !worker.paused,
|
|
2100
|
+
isPaused: () => worker.paused,
|
|
2101
|
+
isClosed: () => worker.closed,
|
|
2102
|
+
getMetrics: async () => this.toWorkerMetrics(worker)
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
toWorkerMetrics(worker) {
|
|
2106
|
+
const uptime = Date.now() - worker.startedAt.getTime();
|
|
2107
|
+
const processed = worker.metrics.processed;
|
|
2108
|
+
return {
|
|
2109
|
+
processed,
|
|
2110
|
+
failed: worker.metrics.failed,
|
|
2111
|
+
avgDuration: processed > 0 ? worker.metrics.totalDuration / processed : 0,
|
|
2112
|
+
concurrency: worker.concurrency,
|
|
2113
|
+
uptime
|
|
2114
|
+
};
|
|
2115
|
+
}
|
|
2116
|
+
async kickWorkers(queue) {
|
|
2117
|
+
if (this.pausedQueues.has(queue)) return;
|
|
2118
|
+
const relevant = Array.from(this.workers.values()).filter((w) => !w.closed && !w.paused && (w.queues.length === 0 || w.queues.includes(queue)));
|
|
2119
|
+
if (relevant.length === 0) return;
|
|
2120
|
+
for (const w of relevant) {
|
|
2121
|
+
void this.processLoop(w, queue);
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
async processLoop(worker, queue) {
|
|
2125
|
+
if (worker.closed || worker.paused) return;
|
|
2126
|
+
const concurrency = Math.max(1, worker.concurrency);
|
|
2127
|
+
const running = worker.__running;
|
|
2128
|
+
const currentRunning = running ?? 0;
|
|
2129
|
+
if (currentRunning >= concurrency) return;
|
|
2130
|
+
worker.__running = currentRunning + 1;
|
|
2131
|
+
try {
|
|
2132
|
+
const next = this.nextJob(queue);
|
|
2133
|
+
if (!next) return;
|
|
2134
|
+
await this.processJob(worker, next);
|
|
2135
|
+
} finally {
|
|
2136
|
+
worker.__running = worker.__running - 1;
|
|
2137
|
+
if (this.nextJob(queue)) void this.processLoop(worker, queue);
|
|
2138
|
+
else if (worker.handlers?.onIdle) await worker.handlers.onIdle();
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
nextJob(queue) {
|
|
2142
|
+
const ids = this.jobsByQueue.get(queue) ?? [];
|
|
2143
|
+
const candidates = ids.map((id) => this.jobsById.get(id)).filter((j) => Boolean(j)).filter((j) => j.status === "waiting").sort((a, b) => b.priority - a.priority || a.createdAt.getTime() - b.createdAt.getTime());
|
|
2144
|
+
return candidates[0] ?? null;
|
|
2145
|
+
}
|
|
2146
|
+
async processJob(worker, job) {
|
|
2147
|
+
if (this.pausedQueues.has(job.queue)) {
|
|
2148
|
+
job.status = "paused";
|
|
2149
|
+
return;
|
|
2150
|
+
}
|
|
2151
|
+
job.status = "active";
|
|
2152
|
+
job.startedAt = /* @__PURE__ */ new Date();
|
|
2153
|
+
job.attemptsMade += 1;
|
|
2154
|
+
job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "info", message: "Job started" });
|
|
2155
|
+
if (worker.handlers?.onActive) await worker.handlers.onActive({ job: this.toSearchResult(job) });
|
|
2156
|
+
const start = Date.now();
|
|
2157
|
+
try {
|
|
2158
|
+
const definition = this.registeredJobs.get(job.queue)?.get(job.name);
|
|
2159
|
+
if (!definition) {
|
|
2160
|
+
throw new IgniterJobsError({
|
|
2161
|
+
code: "JOBS_NOT_REGISTERED",
|
|
2162
|
+
message: `Job "${job.name}" is not registered for queue "${job.queue}".`
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
if (definition.onStart) {
|
|
2166
|
+
await definition.onStart({
|
|
2167
|
+
input: job.input,
|
|
2168
|
+
context: {},
|
|
2169
|
+
job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
|
|
2170
|
+
scope: job.scope,
|
|
2171
|
+
startedAt: job.startedAt
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
const result = await definition.handler({
|
|
2175
|
+
input: job.input,
|
|
2176
|
+
context: {},
|
|
2177
|
+
job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
|
|
2178
|
+
scope: job.scope
|
|
2179
|
+
});
|
|
2180
|
+
const duration = Date.now() - start;
|
|
2181
|
+
job.status = "completed";
|
|
2182
|
+
job.completedAt = /* @__PURE__ */ new Date();
|
|
2183
|
+
job.result = result;
|
|
2184
|
+
job.progress = 100;
|
|
2185
|
+
job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "info", message: `Job completed in ${duration}ms` });
|
|
2186
|
+
worker.metrics.processed += 1;
|
|
2187
|
+
worker.metrics.totalDuration += duration;
|
|
2188
|
+
if (definition.onSuccess) {
|
|
2189
|
+
await definition.onSuccess({
|
|
2190
|
+
input: job.input,
|
|
2191
|
+
context: {},
|
|
2192
|
+
job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
|
|
2193
|
+
scope: job.scope,
|
|
2194
|
+
result,
|
|
2195
|
+
duration
|
|
2196
|
+
});
|
|
2197
|
+
}
|
|
2198
|
+
if (worker.handlers?.onSuccess) await worker.handlers.onSuccess({ job: this.toSearchResult(job), result });
|
|
2199
|
+
} catch (error) {
|
|
2200
|
+
job.error = error?.message ?? String(error);
|
|
2201
|
+
job.logs.push({ timestamp: /* @__PURE__ */ new Date(), level: "error", message: job.error ?? "Unknown error" });
|
|
2202
|
+
const isFinalAttempt = job.attemptsMade >= job.maxAttempts;
|
|
2203
|
+
if (isFinalAttempt) {
|
|
2204
|
+
job.status = "failed";
|
|
2205
|
+
job.completedAt = /* @__PURE__ */ new Date();
|
|
2206
|
+
worker.metrics.failed += 1;
|
|
2207
|
+
const definition = this.registeredJobs.get(job.queue)?.get(job.name);
|
|
2208
|
+
if (definition?.onFailure) {
|
|
2209
|
+
await definition.onFailure({
|
|
2210
|
+
input: job.input,
|
|
2211
|
+
context: {},
|
|
2212
|
+
job: { id: job.id, name: job.name, queue: job.queue, attemptsMade: job.attemptsMade, metadata: job.metadata },
|
|
2213
|
+
scope: job.scope,
|
|
2214
|
+
error,
|
|
2215
|
+
isFinalAttempt: true
|
|
2216
|
+
});
|
|
2217
|
+
}
|
|
2218
|
+
if (worker.handlers?.onFailure) await worker.handlers.onFailure({ job: this.toSearchResult(job), error });
|
|
2219
|
+
} else {
|
|
2220
|
+
job.status = "waiting";
|
|
2221
|
+
void this.kickWorkers(job.queue);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
};
|
|
2226
|
+
|
|
2227
|
+
// src/telemetry/jobs.telemetry.ts
|
|
2228
|
+
var IgniterJobsTelemetryEvents = {
|
|
2229
|
+
namespace: "igniter.jobs",
|
|
2230
|
+
events: {
|
|
2231
|
+
// Job lifecycle events
|
|
2232
|
+
job: {
|
|
2233
|
+
enqueued: {},
|
|
2234
|
+
started: {},
|
|
2235
|
+
completed: {},
|
|
2236
|
+
failed: {},
|
|
2237
|
+
progress: {},
|
|
2238
|
+
retrying: {},
|
|
2239
|
+
scheduled: {}
|
|
2240
|
+
},
|
|
2241
|
+
// Worker lifecycle events
|
|
2242
|
+
worker: {
|
|
2243
|
+
started: {},
|
|
2244
|
+
stopped: {},
|
|
2245
|
+
idle: {},
|
|
2246
|
+
paused: {},
|
|
2247
|
+
resumed: {}
|
|
2248
|
+
},
|
|
2249
|
+
// Queue management events
|
|
2250
|
+
queue: {
|
|
2251
|
+
paused: {},
|
|
2252
|
+
resumed: {},
|
|
2253
|
+
drained: {},
|
|
2254
|
+
cleaned: {},
|
|
2255
|
+
obliterated: {}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
1124
2258
|
};
|
|
1125
2259
|
|
|
1126
|
-
export { IGNITER_JOBS_ERROR_CODES, IgniterJobs, IgniterJobsBuilder, IgniterJobsError,
|
|
2260
|
+
export { IGNITER_JOBS_ERROR_CODES, IgniterJobs, IgniterJobsBuilder, IgniterJobsBullMQAdapter, IgniterJobsError, IgniterJobsMemoryAdapter, IgniterJobsTelemetryEvents, IgniterQueue, IgniterQueueBuilder, IgniterWorkerBuilder };
|
|
1127
2261
|
//# sourceMappingURL=index.mjs.map
|
|
1128
2262
|
//# sourceMappingURL=index.mjs.map
|