@iskra-bun/worker-kit 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +68 -0
- package/README.md +8 -2
- package/dist/index.d.ts +153 -18
- package/dist/index.js +251 -28
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
- package/src/index.ts +335 -31
- package/src/types.ts +79 -10
package/src/index.ts
CHANGED
|
@@ -1,19 +1,49 @@
|
|
|
1
1
|
import type { Driver, App } from '@iskra-bun/core';
|
|
2
|
-
import { Queue, Worker, type Job as BullJob } from 'bullmq';
|
|
2
|
+
import { Queue, QueueEvents, UnrecoverableError, Worker, type Job as BullJob } from 'bullmq';
|
|
3
3
|
import { QueueError, JobError } from './errors';
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
WorkerManagerOptions,
|
|
6
|
+
JobOptions,
|
|
7
|
+
JobHandler,
|
|
8
|
+
RepeatSpec,
|
|
9
|
+
JobDescriptor,
|
|
10
|
+
DeadLetterPayload,
|
|
11
|
+
} from './types';
|
|
5
12
|
|
|
6
|
-
export type {
|
|
13
|
+
export type {
|
|
14
|
+
WorkerManagerOptions,
|
|
15
|
+
JobOptions,
|
|
16
|
+
JobHandler,
|
|
17
|
+
RepeatSpec,
|
|
18
|
+
JobDescriptor,
|
|
19
|
+
DeadLetterPayload,
|
|
20
|
+
} from './types';
|
|
7
21
|
export * from './errors';
|
|
8
22
|
|
|
23
|
+
/**
|
|
24
|
+
* The finished jobs kept in Redis, payload included, unless a job or
|
|
25
|
+
* `defaultJobOptions` says otherwise: BullMQ's own default is to keep every
|
|
26
|
+
* completed and failed job forever.
|
|
27
|
+
*/
|
|
28
|
+
const RETENTION = {
|
|
29
|
+
removeOnComplete: { count: 1000 },
|
|
30
|
+
removeOnFail: { age: 7 * 24 * 60 * 60, count: 5000 },
|
|
31
|
+
};
|
|
32
|
+
|
|
9
33
|
export class WorkerManager implements Driver {
|
|
10
34
|
name = 'WorkerManager';
|
|
11
35
|
private app: App | null = null;
|
|
12
|
-
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
private handlers: Map<string, JobHandler<any, any>> = new Map();
|
|
13
38
|
private queue: Queue | null = null;
|
|
14
39
|
private worker: Worker | null = null;
|
|
40
|
+
private queueEvents: QueueEvents | null = null;
|
|
41
|
+
private stopped = false;
|
|
15
42
|
private options: WorkerManagerOptions;
|
|
16
43
|
|
|
44
|
+
/** Tope de tamaño (bytes) del payload serializado de un job. */
|
|
45
|
+
private static readonly MAX_PAYLOAD_BYTES = 1024 * 1024; // 1 MB
|
|
46
|
+
|
|
17
47
|
constructor(options: WorkerManagerOptions) {
|
|
18
48
|
this.options = options;
|
|
19
49
|
}
|
|
@@ -26,7 +56,7 @@ export class WorkerManager implements Driver {
|
|
|
26
56
|
try {
|
|
27
57
|
this.queue = new Queue(this.options.queueName || 'iskra-jobs', {
|
|
28
58
|
connection,
|
|
29
|
-
defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),
|
|
59
|
+
defaultJobOptions: { ...RETENTION, ...this.mapJobOptions(this.options.defaultJobOptions) },
|
|
30
60
|
});
|
|
31
61
|
} catch (err) {
|
|
32
62
|
throw new QueueError('Failed to initialize BullMQ queue', {
|
|
@@ -34,32 +64,71 @@ export class WorkerManager implements Driver {
|
|
|
34
64
|
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
35
65
|
});
|
|
36
66
|
}
|
|
67
|
+
// Without an 'error' listener BullMQ prints connection errors to the
|
|
68
|
+
// console; route them through the app logger instead.
|
|
69
|
+
this.queue.on('error', (err) => this.logConnectionError('queue', err));
|
|
37
70
|
}
|
|
38
71
|
|
|
39
72
|
/**
|
|
40
|
-
* Registra un handler para un tipo de job.
|
|
73
|
+
* Registra un handler para un tipo de job. El handler puede devolver un
|
|
74
|
+
* valor `R` que queda disponible como resultado del job.
|
|
41
75
|
*/
|
|
42
|
-
register(jobName: string, handler: JobHandler) {
|
|
76
|
+
register<T = unknown, R = void>(jobName: string, handler: JobHandler<T, R>) {
|
|
43
77
|
this.handlers.set(jobName, handler);
|
|
44
78
|
return this;
|
|
45
79
|
}
|
|
46
80
|
|
|
47
81
|
/**
|
|
48
|
-
* Encola un job para ser procesado.
|
|
82
|
+
* Encola un job para ser procesado. Devuelve un descriptor que, además de
|
|
83
|
+
* los datos del job, expone `result()` para esperar el valor de retorno del
|
|
84
|
+
* handler.
|
|
49
85
|
*/
|
|
50
|
-
async enqueue(name: string, data:
|
|
86
|
+
async enqueue<T = unknown, R = unknown>(name: string, data: T, opts?: JobOptions): Promise<JobDescriptor<T, R>> {
|
|
51
87
|
if (!this.queue) {
|
|
52
88
|
throw new QueueError('Queue not initialized. Did you call init()?', {
|
|
53
89
|
context: { jobName: name },
|
|
54
90
|
});
|
|
55
91
|
}
|
|
56
92
|
|
|
93
|
+
this.validateEnqueue(name, data, opts);
|
|
94
|
+
|
|
57
95
|
const job = await this.queue.add(name, data, this.mapJobOptions(opts));
|
|
58
96
|
this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');
|
|
59
|
-
return
|
|
97
|
+
return this.buildDescriptor<T, R>(job, name, data);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Programa un job repetible (cron o intervalo). Conveniencia sobre
|
|
102
|
+
* `enqueue` con la opción `repeat` ya configurada.
|
|
103
|
+
*
|
|
104
|
+
* @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.
|
|
105
|
+
*/
|
|
106
|
+
async schedule<T = unknown, R = unknown>(
|
|
107
|
+
name: string,
|
|
108
|
+
data: T,
|
|
109
|
+
repeat: RepeatSpec,
|
|
110
|
+
opts?: JobOptions,
|
|
111
|
+
): Promise<JobDescriptor<T, R>> {
|
|
112
|
+
return this.enqueue<T, R>(name, data, { ...opts, repeat });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `consume: false`, or `concurrency: 0` (which used to fall back to 1, so
|
|
117
|
+
* a service meant to only enqueue consumed jobs it had no handler for).
|
|
118
|
+
*/
|
|
119
|
+
private get producerOnly(): boolean {
|
|
120
|
+
return this.options.consume === false || this.options.concurrency === 0;
|
|
60
121
|
}
|
|
61
122
|
|
|
62
123
|
async start() {
|
|
124
|
+
if (this.producerOnly) {
|
|
125
|
+
this.app?.logger.info(
|
|
126
|
+
{ queue: this.options.queueName || 'iskra-jobs' },
|
|
127
|
+
'WorkerManager started in producer-only mode (consume: false / concurrency: 0)',
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
63
132
|
const connection = this.parseConnection();
|
|
64
133
|
|
|
65
134
|
this.worker = new Worker(
|
|
@@ -67,12 +136,15 @@ export class WorkerManager implements Driver {
|
|
|
67
136
|
async (job: BullJob) => {
|
|
68
137
|
const handler = this.handlers.get(job.name);
|
|
69
138
|
if (!handler) {
|
|
70
|
-
|
|
71
|
-
|
|
139
|
+
// Returning would mark the job completed and silently drop
|
|
140
|
+
// it. Fail it permanently instead (no retries), so it stays
|
|
141
|
+
// in the failed set and reaches dead-letter handling.
|
|
142
|
+
this.app?.logger.error({ jobName: job.name, jobId: job.id }, 'No handler registered for job');
|
|
143
|
+
throw new UnrecoverableError(`No handler registered for job "${job.name}"`);
|
|
72
144
|
}
|
|
73
145
|
|
|
74
146
|
try {
|
|
75
|
-
await handler({
|
|
147
|
+
return await handler({
|
|
76
148
|
id: job.id!,
|
|
77
149
|
name: job.name,
|
|
78
150
|
data: job.data,
|
|
@@ -83,7 +155,9 @@ export class WorkerManager implements Driver {
|
|
|
83
155
|
cause: err instanceof Error ? err : new Error(String(err)),
|
|
84
156
|
context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
|
|
85
157
|
});
|
|
86
|
-
|
|
158
|
+
// Debug only: BullMQ then emits `failed`, and onFailed logs
|
|
159
|
+
// the failure once at error level.
|
|
160
|
+
this.app?.logger.debug({ err: jobErr }, jobErr.message);
|
|
87
161
|
throw err; // Re-throw para que BullMQ maneje el retry
|
|
88
162
|
}
|
|
89
163
|
},
|
|
@@ -98,51 +172,281 @@ export class WorkerManager implements Driver {
|
|
|
98
172
|
});
|
|
99
173
|
|
|
100
174
|
this.worker.on('failed', (job, err) => {
|
|
101
|
-
this.
|
|
175
|
+
this.onFailed(job, err);
|
|
102
176
|
});
|
|
103
177
|
|
|
104
|
-
this.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
178
|
+
this.worker.on('error', (err) => this.logConnectionError('worker', err));
|
|
179
|
+
|
|
180
|
+
this.app?.logger.info(
|
|
181
|
+
{
|
|
182
|
+
queue: this.options.queueName || 'iskra-jobs',
|
|
183
|
+
concurrency: this.options.concurrency || 1,
|
|
184
|
+
},
|
|
185
|
+
'WorkerManager started',
|
|
186
|
+
);
|
|
108
187
|
}
|
|
109
188
|
|
|
110
189
|
async stop() {
|
|
111
|
-
|
|
190
|
+
// Mark as stopped first so any in-flight result()/getQueueEvents() call
|
|
191
|
+
// throws instead of lazily opening a fresh, never-closed QueueEvents.
|
|
192
|
+
this.stopped = true;
|
|
112
193
|
|
|
194
|
+
// Close the worker first (without force) so BullMQ waits for any
|
|
195
|
+
// in-flight job to finish before tearing down its Redis connections.
|
|
196
|
+
// Only then close the queue — closing them concurrently can cut the
|
|
197
|
+
// queue connection out from under a still-draining worker.
|
|
113
198
|
if (this.worker) {
|
|
114
|
-
|
|
199
|
+
try {
|
|
200
|
+
await this.worker.close();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.app?.logger.error(
|
|
203
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
204
|
+
'WorkerManager: error while closing worker',
|
|
205
|
+
);
|
|
206
|
+
}
|
|
115
207
|
}
|
|
208
|
+
|
|
209
|
+
if (this.queueEvents) {
|
|
210
|
+
try {
|
|
211
|
+
await this.queueEvents.close();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
this.app?.logger.error(
|
|
214
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
215
|
+
'WorkerManager: error while closing queue events',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
116
220
|
if (this.queue) {
|
|
117
|
-
|
|
221
|
+
try {
|
|
222
|
+
await this.queue.close();
|
|
223
|
+
} catch (err) {
|
|
224
|
+
this.app?.logger.error(
|
|
225
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
226
|
+
'WorkerManager: error while closing queue',
|
|
227
|
+
);
|
|
228
|
+
}
|
|
118
229
|
}
|
|
119
230
|
|
|
120
|
-
await Promise.all(closePromises);
|
|
121
231
|
this.app?.logger.info('WorkerManager stopped');
|
|
122
232
|
}
|
|
123
233
|
|
|
234
|
+
private logConnectionError(source: string, err: Error) {
|
|
235
|
+
this.app?.logger.error({ err, source }, 'WorkerManager: BullMQ connection error');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Turns a redis:// or rediss:// URL into ioredis options, keeping the ACL
|
|
240
|
+
* username, the percent-decoded password, the db index and TLS (rediss).
|
|
241
|
+
*/
|
|
124
242
|
private parseConnection() {
|
|
125
243
|
if (typeof this.options.connection === 'string') {
|
|
126
244
|
const url = new URL(this.options.connection);
|
|
127
245
|
return {
|
|
128
|
-
host
|
|
246
|
+
// URL keeps the brackets of an IPv6 host ("[::1]"); ioredis wants the bare address.
|
|
247
|
+
host: url.hostname.replace(/^\[(.*)\]$/, '$1'),
|
|
129
248
|
port: Number(url.port) || 6379,
|
|
130
|
-
|
|
249
|
+
username: url.username ? decodeURIComponent(url.username) : undefined,
|
|
250
|
+
password: url.password ? decodeURIComponent(url.password) : undefined,
|
|
131
251
|
db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,
|
|
252
|
+
...(url.protocol === 'rediss:' ? { tls: {} } : {}),
|
|
132
253
|
};
|
|
133
254
|
}
|
|
134
255
|
return this.options.connection;
|
|
135
256
|
}
|
|
136
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Only the options that were given: BullMQ merges `{ ...defaultJobOptions,
|
|
260
|
+
* ...opts }`, so an explicit `undefined` erased the queue default (a job
|
|
261
|
+
* enqueued with just `{ priority }`, and every scheduled job, lost its
|
|
262
|
+
* attempts, backoff and removeOn* settings).
|
|
263
|
+
*/
|
|
137
264
|
private mapJobOptions(opts?: JobOptions) {
|
|
138
265
|
if (!opts) return undefined;
|
|
266
|
+
const mapped: Record<string, unknown> = {};
|
|
267
|
+
for (const key of ['attempts', 'delay', 'priority', 'backoff', 'removeOnComplete', 'removeOnFail'] as const) {
|
|
268
|
+
if (opts[key] !== undefined) mapped[key] = opts[key];
|
|
269
|
+
}
|
|
270
|
+
if (opts.repeat !== undefined) {
|
|
271
|
+
mapped.repeat = this.mapRepeat(opts.repeat);
|
|
272
|
+
}
|
|
273
|
+
return mapped;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Normaliza una RepeatSpec a la forma `repeat` de BullMQ:
|
|
278
|
+
* - string → `{ pattern: cron }`
|
|
279
|
+
* - `{ every }` / `{ pattern }` → se reenvían tal cual.
|
|
280
|
+
*/
|
|
281
|
+
private mapRepeat(repeat: RepeatSpec) {
|
|
282
|
+
if (typeof repeat === 'string') {
|
|
283
|
+
return { pattern: repeat };
|
|
284
|
+
}
|
|
285
|
+
return { ...repeat };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que
|
|
290
|
+
* entrada no confiable inunde la queue, almacene payloads gigantes o
|
|
291
|
+
* programe repeticiones malformadas. Lanza `QueueError` ante cualquier
|
|
292
|
+
* problema; no muta nada.
|
|
293
|
+
*/
|
|
294
|
+
private validateEnqueue(name: string, data: unknown, opts?: JobOptions) {
|
|
295
|
+
// A consuming instance only accepts jobs it can process itself; a
|
|
296
|
+
// producer-only instance (consume: false) enqueues for other workers.
|
|
297
|
+
if (!this.producerOnly && !this.handlers.has(name)) {
|
|
298
|
+
throw new QueueError(`No handler registered for job "${name}"`, {
|
|
299
|
+
context: { jobName: name },
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.validatePayloadSize(name, data);
|
|
304
|
+
|
|
305
|
+
if (opts?.repeat !== undefined) {
|
|
306
|
+
this.validateRepeat(name, opts.repeat);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Rechaza payloads cuya serialización JSON excede el tope configurado. */
|
|
311
|
+
private validatePayloadSize(name: string, data: unknown) {
|
|
312
|
+
let serialized: string;
|
|
313
|
+
try {
|
|
314
|
+
serialized = JSON.stringify(data ?? null);
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new QueueError(`Job "${name}" data is not serializable`, {
|
|
317
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
318
|
+
context: { jobName: name },
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const size = Buffer.byteLength(serialized, 'utf8');
|
|
323
|
+
if (size > WorkerManager.MAX_PAYLOAD_BYTES) {
|
|
324
|
+
throw new QueueError(
|
|
325
|
+
`Job "${name}" payload too large: ${size} bytes (max ${WorkerManager.MAX_PAYLOAD_BYTES})`,
|
|
326
|
+
{ context: { jobName: name, size, max: WorkerManager.MAX_PAYLOAD_BYTES } },
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */
|
|
332
|
+
private validateRepeat(name: string, repeat: RepeatSpec) {
|
|
333
|
+
if (typeof repeat === 'string') {
|
|
334
|
+
if (repeat.trim().length === 0) {
|
|
335
|
+
throw new QueueError(`Job "${name}" has an empty cron repeat pattern`, {
|
|
336
|
+
context: { jobName: name },
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const hasEvery = 'every' in repeat;
|
|
343
|
+
const hasPattern = 'pattern' in repeat;
|
|
344
|
+
if (!hasEvery && !hasPattern) {
|
|
345
|
+
throw new QueueError(`Job "${name}" repeat spec must define "every" or "pattern"`, {
|
|
346
|
+
context: { jobName: name },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (hasEvery && !(typeof repeat.every === 'number' && repeat.every > 0)) {
|
|
351
|
+
throw new QueueError(`Job "${name}" repeat "every" must be a positive number`, {
|
|
352
|
+
context: { jobName: name, every: repeat.every },
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (hasPattern && (typeof repeat.pattern !== 'string' || repeat.pattern.trim().length === 0)) {
|
|
357
|
+
throw new QueueError(`Job "${name}" repeat "pattern" must be a non-empty cron string`, {
|
|
358
|
+
context: { jobName: name },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó
|
|
365
|
+
* todos sus reintentos y `deadLetter` está activado, emite
|
|
366
|
+
* `worker:dead-letter` en el bus de eventos de la App.
|
|
367
|
+
*/
|
|
368
|
+
private onFailed(job: BullJob | undefined, err: Error) {
|
|
369
|
+
this.app?.logger.error(
|
|
370
|
+
{ jobId: job?.id, jobName: job?.name, attemptsMade: job?.attemptsMade, err },
|
|
371
|
+
'Job failed',
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
if (!this.options.deadLetter || !job) return;
|
|
375
|
+
|
|
376
|
+
// BullMQ default attempts is 1 when unspecified.
|
|
377
|
+
//
|
|
378
|
+
// Assumed BullMQ `attemptsMade` semantics at the `failed` event: on a
|
|
379
|
+
// job's TERMINAL failure (all retries exhausted) BullMQ reports
|
|
380
|
+
// `attemptsMade == opts.attempts`, so `attemptsMade < maxAttempts`
|
|
381
|
+
// identifies a non-terminal failure with a retry still pending. This is
|
|
382
|
+
// verified against bullmq 5.78 (see test/dead-letter-attempts*.test.ts);
|
|
383
|
+
// a future bump that changes `attemptsMade` reporting will fail those
|
|
384
|
+
// tests loudly rather than silently skip dead-lettering.
|
|
385
|
+
const maxAttempts = job.opts?.attempts ?? 1;
|
|
386
|
+
// An UnrecoverableError (e.g. no handler) is terminal regardless of attempts left.
|
|
387
|
+
if (job.attemptsMade < maxAttempts && err?.name !== 'UnrecoverableError') return;
|
|
388
|
+
|
|
389
|
+
const payload: DeadLetterPayload = {
|
|
390
|
+
jobId: job.id,
|
|
391
|
+
name: job.name,
|
|
392
|
+
data: job.data,
|
|
393
|
+
failedReason: job.failedReason ?? err?.message,
|
|
394
|
+
attemptsMade: job.attemptsMade,
|
|
395
|
+
};
|
|
396
|
+
this.app?.events.emit('worker:dead-letter', payload);
|
|
397
|
+
this.app?.logger.warn(
|
|
398
|
+
{ jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
|
|
399
|
+
'Job routed to dead-letter',
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Construye el descriptor de un job, incluyendo el helper `result()` que
|
|
405
|
+
* espera el valor de retorno del handler vía `job.waitUntilFinished`.
|
|
406
|
+
*/
|
|
407
|
+
private buildDescriptor<T, R>(job: BullJob, name: string, data: T): JobDescriptor<T, R> {
|
|
139
408
|
return {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
409
|
+
id: job.id!,
|
|
410
|
+
name,
|
|
411
|
+
data,
|
|
412
|
+
// async so a post-stop getQueueEvents() throw surfaces as a rejected
|
|
413
|
+
// promise rather than a synchronous throw.
|
|
414
|
+
result: async (ttlMs?: number): Promise<R> => {
|
|
415
|
+
const queueEvents = this.getQueueEvents();
|
|
416
|
+
return job.waitUntilFinished(queueEvents, ttlMs) as Promise<R>;
|
|
417
|
+
},
|
|
146
418
|
};
|
|
147
419
|
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Devuelve (creando perezosamente) una instancia compartida de QueueEvents
|
|
423
|
+
* usada para esperar resultados de jobs.
|
|
424
|
+
*/
|
|
425
|
+
private getQueueEvents(): QueueEvents {
|
|
426
|
+
if (this.stopped) {
|
|
427
|
+
throw new QueueError('WorkerManager is stopped; cannot open QueueEvents', {
|
|
428
|
+
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (!this.queueEvents) {
|
|
432
|
+
try {
|
|
433
|
+
this.queueEvents = new QueueEvents(this.options.queueName || 'iskra-jobs', {
|
|
434
|
+
connection: this.parseConnection(),
|
|
435
|
+
});
|
|
436
|
+
} catch (err) {
|
|
437
|
+
throw new QueueError('Failed to initialize BullMQ QueueEvents', {
|
|
438
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
439
|
+
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return this.queueEvents;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// A job that failed for good (see WorkerManager's dead-letter handling).
|
|
448
|
+
declare module '@iskra-bun/core' {
|
|
449
|
+
interface AppEvents {
|
|
450
|
+
'worker:dead-letter': DeadLetterPayload;
|
|
451
|
+
}
|
|
148
452
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,14 +1,38 @@
|
|
|
1
|
+
import type { KeepJobs } from 'bullmq';
|
|
2
|
+
|
|
1
3
|
export interface WorkerManagerOptions {
|
|
2
|
-
/** URL de conexión a Redis (ej: 'redis://localhost:6379') */
|
|
3
|
-
connection:
|
|
4
|
-
|
|
4
|
+
/** URL de conexión a Redis (ej: 'redis://user:pass@localhost:6379/0'; `rediss://` activa TLS) */
|
|
5
|
+
connection:
|
|
6
|
+
| string
|
|
7
|
+
| { host: string; port: number; username?: string; password?: string; db?: number; tls?: object };
|
|
8
|
+
/**
|
|
9
|
+
* `false` = solo productor: `start()` no crea un Worker y `enqueue` acepta
|
|
10
|
+
* jobs sin handler local (los procesa otro proceso). Default: true.
|
|
11
|
+
*/
|
|
12
|
+
consume?: boolean;
|
|
13
|
+
/** Cantidad de jobs que se procesan en paralelo (default: 1). `0` = solo productor, como `consume: false`. */
|
|
5
14
|
concurrency?: number;
|
|
6
15
|
/** Nombre de la queue en Redis (default: 'iskra-jobs') */
|
|
7
16
|
queueName?: string;
|
|
8
17
|
/** Opciones por defecto para cada job */
|
|
9
18
|
defaultJobOptions?: JobOptions;
|
|
19
|
+
/**
|
|
20
|
+
* Activa el ruteo a dead-letter: cuando un job agota todos sus reintentos
|
|
21
|
+
* se emite el evento `worker:dead-letter` en el bus de eventos de la App.
|
|
22
|
+
* Opt-in para no cambiar el comportamiento existente (default: false).
|
|
23
|
+
*/
|
|
24
|
+
deadLetter?: boolean;
|
|
10
25
|
}
|
|
11
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Especificación de repetición para jobs programados.
|
|
29
|
+
*
|
|
30
|
+
* - Un string se interpreta como un patrón cron (ej: '0 0 * * *').
|
|
31
|
+
* - `{ every: ms }` repite cada `ms` milisegundos.
|
|
32
|
+
* - `{ pattern: cron }` repite según el patrón cron, con opciones extra.
|
|
33
|
+
*/
|
|
34
|
+
export type RepeatSpec = string | { every: number; limit?: number } | { pattern: string; limit?: number; tz?: string };
|
|
35
|
+
|
|
12
36
|
export interface JobOptions {
|
|
13
37
|
/** Reintentos en caso de fallo */
|
|
14
38
|
attempts?: number;
|
|
@@ -21,14 +45,59 @@ export interface JobOptions {
|
|
|
21
45
|
type: 'fixed' | 'exponential';
|
|
22
46
|
delay: number;
|
|
23
47
|
};
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Jobs completados que quedan en Redis, con sus datos: `true` los borra,
|
|
50
|
+
* un número conserva los últimos N, `{ age, count }` por edad (s) y
|
|
51
|
+
* cantidad. Default `{ count: 1000 }`; `false` los conserva todos.
|
|
52
|
+
*/
|
|
53
|
+
removeOnComplete?: boolean | number | KeepJobs;
|
|
54
|
+
/**
|
|
55
|
+
* Jobs fallidos (sin reintentos pendientes) que quedan en Redis, igual que
|
|
56
|
+
* removeOnComplete. Default `{ age: 7 días, count: 5000 }`; `false` los conserva todos.
|
|
57
|
+
*/
|
|
58
|
+
removeOnFail?: boolean | number | KeepJobs;
|
|
59
|
+
/**
|
|
60
|
+
* Programa el job como repetible (cron o intervalo).
|
|
61
|
+
* Se reenvía a la opción `repeat` de BullMQ.
|
|
62
|
+
*/
|
|
63
|
+
repeat?: RepeatSpec;
|
|
28
64
|
}
|
|
29
65
|
|
|
30
|
-
|
|
31
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Handler de un job. Puede devolver un valor `R` que queda disponible como
|
|
68
|
+
* resultado del job (recuperable vía `job.waitUntilFinished`). Devolver `void`
|
|
69
|
+
* sigue siendo válido (R por defecto es `void`).
|
|
70
|
+
*/
|
|
71
|
+
export type JobHandler<T = unknown, R = void> = (job: {
|
|
72
|
+
id: string;
|
|
73
|
+
name: string;
|
|
74
|
+
data: T;
|
|
75
|
+
attemptsMade: number;
|
|
76
|
+
}) => Promise<R>;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Payload del evento `worker:dead-letter`, emitido cuando un job agota todos
|
|
80
|
+
* sus reintentos y `deadLetter` está activado.
|
|
81
|
+
*/
|
|
82
|
+
export interface DeadLetterPayload {
|
|
83
|
+
jobId: string | undefined;
|
|
84
|
+
name: string | undefined;
|
|
85
|
+
data: unknown;
|
|
86
|
+
failedReason: string | undefined;
|
|
87
|
+
attemptsMade: number;
|
|
32
88
|
}
|
|
33
89
|
|
|
34
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Descriptor devuelto por `enqueue`/`schedule`. Además de los datos del job,
|
|
92
|
+
* expone `result()` para esperar el valor de retorno del handler.
|
|
93
|
+
*/
|
|
94
|
+
export interface JobDescriptor<T = unknown, R = unknown> {
|
|
95
|
+
id: string;
|
|
96
|
+
name: string;
|
|
97
|
+
data: T;
|
|
98
|
+
/**
|
|
99
|
+
* Espera a que el job termine y resuelve con el valor que devolvió el
|
|
100
|
+
* handler. Lanza si el job falló. Requiere una conexión a Redis viva.
|
|
101
|
+
*/
|
|
102
|
+
result(ttlMs?: number): Promise<R>;
|
|
103
|
+
}
|