@iskra-bun/worker-kit 0.1.0 → 0.2.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 +20 -0
- package/dist/index.d.ts +111 -12
- package/dist/index.js +197 -12
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +259 -16
- package/src/types.ts +59 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @iskra-bun/worker-kit
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f9654df: New worker features:
|
|
8
|
+
|
|
9
|
+
- Scheduled/repeat jobs: a `repeat` option on `JobOptions` plus a `schedule(name, data, repeat, opts?)` convenience for cron/interval jobs.
|
|
10
|
+
- Dead-letter handling: opt-in `deadLetter` emits a `worker:dead-letter` event with the job and `failedReason` once retries are exhausted.
|
|
11
|
+
- Job results: handlers may return a value (`JobHandler<T, R>`); the enqueue descriptor exposes a `result()` helper backed by BullMQ `QueueEvents`.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- f9654df: `register`, `enqueue`, and `JobHandler` are now generic over the job payload type, so a handler's `job.data` and the enqueued payload are typed instead of `any`. Defaults to `unknown`, so existing call sites compile unchanged.
|
|
16
|
+
- f9654df: `stop()` now drains in-flight jobs by fully closing the worker before closing the queue, instead of closing both concurrently (which could leave a running job stuck in the `active` state).
|
|
17
|
+
- Fix a connection leak after `stop()`. `stop()` now sets the stopped flag first, and `getQueueEvents()` throws `WorkerManager is stopped; cannot open QueueEvents` rather than lazily opening a new orphaned connection. Job descriptors created after stop reject instead of silently holding an open connection.
|
|
18
|
+
- Updated dependencies [f9654df]
|
|
19
|
+
- Updated dependencies
|
|
20
|
+
- Updated dependencies [f9654df]
|
|
21
|
+
- @iskra-bun/core@0.1.1
|
|
22
|
+
|
|
3
23
|
## 0.1.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/dist/index.d.ts
CHANGED
|
@@ -14,7 +14,28 @@ interface WorkerManagerOptions {
|
|
|
14
14
|
queueName?: string;
|
|
15
15
|
/** Opciones por defecto para cada job */
|
|
16
16
|
defaultJobOptions?: JobOptions;
|
|
17
|
+
/**
|
|
18
|
+
* Activa el ruteo a dead-letter: cuando un job agota todos sus reintentos
|
|
19
|
+
* se emite el evento `worker:dead-letter` en el bus de eventos de la App.
|
|
20
|
+
* Opt-in para no cambiar el comportamiento existente (default: false).
|
|
21
|
+
*/
|
|
22
|
+
deadLetter?: boolean;
|
|
17
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Especificación de repetición para jobs programados.
|
|
26
|
+
*
|
|
27
|
+
* - Un string se interpreta como un patrón cron (ej: '0 0 * * *').
|
|
28
|
+
* - `{ every: ms }` repite cada `ms` milisegundos.
|
|
29
|
+
* - `{ pattern: cron }` repite según el patrón cron, con opciones extra.
|
|
30
|
+
*/
|
|
31
|
+
type RepeatSpec = string | {
|
|
32
|
+
every: number;
|
|
33
|
+
limit?: number;
|
|
34
|
+
} | {
|
|
35
|
+
pattern: string;
|
|
36
|
+
limit?: number;
|
|
37
|
+
tz?: string;
|
|
38
|
+
};
|
|
18
39
|
interface JobOptions {
|
|
19
40
|
/** Reintentos en caso de fallo */
|
|
20
41
|
attempts?: number;
|
|
@@ -31,13 +52,48 @@ interface JobOptions {
|
|
|
31
52
|
removeOnComplete?: boolean | number;
|
|
32
53
|
/** Eliminar el job de Redis al fallar */
|
|
33
54
|
removeOnFail?: boolean | number;
|
|
55
|
+
/**
|
|
56
|
+
* Programa el job como repetible (cron o intervalo).
|
|
57
|
+
* Se reenvía a la opción `repeat` de BullMQ.
|
|
58
|
+
*/
|
|
59
|
+
repeat?: RepeatSpec;
|
|
34
60
|
}
|
|
35
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Handler de un job. Puede devolver un valor `R` que queda disponible como
|
|
63
|
+
* resultado del job (recuperable vía `job.waitUntilFinished`). Devolver `void`
|
|
64
|
+
* sigue siendo válido (R por defecto es `void`).
|
|
65
|
+
*/
|
|
66
|
+
type JobHandler<T = unknown, R = void> = (job: {
|
|
36
67
|
id: string;
|
|
37
68
|
name: string;
|
|
38
|
-
data:
|
|
69
|
+
data: T;
|
|
70
|
+
attemptsMade: number;
|
|
71
|
+
}) => Promise<R>;
|
|
72
|
+
/**
|
|
73
|
+
* Payload del evento `worker:dead-letter`, emitido cuando un job agota todos
|
|
74
|
+
* sus reintentos y `deadLetter` está activado.
|
|
75
|
+
*/
|
|
76
|
+
interface DeadLetterPayload {
|
|
77
|
+
jobId: string | undefined;
|
|
78
|
+
name: string | undefined;
|
|
79
|
+
data: unknown;
|
|
80
|
+
failedReason: string | undefined;
|
|
39
81
|
attemptsMade: number;
|
|
40
|
-
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Descriptor devuelto por `enqueue`/`schedule`. Además de los datos del job,
|
|
85
|
+
* expone `result()` para esperar el valor de retorno del handler.
|
|
86
|
+
*/
|
|
87
|
+
interface JobDescriptor<T = unknown, R = unknown> {
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
data: T;
|
|
91
|
+
/**
|
|
92
|
+
* Espera a que el job termine y resuelve con el valor que devolvió el
|
|
93
|
+
* handler. Lanza si el job falló. Requiere una conexión a Redis viva.
|
|
94
|
+
*/
|
|
95
|
+
result(ttlMs?: number): Promise<R>;
|
|
96
|
+
}
|
|
41
97
|
|
|
42
98
|
declare class QueueError extends IskraError {
|
|
43
99
|
constructor(message: string, options?: {
|
|
@@ -58,25 +114,68 @@ declare class WorkerManager implements Driver {
|
|
|
58
114
|
private handlers;
|
|
59
115
|
private queue;
|
|
60
116
|
private worker;
|
|
117
|
+
private queueEvents;
|
|
118
|
+
private stopped;
|
|
61
119
|
private options;
|
|
120
|
+
/** Tope de tamaño (bytes) del payload serializado de un job. */
|
|
121
|
+
private static readonly MAX_PAYLOAD_BYTES;
|
|
62
122
|
constructor(options: WorkerManagerOptions);
|
|
63
123
|
init(app: App): Promise<void>;
|
|
64
124
|
/**
|
|
65
|
-
* Registra un handler para un tipo de job.
|
|
125
|
+
* Registra un handler para un tipo de job. El handler puede devolver un
|
|
126
|
+
* valor `R` que queda disponible como resultado del job.
|
|
66
127
|
*/
|
|
67
|
-
register(jobName: string, handler: JobHandler): this;
|
|
128
|
+
register<T = unknown, R = void>(jobName: string, handler: JobHandler<T, R>): this;
|
|
68
129
|
/**
|
|
69
|
-
* Encola un job para ser procesado.
|
|
130
|
+
* Encola un job para ser procesado. Devuelve un descriptor que, además de
|
|
131
|
+
* los datos del job, expone `result()` para esperar el valor de retorno del
|
|
132
|
+
* handler.
|
|
70
133
|
*/
|
|
71
|
-
enqueue(name: string, data:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
134
|
+
enqueue<T = unknown, R = unknown>(name: string, data: T, opts?: JobOptions): Promise<JobDescriptor<T, R>>;
|
|
135
|
+
/**
|
|
136
|
+
* Programa un job repetible (cron o intervalo). Conveniencia sobre
|
|
137
|
+
* `enqueue` con la opción `repeat` ya configurada.
|
|
138
|
+
*
|
|
139
|
+
* @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.
|
|
140
|
+
*/
|
|
141
|
+
schedule<T = unknown, R = unknown>(name: string, data: T, repeat: RepeatSpec, opts?: JobOptions): Promise<JobDescriptor<T, R>>;
|
|
76
142
|
start(): Promise<void>;
|
|
77
143
|
stop(): Promise<void>;
|
|
78
144
|
private parseConnection;
|
|
79
145
|
private mapJobOptions;
|
|
146
|
+
/**
|
|
147
|
+
* Normaliza una RepeatSpec a la forma `repeat` de BullMQ:
|
|
148
|
+
* - string → `{ pattern: cron }`
|
|
149
|
+
* - `{ every }` / `{ pattern }` → se reenvían tal cual.
|
|
150
|
+
*/
|
|
151
|
+
private mapRepeat;
|
|
152
|
+
/**
|
|
153
|
+
* Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que
|
|
154
|
+
* entrada no confiable inunde la queue, almacene payloads gigantes o
|
|
155
|
+
* programe repeticiones malformadas. Lanza `QueueError` ante cualquier
|
|
156
|
+
* problema; no muta nada.
|
|
157
|
+
*/
|
|
158
|
+
private validateEnqueue;
|
|
159
|
+
/** Rechaza payloads cuya serialización JSON excede el tope configurado. */
|
|
160
|
+
private validatePayloadSize;
|
|
161
|
+
/** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */
|
|
162
|
+
private validateRepeat;
|
|
163
|
+
/**
|
|
164
|
+
* Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó
|
|
165
|
+
* todos sus reintentos y `deadLetter` está activado, emite
|
|
166
|
+
* `worker:dead-letter` en el bus de eventos de la App.
|
|
167
|
+
*/
|
|
168
|
+
private onFailed;
|
|
169
|
+
/**
|
|
170
|
+
* Construye el descriptor de un job, incluyendo el helper `result()` que
|
|
171
|
+
* espera el valor de retorno del handler vía `job.waitUntilFinished`.
|
|
172
|
+
*/
|
|
173
|
+
private buildDescriptor;
|
|
174
|
+
/**
|
|
175
|
+
* Devuelve (creando perezosamente) una instancia compartida de QueueEvents
|
|
176
|
+
* usada para esperar resultados de jobs.
|
|
177
|
+
*/
|
|
178
|
+
private getQueueEvents;
|
|
80
179
|
}
|
|
81
180
|
|
|
82
|
-
export { JobError, type JobHandler, type JobOptions, QueueError, WorkerManager, type WorkerManagerOptions };
|
|
181
|
+
export { type DeadLetterPayload, type JobDescriptor, JobError, type JobHandler, type JobOptions, QueueError, type RepeatSpec, WorkerManager, type WorkerManagerOptions };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import { Queue, Worker } from "bullmq";
|
|
2
|
+
import { Queue, QueueEvents, Worker } from "bullmq";
|
|
3
3
|
|
|
4
4
|
// src/errors.ts
|
|
5
5
|
import { IskraError, ErrorCodes } from "@iskra-bun/core";
|
|
@@ -17,13 +17,19 @@ var JobError = class extends IskraError {
|
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
// src/index.ts
|
|
20
|
-
var WorkerManager = class {
|
|
20
|
+
var WorkerManager = class _WorkerManager {
|
|
21
21
|
name = "WorkerManager";
|
|
22
22
|
app = null;
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
23
24
|
handlers = /* @__PURE__ */ new Map();
|
|
24
25
|
queue = null;
|
|
25
26
|
worker = null;
|
|
27
|
+
queueEvents = null;
|
|
28
|
+
stopped = false;
|
|
26
29
|
options;
|
|
30
|
+
/** Tope de tamaño (bytes) del payload serializado de un job. */
|
|
31
|
+
static MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
32
|
+
// 1 MB
|
|
27
33
|
constructor(options) {
|
|
28
34
|
this.options = options;
|
|
29
35
|
}
|
|
@@ -43,14 +49,17 @@ var WorkerManager = class {
|
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
51
|
/**
|
|
46
|
-
* Registra un handler para un tipo de job.
|
|
52
|
+
* Registra un handler para un tipo de job. El handler puede devolver un
|
|
53
|
+
* valor `R` que queda disponible como resultado del job.
|
|
47
54
|
*/
|
|
48
55
|
register(jobName, handler) {
|
|
49
56
|
this.handlers.set(jobName, handler);
|
|
50
57
|
return this;
|
|
51
58
|
}
|
|
52
59
|
/**
|
|
53
|
-
* Encola un job para ser procesado.
|
|
60
|
+
* Encola un job para ser procesado. Devuelve un descriptor que, además de
|
|
61
|
+
* los datos del job, expone `result()` para esperar el valor de retorno del
|
|
62
|
+
* handler.
|
|
54
63
|
*/
|
|
55
64
|
async enqueue(name, data, opts) {
|
|
56
65
|
if (!this.queue) {
|
|
@@ -58,9 +67,19 @@ var WorkerManager = class {
|
|
|
58
67
|
context: { jobName: name }
|
|
59
68
|
});
|
|
60
69
|
}
|
|
70
|
+
this.validateEnqueue(name, data, opts);
|
|
61
71
|
const job = await this.queue.add(name, data, this.mapJobOptions(opts));
|
|
62
72
|
this.app?.logger.debug({ jobId: job.id, jobName: name }, "Job enqueued");
|
|
63
|
-
return
|
|
73
|
+
return this.buildDescriptor(job, name, data);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Programa un job repetible (cron o intervalo). Conveniencia sobre
|
|
77
|
+
* `enqueue` con la opción `repeat` ya configurada.
|
|
78
|
+
*
|
|
79
|
+
* @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.
|
|
80
|
+
*/
|
|
81
|
+
async schedule(name, data, repeat, opts) {
|
|
82
|
+
return this.enqueue(name, data, { ...opts, repeat });
|
|
64
83
|
}
|
|
65
84
|
async start() {
|
|
66
85
|
const connection = this.parseConnection();
|
|
@@ -73,7 +92,7 @@ var WorkerManager = class {
|
|
|
73
92
|
return;
|
|
74
93
|
}
|
|
75
94
|
try {
|
|
76
|
-
await handler({
|
|
95
|
+
return await handler({
|
|
77
96
|
id: job.id,
|
|
78
97
|
name: job.name,
|
|
79
98
|
data: job.data,
|
|
@@ -97,7 +116,7 @@ var WorkerManager = class {
|
|
|
97
116
|
this.app?.logger.debug({ jobId: job.id, jobName: job.name }, "Job completed");
|
|
98
117
|
});
|
|
99
118
|
this.worker.on("failed", (job, err) => {
|
|
100
|
-
this.
|
|
119
|
+
this.onFailed(job, err);
|
|
101
120
|
});
|
|
102
121
|
this.app?.logger.info({
|
|
103
122
|
queue: this.options.queueName || "iskra-jobs",
|
|
@@ -105,14 +124,37 @@ var WorkerManager = class {
|
|
|
105
124
|
}, "WorkerManager started");
|
|
106
125
|
}
|
|
107
126
|
async stop() {
|
|
108
|
-
|
|
127
|
+
this.stopped = true;
|
|
109
128
|
if (this.worker) {
|
|
110
|
-
|
|
129
|
+
try {
|
|
130
|
+
await this.worker.close();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
this.app?.logger.error(
|
|
133
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
134
|
+
"WorkerManager: error while closing worker"
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (this.queueEvents) {
|
|
139
|
+
try {
|
|
140
|
+
await this.queueEvents.close();
|
|
141
|
+
} catch (err) {
|
|
142
|
+
this.app?.logger.error(
|
|
143
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
144
|
+
"WorkerManager: error while closing queue events"
|
|
145
|
+
);
|
|
146
|
+
}
|
|
111
147
|
}
|
|
112
148
|
if (this.queue) {
|
|
113
|
-
|
|
149
|
+
try {
|
|
150
|
+
await this.queue.close();
|
|
151
|
+
} catch (err) {
|
|
152
|
+
this.app?.logger.error(
|
|
153
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
154
|
+
"WorkerManager: error while closing queue"
|
|
155
|
+
);
|
|
156
|
+
}
|
|
114
157
|
}
|
|
115
|
-
await Promise.all(closePromises);
|
|
116
158
|
this.app?.logger.info("WorkerManager stopped");
|
|
117
159
|
}
|
|
118
160
|
parseConnection() {
|
|
@@ -129,7 +171,7 @@ var WorkerManager = class {
|
|
|
129
171
|
}
|
|
130
172
|
mapJobOptions(opts) {
|
|
131
173
|
if (!opts) return void 0;
|
|
132
|
-
|
|
174
|
+
const mapped = {
|
|
133
175
|
attempts: opts.attempts,
|
|
134
176
|
delay: opts.delay,
|
|
135
177
|
priority: opts.priority,
|
|
@@ -137,6 +179,149 @@ var WorkerManager = class {
|
|
|
137
179
|
removeOnComplete: opts.removeOnComplete,
|
|
138
180
|
removeOnFail: opts.removeOnFail
|
|
139
181
|
};
|
|
182
|
+
if (opts.repeat !== void 0) {
|
|
183
|
+
mapped.repeat = this.mapRepeat(opts.repeat);
|
|
184
|
+
}
|
|
185
|
+
return mapped;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Normaliza una RepeatSpec a la forma `repeat` de BullMQ:
|
|
189
|
+
* - string → `{ pattern: cron }`
|
|
190
|
+
* - `{ every }` / `{ pattern }` → se reenvían tal cual.
|
|
191
|
+
*/
|
|
192
|
+
mapRepeat(repeat) {
|
|
193
|
+
if (typeof repeat === "string") {
|
|
194
|
+
return { pattern: repeat };
|
|
195
|
+
}
|
|
196
|
+
return { ...repeat };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que
|
|
200
|
+
* entrada no confiable inunde la queue, almacene payloads gigantes o
|
|
201
|
+
* programe repeticiones malformadas. Lanza `QueueError` ante cualquier
|
|
202
|
+
* problema; no muta nada.
|
|
203
|
+
*/
|
|
204
|
+
validateEnqueue(name, data, opts) {
|
|
205
|
+
if (!this.handlers.has(name)) {
|
|
206
|
+
throw new QueueError(`No handler registered for job "${name}"`, {
|
|
207
|
+
context: { jobName: name }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
this.validatePayloadSize(name, data);
|
|
211
|
+
if (opts?.repeat !== void 0) {
|
|
212
|
+
this.validateRepeat(name, opts.repeat);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Rechaza payloads cuya serialización JSON excede el tope configurado. */
|
|
216
|
+
validatePayloadSize(name, data) {
|
|
217
|
+
let serialized;
|
|
218
|
+
try {
|
|
219
|
+
serialized = JSON.stringify(data ?? null);
|
|
220
|
+
} catch (err) {
|
|
221
|
+
throw new QueueError(`Job "${name}" data is not serializable`, {
|
|
222
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
223
|
+
context: { jobName: name }
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const size = Buffer.byteLength(serialized, "utf8");
|
|
227
|
+
if (size > _WorkerManager.MAX_PAYLOAD_BYTES) {
|
|
228
|
+
throw new QueueError(
|
|
229
|
+
`Job "${name}" payload too large: ${size} bytes (max ${_WorkerManager.MAX_PAYLOAD_BYTES})`,
|
|
230
|
+
{ context: { jobName: name, size, max: _WorkerManager.MAX_PAYLOAD_BYTES } }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */
|
|
235
|
+
validateRepeat(name, repeat) {
|
|
236
|
+
if (typeof repeat === "string") {
|
|
237
|
+
if (repeat.trim().length === 0) {
|
|
238
|
+
throw new QueueError(`Job "${name}" has an empty cron repeat pattern`, {
|
|
239
|
+
context: { jobName: name }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const hasEvery = "every" in repeat;
|
|
245
|
+
const hasPattern = "pattern" in repeat;
|
|
246
|
+
if (!hasEvery && !hasPattern) {
|
|
247
|
+
throw new QueueError(`Job "${name}" repeat spec must define "every" or "pattern"`, {
|
|
248
|
+
context: { jobName: name }
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
if (hasEvery && !(typeof repeat.every === "number" && repeat.every > 0)) {
|
|
252
|
+
throw new QueueError(`Job "${name}" repeat "every" must be a positive number`, {
|
|
253
|
+
context: { jobName: name, every: repeat.every }
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (hasPattern && (typeof repeat.pattern !== "string" || repeat.pattern.trim().length === 0)) {
|
|
257
|
+
throw new QueueError(`Job "${name}" repeat "pattern" must be a non-empty cron string`, {
|
|
258
|
+
context: { jobName: name }
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó
|
|
264
|
+
* todos sus reintentos y `deadLetter` está activado, emite
|
|
265
|
+
* `worker:dead-letter` en el bus de eventos de la App.
|
|
266
|
+
*/
|
|
267
|
+
onFailed(job, err) {
|
|
268
|
+
this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, "Job failed");
|
|
269
|
+
if (!this.options.deadLetter || !job) return;
|
|
270
|
+
const maxAttempts = job.opts?.attempts ?? 1;
|
|
271
|
+
if (job.attemptsMade < maxAttempts) return;
|
|
272
|
+
const payload = {
|
|
273
|
+
jobId: job.id,
|
|
274
|
+
name: job.name,
|
|
275
|
+
data: job.data,
|
|
276
|
+
failedReason: job.failedReason ?? err?.message,
|
|
277
|
+
attemptsMade: job.attemptsMade
|
|
278
|
+
};
|
|
279
|
+
this.app?.events.emit("worker:dead-letter", payload);
|
|
280
|
+
this.app?.logger.warn(
|
|
281
|
+
{ jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
|
|
282
|
+
"Job routed to dead-letter"
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Construye el descriptor de un job, incluyendo el helper `result()` que
|
|
287
|
+
* espera el valor de retorno del handler vía `job.waitUntilFinished`.
|
|
288
|
+
*/
|
|
289
|
+
buildDescriptor(job, name, data) {
|
|
290
|
+
return {
|
|
291
|
+
id: job.id,
|
|
292
|
+
name,
|
|
293
|
+
data,
|
|
294
|
+
// async so a post-stop getQueueEvents() throw surfaces as a rejected
|
|
295
|
+
// promise rather than a synchronous throw.
|
|
296
|
+
result: async (ttlMs) => {
|
|
297
|
+
const queueEvents = this.getQueueEvents();
|
|
298
|
+
return job.waitUntilFinished(queueEvents, ttlMs);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Devuelve (creando perezosamente) una instancia compartida de QueueEvents
|
|
304
|
+
* usada para esperar resultados de jobs.
|
|
305
|
+
*/
|
|
306
|
+
getQueueEvents() {
|
|
307
|
+
if (this.stopped) {
|
|
308
|
+
throw new QueueError("WorkerManager is stopped; cannot open QueueEvents", {
|
|
309
|
+
context: { queueName: this.options.queueName || "iskra-jobs" }
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
if (!this.queueEvents) {
|
|
313
|
+
try {
|
|
314
|
+
this.queueEvents = new QueueEvents(this.options.queueName || "iskra-jobs", {
|
|
315
|
+
connection: this.parseConnection()
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
throw new QueueError("Failed to initialize BullMQ QueueEvents", {
|
|
319
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
320
|
+
context: { queueName: this.options.queueName || "iskra-jobs" }
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return this.queueEvents;
|
|
140
325
|
}
|
|
141
326
|
};
|
|
142
327
|
export {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/errors.ts"],"sourcesContent":["import type { Driver, App } from '@iskra-bun/core';\nimport { Queue, Worker, type Job as BullJob } from 'bullmq';\nimport { QueueError, JobError } from './errors';\nimport type { WorkerManagerOptions, JobOptions, JobHandler } from './types';\n\nexport type { WorkerManagerOptions, JobOptions, JobHandler } from './types';\nexport * from './errors';\n\nexport class WorkerManager implements Driver {\n name = 'WorkerManager';\n private app: App | null = null;\n private handlers: Map<string, JobHandler> = new Map();\n private queue: Queue | null = null;\n private worker: Worker | null = null;\n private options: WorkerManagerOptions;\n\n constructor(options: WorkerManagerOptions) {\n this.options = options;\n }\n\n async init(app: App) {\n this.app = app;\n\n const connection = this.parseConnection();\n\n try {\n this.queue = new Queue(this.options.queueName || 'iskra-jobs', {\n connection,\n defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),\n });\n } catch (err) {\n throw new QueueError('Failed to initialize BullMQ queue', {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { queueName: this.options.queueName || 'iskra-jobs' },\n });\n }\n }\n\n /**\n * Registra un handler para un tipo de job.\n */\n register(jobName: string, handler: JobHandler) {\n this.handlers.set(jobName, handler);\n return this;\n }\n\n /**\n * Encola un job para ser procesado.\n */\n async enqueue(name: string, data: any, opts?: JobOptions) {\n if (!this.queue) {\n throw new QueueError('Queue not initialized. Did you call init()?', {\n context: { jobName: name },\n });\n }\n\n const job = await this.queue.add(name, data, this.mapJobOptions(opts));\n this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');\n return { id: job.id!, name, data };\n }\n\n async start() {\n const connection = this.parseConnection();\n\n this.worker = new Worker(\n this.options.queueName || 'iskra-jobs',\n async (job: BullJob) => {\n const handler = this.handlers.get(job.name);\n if (!handler) {\n this.app?.logger.warn({ jobName: job.name, jobId: job.id }, 'No handler registered for job');\n return;\n }\n\n try {\n await handler({\n id: job.id!,\n name: job.name,\n data: job.data,\n attemptsMade: job.attemptsMade,\n });\n } catch (err) {\n const jobErr = new JobError(`Job \"${job.name}\" failed`, {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },\n });\n this.app?.logger.error({ err: jobErr }, jobErr.message);\n throw err; // Re-throw para que BullMQ maneje el retry\n }\n },\n {\n connection,\n concurrency: this.options.concurrency || 1,\n },\n );\n\n this.worker.on('completed', (job) => {\n this.app?.logger.debug({ jobId: job.id, jobName: job.name }, 'Job completed');\n });\n\n this.worker.on('failed', (job, err) => {\n this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');\n });\n\n this.app?.logger.info({\n queue: this.options.queueName || 'iskra-jobs',\n concurrency: this.options.concurrency || 1,\n }, 'WorkerManager started');\n }\n\n async stop() {\n const closePromises: Promise<void>[] = [];\n\n if (this.worker) {\n closePromises.push(this.worker.close());\n }\n if (this.queue) {\n closePromises.push(this.queue.close());\n }\n\n await Promise.all(closePromises);\n this.app?.logger.info('WorkerManager stopped');\n }\n\n private parseConnection() {\n if (typeof this.options.connection === 'string') {\n const url = new URL(this.options.connection);\n return {\n host: url.hostname,\n port: Number(url.port) || 6379,\n password: url.password || undefined,\n db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,\n };\n }\n return this.options.connection;\n }\n\n private mapJobOptions(opts?: JobOptions) {\n if (!opts) return undefined;\n return {\n attempts: opts.attempts,\n delay: opts.delay,\n priority: opts.priority,\n backoff: opts.backoff,\n removeOnComplete: opts.removeOnComplete,\n removeOnFail: opts.removeOnFail,\n };\n }\n}\n","import { IskraError, ErrorCodes } from '@iskra-bun/core';\n\n// ─── Queue Error ─────────────────────────────────────────────────────────────\n\nexport class QueueError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.QUEUE_ERROR, ...options });\n this.name = 'QueueError';\n }\n}\n\n// ─── Job Error ───────────────────────────────────────────────────────────────\n\nexport class JobError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.JOB_ERROR, ...options });\n this.name = 'JobError';\n }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/errors.ts"],"sourcesContent":["import type { Driver, App } from '@iskra-bun/core';\nimport { Queue, QueueEvents, Worker, type Job as BullJob } from 'bullmq';\nimport { QueueError, JobError } from './errors';\nimport type {\n WorkerManagerOptions,\n JobOptions,\n JobHandler,\n RepeatSpec,\n JobDescriptor,\n DeadLetterPayload,\n} from './types';\n\nexport type {\n WorkerManagerOptions,\n JobOptions,\n JobHandler,\n RepeatSpec,\n JobDescriptor,\n DeadLetterPayload,\n} from './types';\nexport * from './errors';\n\nexport class WorkerManager implements Driver {\n name = 'WorkerManager';\n private app: App | null = null;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n private handlers: Map<string, JobHandler<any, any>> = new Map();\n private queue: Queue | null = null;\n private worker: Worker | null = null;\n private queueEvents: QueueEvents | null = null;\n private stopped = false;\n private options: WorkerManagerOptions;\n\n /** Tope de tamaño (bytes) del payload serializado de un job. */\n private static readonly MAX_PAYLOAD_BYTES = 1024 * 1024; // 1 MB\n\n constructor(options: WorkerManagerOptions) {\n this.options = options;\n }\n\n async init(app: App) {\n this.app = app;\n\n const connection = this.parseConnection();\n\n try {\n this.queue = new Queue(this.options.queueName || 'iskra-jobs', {\n connection,\n defaultJobOptions: this.mapJobOptions(this.options.defaultJobOptions),\n });\n } catch (err) {\n throw new QueueError('Failed to initialize BullMQ queue', {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { queueName: this.options.queueName || 'iskra-jobs' },\n });\n }\n }\n\n /**\n * Registra un handler para un tipo de job. El handler puede devolver un\n * valor `R` que queda disponible como resultado del job.\n */\n register<T = unknown, R = void>(jobName: string, handler: JobHandler<T, R>) {\n this.handlers.set(jobName, handler);\n return this;\n }\n\n /**\n * Encola un job para ser procesado. Devuelve un descriptor que, además de\n * los datos del job, expone `result()` para esperar el valor de retorno del\n * handler.\n */\n async enqueue<T = unknown, R = unknown>(\n name: string,\n data: T,\n opts?: JobOptions,\n ): Promise<JobDescriptor<T, R>> {\n if (!this.queue) {\n throw new QueueError('Queue not initialized. Did you call init()?', {\n context: { jobName: name },\n });\n }\n\n this.validateEnqueue(name, data, opts);\n\n const job = await this.queue.add(name, data, this.mapJobOptions(opts));\n this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');\n return this.buildDescriptor<T, R>(job, name, data);\n }\n\n /**\n * Programa un job repetible (cron o intervalo). Conveniencia sobre\n * `enqueue` con la opción `repeat` ya configurada.\n *\n * @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.\n */\n async schedule<T = unknown, R = unknown>(\n name: string,\n data: T,\n repeat: RepeatSpec,\n opts?: JobOptions,\n ): Promise<JobDescriptor<T, R>> {\n return this.enqueue<T, R>(name, data, { ...opts, repeat });\n }\n\n async start() {\n const connection = this.parseConnection();\n\n this.worker = new Worker(\n this.options.queueName || 'iskra-jobs',\n async (job: BullJob) => {\n const handler = this.handlers.get(job.name);\n if (!handler) {\n this.app?.logger.warn({ jobName: job.name, jobId: job.id }, 'No handler registered for job');\n return;\n }\n\n try {\n return await handler({\n id: job.id!,\n name: job.name,\n data: job.data,\n attemptsMade: job.attemptsMade,\n });\n } catch (err) {\n const jobErr = new JobError(`Job \"${job.name}\" failed`, {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },\n });\n this.app?.logger.error({ err: jobErr }, jobErr.message);\n throw err; // Re-throw para que BullMQ maneje el retry\n }\n },\n {\n connection,\n concurrency: this.options.concurrency || 1,\n },\n );\n\n this.worker.on('completed', (job) => {\n this.app?.logger.debug({ jobId: job.id, jobName: job.name }, 'Job completed');\n });\n\n this.worker.on('failed', (job, err) => {\n this.onFailed(job, err);\n });\n\n this.app?.logger.info({\n queue: this.options.queueName || 'iskra-jobs',\n concurrency: this.options.concurrency || 1,\n }, 'WorkerManager started');\n }\n\n async stop() {\n // Mark as stopped first so any in-flight result()/getQueueEvents() call\n // throws instead of lazily opening a fresh, never-closed QueueEvents.\n this.stopped = true;\n\n // Close the worker first (without force) so BullMQ waits for any\n // in-flight job to finish before tearing down its Redis connections.\n // Only then close the queue — closing them concurrently can cut the\n // queue connection out from under a still-draining worker.\n if (this.worker) {\n try {\n await this.worker.close();\n } catch (err) {\n this.app?.logger.error(\n { err: err instanceof Error ? err : new Error(String(err)) },\n 'WorkerManager: error while closing worker',\n );\n }\n }\n\n if (this.queueEvents) {\n try {\n await this.queueEvents.close();\n } catch (err) {\n this.app?.logger.error(\n { err: err instanceof Error ? err : new Error(String(err)) },\n 'WorkerManager: error while closing queue events',\n );\n }\n }\n\n if (this.queue) {\n try {\n await this.queue.close();\n } catch (err) {\n this.app?.logger.error(\n { err: err instanceof Error ? err : new Error(String(err)) },\n 'WorkerManager: error while closing queue',\n );\n }\n }\n\n this.app?.logger.info('WorkerManager stopped');\n }\n\n private parseConnection() {\n if (typeof this.options.connection === 'string') {\n const url = new URL(this.options.connection);\n return {\n host: url.hostname,\n port: Number(url.port) || 6379,\n password: url.password || undefined,\n db: url.pathname ? Number(url.pathname.slice(1)) || 0 : 0,\n };\n }\n return this.options.connection;\n }\n\n private mapJobOptions(opts?: JobOptions) {\n if (!opts) return undefined;\n const mapped: Record<string, unknown> = {\n attempts: opts.attempts,\n delay: opts.delay,\n priority: opts.priority,\n backoff: opts.backoff,\n removeOnComplete: opts.removeOnComplete,\n removeOnFail: opts.removeOnFail,\n };\n if (opts.repeat !== undefined) {\n mapped.repeat = this.mapRepeat(opts.repeat);\n }\n return mapped;\n }\n\n /**\n * Normaliza una RepeatSpec a la forma `repeat` de BullMQ:\n * - string → `{ pattern: cron }`\n * - `{ every }` / `{ pattern }` → se reenvían tal cual.\n */\n private mapRepeat(repeat: RepeatSpec) {\n if (typeof repeat === 'string') {\n return { pattern: repeat };\n }\n return { ...repeat };\n }\n\n /**\n * Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que\n * entrada no confiable inunde la queue, almacene payloads gigantes o\n * programe repeticiones malformadas. Lanza `QueueError` ante cualquier\n * problema; no muta nada.\n */\n private validateEnqueue(name: string, data: unknown, opts?: JobOptions) {\n if (!this.handlers.has(name)) {\n throw new QueueError(`No handler registered for job \"${name}\"`, {\n context: { jobName: name },\n });\n }\n\n this.validatePayloadSize(name, data);\n\n if (opts?.repeat !== undefined) {\n this.validateRepeat(name, opts.repeat);\n }\n }\n\n /** Rechaza payloads cuya serialización JSON excede el tope configurado. */\n private validatePayloadSize(name: string, data: unknown) {\n let serialized: string;\n try {\n serialized = JSON.stringify(data ?? null);\n } catch (err) {\n throw new QueueError(`Job \"${name}\" data is not serializable`, {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { jobName: name },\n });\n }\n\n const size = Buffer.byteLength(serialized, 'utf8');\n if (size > WorkerManager.MAX_PAYLOAD_BYTES) {\n throw new QueueError(\n `Job \"${name}\" payload too large: ${size} bytes (max ${WorkerManager.MAX_PAYLOAD_BYTES})`,\n { context: { jobName: name, size, max: WorkerManager.MAX_PAYLOAD_BYTES } },\n );\n }\n }\n\n /** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */\n private validateRepeat(name: string, repeat: RepeatSpec) {\n if (typeof repeat === 'string') {\n if (repeat.trim().length === 0) {\n throw new QueueError(`Job \"${name}\" has an empty cron repeat pattern`, {\n context: { jobName: name },\n });\n }\n return;\n }\n\n const hasEvery = 'every' in repeat;\n const hasPattern = 'pattern' in repeat;\n if (!hasEvery && !hasPattern) {\n throw new QueueError(`Job \"${name}\" repeat spec must define \"every\" or \"pattern\"`, {\n context: { jobName: name },\n });\n }\n\n if (hasEvery && !(typeof repeat.every === 'number' && repeat.every > 0)) {\n throw new QueueError(`Job \"${name}\" repeat \"every\" must be a positive number`, {\n context: { jobName: name, every: repeat.every },\n });\n }\n\n if (hasPattern && (typeof repeat.pattern !== 'string' || repeat.pattern.trim().length === 0)) {\n throw new QueueError(`Job \"${name}\" repeat \"pattern\" must be a non-empty cron string`, {\n context: { jobName: name },\n });\n }\n }\n\n /**\n * Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó\n * todos sus reintentos y `deadLetter` está activado, emite\n * `worker:dead-letter` en el bus de eventos de la App.\n */\n private onFailed(job: BullJob | undefined, err: Error) {\n this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');\n\n if (!this.options.deadLetter || !job) return;\n\n // BullMQ default attempts is 1 when unspecified.\n //\n // Assumed BullMQ `attemptsMade` semantics at the `failed` event: on a\n // job's TERMINAL failure (all retries exhausted) BullMQ reports\n // `attemptsMade == opts.attempts`, so `attemptsMade < maxAttempts`\n // identifies a non-terminal failure with a retry still pending. This is\n // verified against bullmq 5.78 (see test/dead-letter-attempts*.test.ts);\n // a future bump that changes `attemptsMade` reporting will fail those\n // tests loudly rather than silently skip dead-lettering.\n const maxAttempts = job.opts?.attempts ?? 1;\n if (job.attemptsMade < maxAttempts) return;\n\n const payload: DeadLetterPayload = {\n jobId: job.id,\n name: job.name,\n data: job.data,\n failedReason: job.failedReason ?? err?.message,\n attemptsMade: job.attemptsMade,\n };\n this.app?.events.emit('worker:dead-letter', payload);\n this.app?.logger.warn(\n { jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },\n 'Job routed to dead-letter',\n );\n }\n\n /**\n * Construye el descriptor de un job, incluyendo el helper `result()` que\n * espera el valor de retorno del handler vía `job.waitUntilFinished`.\n */\n private buildDescriptor<T, R>(job: BullJob, name: string, data: T): JobDescriptor<T, R> {\n return {\n id: job.id!,\n name,\n data,\n // async so a post-stop getQueueEvents() throw surfaces as a rejected\n // promise rather than a synchronous throw.\n result: async (ttlMs?: number): Promise<R> => {\n const queueEvents = this.getQueueEvents();\n return job.waitUntilFinished(queueEvents, ttlMs) as Promise<R>;\n },\n };\n }\n\n /**\n * Devuelve (creando perezosamente) una instancia compartida de QueueEvents\n * usada para esperar resultados de jobs.\n */\n private getQueueEvents(): QueueEvents {\n if (this.stopped) {\n throw new QueueError('WorkerManager is stopped; cannot open QueueEvents', {\n context: { queueName: this.options.queueName || 'iskra-jobs' },\n });\n }\n if (!this.queueEvents) {\n try {\n this.queueEvents = new QueueEvents(this.options.queueName || 'iskra-jobs', {\n connection: this.parseConnection(),\n });\n } catch (err) {\n throw new QueueError('Failed to initialize BullMQ QueueEvents', {\n cause: err instanceof Error ? err : new Error(String(err)),\n context: { queueName: this.options.queueName || 'iskra-jobs' },\n });\n }\n }\n return this.queueEvents;\n }\n}\n","import { IskraError, ErrorCodes } from '@iskra-bun/core';\n\n// ─── Queue Error ─────────────────────────────────────────────────────────────\n\nexport class QueueError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.QUEUE_ERROR, ...options });\n this.name = 'QueueError';\n }\n}\n\n// ─── Job Error ───────────────────────────────────────────────────────────────\n\nexport class JobError extends IskraError {\n constructor(message: string, options?: { cause?: Error; context?: Record<string, unknown> }) {\n super(message, { code: ErrorCodes.JOB_ERROR, ...options });\n this.name = 'JobError';\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iskra-bun/worker-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Cola de jobs en segundo plano para Iskra con BullMQ.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iskra",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"build": "tsup --config ../../tsup.config.ts"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@iskra-bun/core": "0.1.
|
|
50
|
+
"@iskra-bun/core": "0.1.1",
|
|
51
51
|
"bullmq": "^5.0.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
import type { Driver, App } from '@iskra-bun/core';
|
|
2
|
-
import { Queue, Worker, type Job as BullJob } from 'bullmq';
|
|
2
|
+
import { Queue, QueueEvents, 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
|
|
|
9
23
|
export class WorkerManager implements Driver {
|
|
10
24
|
name = 'WorkerManager';
|
|
11
25
|
private app: App | null = null;
|
|
12
|
-
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
+
private handlers: Map<string, JobHandler<any, any>> = new Map();
|
|
13
28
|
private queue: Queue | null = null;
|
|
14
29
|
private worker: Worker | null = null;
|
|
30
|
+
private queueEvents: QueueEvents | null = null;
|
|
31
|
+
private stopped = false;
|
|
15
32
|
private options: WorkerManagerOptions;
|
|
16
33
|
|
|
34
|
+
/** Tope de tamaño (bytes) del payload serializado de un job. */
|
|
35
|
+
private static readonly MAX_PAYLOAD_BYTES = 1024 * 1024; // 1 MB
|
|
36
|
+
|
|
17
37
|
constructor(options: WorkerManagerOptions) {
|
|
18
38
|
this.options = options;
|
|
19
39
|
}
|
|
@@ -37,26 +57,50 @@ export class WorkerManager implements Driver {
|
|
|
37
57
|
}
|
|
38
58
|
|
|
39
59
|
/**
|
|
40
|
-
* Registra un handler para un tipo de job.
|
|
60
|
+
* Registra un handler para un tipo de job. El handler puede devolver un
|
|
61
|
+
* valor `R` que queda disponible como resultado del job.
|
|
41
62
|
*/
|
|
42
|
-
register(jobName: string, handler: JobHandler) {
|
|
63
|
+
register<T = unknown, R = void>(jobName: string, handler: JobHandler<T, R>) {
|
|
43
64
|
this.handlers.set(jobName, handler);
|
|
44
65
|
return this;
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
/**
|
|
48
|
-
* Encola un job para ser procesado.
|
|
69
|
+
* Encola un job para ser procesado. Devuelve un descriptor que, además de
|
|
70
|
+
* los datos del job, expone `result()` para esperar el valor de retorno del
|
|
71
|
+
* handler.
|
|
49
72
|
*/
|
|
50
|
-
async enqueue
|
|
73
|
+
async enqueue<T = unknown, R = unknown>(
|
|
74
|
+
name: string,
|
|
75
|
+
data: T,
|
|
76
|
+
opts?: JobOptions,
|
|
77
|
+
): Promise<JobDescriptor<T, R>> {
|
|
51
78
|
if (!this.queue) {
|
|
52
79
|
throw new QueueError('Queue not initialized. Did you call init()?', {
|
|
53
80
|
context: { jobName: name },
|
|
54
81
|
});
|
|
55
82
|
}
|
|
56
83
|
|
|
84
|
+
this.validateEnqueue(name, data, opts);
|
|
85
|
+
|
|
57
86
|
const job = await this.queue.add(name, data, this.mapJobOptions(opts));
|
|
58
87
|
this.app?.logger.debug({ jobId: job.id, jobName: name }, 'Job enqueued');
|
|
59
|
-
return
|
|
88
|
+
return this.buildDescriptor<T, R>(job, name, data);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Programa un job repetible (cron o intervalo). Conveniencia sobre
|
|
93
|
+
* `enqueue` con la opción `repeat` ya configurada.
|
|
94
|
+
*
|
|
95
|
+
* @param repeat patrón cron (string) o `{ every: ms }`/`{ pattern: cron }`.
|
|
96
|
+
*/
|
|
97
|
+
async schedule<T = unknown, R = unknown>(
|
|
98
|
+
name: string,
|
|
99
|
+
data: T,
|
|
100
|
+
repeat: RepeatSpec,
|
|
101
|
+
opts?: JobOptions,
|
|
102
|
+
): Promise<JobDescriptor<T, R>> {
|
|
103
|
+
return this.enqueue<T, R>(name, data, { ...opts, repeat });
|
|
60
104
|
}
|
|
61
105
|
|
|
62
106
|
async start() {
|
|
@@ -72,7 +116,7 @@ export class WorkerManager implements Driver {
|
|
|
72
116
|
}
|
|
73
117
|
|
|
74
118
|
try {
|
|
75
|
-
await handler({
|
|
119
|
+
return await handler({
|
|
76
120
|
id: job.id!,
|
|
77
121
|
name: job.name,
|
|
78
122
|
data: job.data,
|
|
@@ -98,7 +142,7 @@ export class WorkerManager implements Driver {
|
|
|
98
142
|
});
|
|
99
143
|
|
|
100
144
|
this.worker.on('failed', (job, err) => {
|
|
101
|
-
this.
|
|
145
|
+
this.onFailed(job, err);
|
|
102
146
|
});
|
|
103
147
|
|
|
104
148
|
this.app?.logger.info({
|
|
@@ -108,16 +152,47 @@ export class WorkerManager implements Driver {
|
|
|
108
152
|
}
|
|
109
153
|
|
|
110
154
|
async stop() {
|
|
111
|
-
|
|
155
|
+
// Mark as stopped first so any in-flight result()/getQueueEvents() call
|
|
156
|
+
// throws instead of lazily opening a fresh, never-closed QueueEvents.
|
|
157
|
+
this.stopped = true;
|
|
112
158
|
|
|
159
|
+
// Close the worker first (without force) so BullMQ waits for any
|
|
160
|
+
// in-flight job to finish before tearing down its Redis connections.
|
|
161
|
+
// Only then close the queue — closing them concurrently can cut the
|
|
162
|
+
// queue connection out from under a still-draining worker.
|
|
113
163
|
if (this.worker) {
|
|
114
|
-
|
|
164
|
+
try {
|
|
165
|
+
await this.worker.close();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
this.app?.logger.error(
|
|
168
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
169
|
+
'WorkerManager: error while closing worker',
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.queueEvents) {
|
|
175
|
+
try {
|
|
176
|
+
await this.queueEvents.close();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
this.app?.logger.error(
|
|
179
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
180
|
+
'WorkerManager: error while closing queue events',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
115
183
|
}
|
|
184
|
+
|
|
116
185
|
if (this.queue) {
|
|
117
|
-
|
|
186
|
+
try {
|
|
187
|
+
await this.queue.close();
|
|
188
|
+
} catch (err) {
|
|
189
|
+
this.app?.logger.error(
|
|
190
|
+
{ err: err instanceof Error ? err : new Error(String(err)) },
|
|
191
|
+
'WorkerManager: error while closing queue',
|
|
192
|
+
);
|
|
193
|
+
}
|
|
118
194
|
}
|
|
119
195
|
|
|
120
|
-
await Promise.all(closePromises);
|
|
121
196
|
this.app?.logger.info('WorkerManager stopped');
|
|
122
197
|
}
|
|
123
198
|
|
|
@@ -136,7 +211,7 @@ export class WorkerManager implements Driver {
|
|
|
136
211
|
|
|
137
212
|
private mapJobOptions(opts?: JobOptions) {
|
|
138
213
|
if (!opts) return undefined;
|
|
139
|
-
|
|
214
|
+
const mapped: Record<string, unknown> = {
|
|
140
215
|
attempts: opts.attempts,
|
|
141
216
|
delay: opts.delay,
|
|
142
217
|
priority: opts.priority,
|
|
@@ -144,5 +219,173 @@ export class WorkerManager implements Driver {
|
|
|
144
219
|
removeOnComplete: opts.removeOnComplete,
|
|
145
220
|
removeOnFail: opts.removeOnFail,
|
|
146
221
|
};
|
|
222
|
+
if (opts.repeat !== undefined) {
|
|
223
|
+
mapped.repeat = this.mapRepeat(opts.repeat);
|
|
224
|
+
}
|
|
225
|
+
return mapped;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Normaliza una RepeatSpec a la forma `repeat` de BullMQ:
|
|
230
|
+
* - string → `{ pattern: cron }`
|
|
231
|
+
* - `{ every }` / `{ pattern }` → se reenvían tal cual.
|
|
232
|
+
*/
|
|
233
|
+
private mapRepeat(repeat: RepeatSpec) {
|
|
234
|
+
if (typeof repeat === 'string') {
|
|
235
|
+
return { pattern: repeat };
|
|
236
|
+
}
|
|
237
|
+
return { ...repeat };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Valida la entrada de `enqueue` ANTES de tocar Redis, para evitar que
|
|
242
|
+
* entrada no confiable inunde la queue, almacene payloads gigantes o
|
|
243
|
+
* programe repeticiones malformadas. Lanza `QueueError` ante cualquier
|
|
244
|
+
* problema; no muta nada.
|
|
245
|
+
*/
|
|
246
|
+
private validateEnqueue(name: string, data: unknown, opts?: JobOptions) {
|
|
247
|
+
if (!this.handlers.has(name)) {
|
|
248
|
+
throw new QueueError(`No handler registered for job "${name}"`, {
|
|
249
|
+
context: { jobName: name },
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
this.validatePayloadSize(name, data);
|
|
254
|
+
|
|
255
|
+
if (opts?.repeat !== undefined) {
|
|
256
|
+
this.validateRepeat(name, opts.repeat);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Rechaza payloads cuya serialización JSON excede el tope configurado. */
|
|
261
|
+
private validatePayloadSize(name: string, data: unknown) {
|
|
262
|
+
let serialized: string;
|
|
263
|
+
try {
|
|
264
|
+
serialized = JSON.stringify(data ?? null);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
throw new QueueError(`Job "${name}" data is not serializable`, {
|
|
267
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
268
|
+
context: { jobName: name },
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const size = Buffer.byteLength(serialized, 'utf8');
|
|
273
|
+
if (size > WorkerManager.MAX_PAYLOAD_BYTES) {
|
|
274
|
+
throw new QueueError(
|
|
275
|
+
`Job "${name}" payload too large: ${size} bytes (max ${WorkerManager.MAX_PAYLOAD_BYTES})`,
|
|
276
|
+
{ context: { jobName: name, size, max: WorkerManager.MAX_PAYLOAD_BYTES } },
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Rechaza specs de repetición vacías, intervalos no positivos o crons en blanco. */
|
|
282
|
+
private validateRepeat(name: string, repeat: RepeatSpec) {
|
|
283
|
+
if (typeof repeat === 'string') {
|
|
284
|
+
if (repeat.trim().length === 0) {
|
|
285
|
+
throw new QueueError(`Job "${name}" has an empty cron repeat pattern`, {
|
|
286
|
+
context: { jobName: name },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const hasEvery = 'every' in repeat;
|
|
293
|
+
const hasPattern = 'pattern' in repeat;
|
|
294
|
+
if (!hasEvery && !hasPattern) {
|
|
295
|
+
throw new QueueError(`Job "${name}" repeat spec must define "every" or "pattern"`, {
|
|
296
|
+
context: { jobName: name },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (hasEvery && !(typeof repeat.every === 'number' && repeat.every > 0)) {
|
|
301
|
+
throw new QueueError(`Job "${name}" repeat "every" must be a positive number`, {
|
|
302
|
+
context: { jobName: name, every: repeat.every },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (hasPattern && (typeof repeat.pattern !== 'string' || repeat.pattern.trim().length === 0)) {
|
|
307
|
+
throw new QueueError(`Job "${name}" repeat "pattern" must be a non-empty cron string`, {
|
|
308
|
+
context: { jobName: name },
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Maneja el evento `failed` del worker. Loggea el fallo y, si el job agotó
|
|
315
|
+
* todos sus reintentos y `deadLetter` está activado, emite
|
|
316
|
+
* `worker:dead-letter` en el bus de eventos de la App.
|
|
317
|
+
*/
|
|
318
|
+
private onFailed(job: BullJob | undefined, err: Error) {
|
|
319
|
+
this.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');
|
|
320
|
+
|
|
321
|
+
if (!this.options.deadLetter || !job) return;
|
|
322
|
+
|
|
323
|
+
// BullMQ default attempts is 1 when unspecified.
|
|
324
|
+
//
|
|
325
|
+
// Assumed BullMQ `attemptsMade` semantics at the `failed` event: on a
|
|
326
|
+
// job's TERMINAL failure (all retries exhausted) BullMQ reports
|
|
327
|
+
// `attemptsMade == opts.attempts`, so `attemptsMade < maxAttempts`
|
|
328
|
+
// identifies a non-terminal failure with a retry still pending. This is
|
|
329
|
+
// verified against bullmq 5.78 (see test/dead-letter-attempts*.test.ts);
|
|
330
|
+
// a future bump that changes `attemptsMade` reporting will fail those
|
|
331
|
+
// tests loudly rather than silently skip dead-lettering.
|
|
332
|
+
const maxAttempts = job.opts?.attempts ?? 1;
|
|
333
|
+
if (job.attemptsMade < maxAttempts) return;
|
|
334
|
+
|
|
335
|
+
const payload: DeadLetterPayload = {
|
|
336
|
+
jobId: job.id,
|
|
337
|
+
name: job.name,
|
|
338
|
+
data: job.data,
|
|
339
|
+
failedReason: job.failedReason ?? err?.message,
|
|
340
|
+
attemptsMade: job.attemptsMade,
|
|
341
|
+
};
|
|
342
|
+
this.app?.events.emit('worker:dead-letter', payload);
|
|
343
|
+
this.app?.logger.warn(
|
|
344
|
+
{ jobId: job.id, jobName: job.name, attemptsMade: job.attemptsMade },
|
|
345
|
+
'Job routed to dead-letter',
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Construye el descriptor de un job, incluyendo el helper `result()` que
|
|
351
|
+
* espera el valor de retorno del handler vía `job.waitUntilFinished`.
|
|
352
|
+
*/
|
|
353
|
+
private buildDescriptor<T, R>(job: BullJob, name: string, data: T): JobDescriptor<T, R> {
|
|
354
|
+
return {
|
|
355
|
+
id: job.id!,
|
|
356
|
+
name,
|
|
357
|
+
data,
|
|
358
|
+
// async so a post-stop getQueueEvents() throw surfaces as a rejected
|
|
359
|
+
// promise rather than a synchronous throw.
|
|
360
|
+
result: async (ttlMs?: number): Promise<R> => {
|
|
361
|
+
const queueEvents = this.getQueueEvents();
|
|
362
|
+
return job.waitUntilFinished(queueEvents, ttlMs) as Promise<R>;
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Devuelve (creando perezosamente) una instancia compartida de QueueEvents
|
|
369
|
+
* usada para esperar resultados de jobs.
|
|
370
|
+
*/
|
|
371
|
+
private getQueueEvents(): QueueEvents {
|
|
372
|
+
if (this.stopped) {
|
|
373
|
+
throw new QueueError('WorkerManager is stopped; cannot open QueueEvents', {
|
|
374
|
+
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
if (!this.queueEvents) {
|
|
378
|
+
try {
|
|
379
|
+
this.queueEvents = new QueueEvents(this.options.queueName || 'iskra-jobs', {
|
|
380
|
+
connection: this.parseConnection(),
|
|
381
|
+
});
|
|
382
|
+
} catch (err) {
|
|
383
|
+
throw new QueueError('Failed to initialize BullMQ QueueEvents', {
|
|
384
|
+
cause: err instanceof Error ? err : new Error(String(err)),
|
|
385
|
+
context: { queueName: this.options.queueName || 'iskra-jobs' },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return this.queueEvents;
|
|
147
390
|
}
|
|
148
391
|
}
|
package/src/types.ts
CHANGED
|
@@ -7,8 +7,26 @@ export interface WorkerManagerOptions {
|
|
|
7
7
|
queueName?: string;
|
|
8
8
|
/** Opciones por defecto para cada job */
|
|
9
9
|
defaultJobOptions?: JobOptions;
|
|
10
|
+
/**
|
|
11
|
+
* Activa el ruteo a dead-letter: cuando un job agota todos sus reintentos
|
|
12
|
+
* se emite el evento `worker:dead-letter` en el bus de eventos de la App.
|
|
13
|
+
* Opt-in para no cambiar el comportamiento existente (default: false).
|
|
14
|
+
*/
|
|
15
|
+
deadLetter?: boolean;
|
|
10
16
|
}
|
|
11
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Especificación de repetición para jobs programados.
|
|
20
|
+
*
|
|
21
|
+
* - Un string se interpreta como un patrón cron (ej: '0 0 * * *').
|
|
22
|
+
* - `{ every: ms }` repite cada `ms` milisegundos.
|
|
23
|
+
* - `{ pattern: cron }` repite según el patrón cron, con opciones extra.
|
|
24
|
+
*/
|
|
25
|
+
export type RepeatSpec =
|
|
26
|
+
| string
|
|
27
|
+
| { every: number; limit?: number }
|
|
28
|
+
| { pattern: string; limit?: number; tz?: string };
|
|
29
|
+
|
|
12
30
|
export interface JobOptions {
|
|
13
31
|
/** Reintentos en caso de fallo */
|
|
14
32
|
attempts?: number;
|
|
@@ -25,10 +43,48 @@ export interface JobOptions {
|
|
|
25
43
|
removeOnComplete?: boolean | number;
|
|
26
44
|
/** Eliminar el job de Redis al fallar */
|
|
27
45
|
removeOnFail?: boolean | number;
|
|
46
|
+
/**
|
|
47
|
+
* Programa el job como repetible (cron o intervalo).
|
|
48
|
+
* Se reenvía a la opción `repeat` de BullMQ.
|
|
49
|
+
*/
|
|
50
|
+
repeat?: RepeatSpec;
|
|
28
51
|
}
|
|
29
52
|
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Handler de un job. Puede devolver un valor `R` que queda disponible como
|
|
55
|
+
* resultado del job (recuperable vía `job.waitUntilFinished`). Devolver `void`
|
|
56
|
+
* sigue siendo válido (R por defecto es `void`).
|
|
57
|
+
*/
|
|
58
|
+
export type JobHandler<T = unknown, R = void> = (job: {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
data: T;
|
|
62
|
+
attemptsMade: number;
|
|
63
|
+
}) => Promise<R>;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Payload del evento `worker:dead-letter`, emitido cuando un job agota todos
|
|
67
|
+
* sus reintentos y `deadLetter` está activado.
|
|
68
|
+
*/
|
|
69
|
+
export interface DeadLetterPayload {
|
|
70
|
+
jobId: string | undefined;
|
|
71
|
+
name: string | undefined;
|
|
72
|
+
data: unknown;
|
|
73
|
+
failedReason: string | undefined;
|
|
74
|
+
attemptsMade: number;
|
|
32
75
|
}
|
|
33
76
|
|
|
34
|
-
|
|
77
|
+
/**
|
|
78
|
+
* Descriptor devuelto por `enqueue`/`schedule`. Además de los datos del job,
|
|
79
|
+
* expone `result()` para esperar el valor de retorno del handler.
|
|
80
|
+
*/
|
|
81
|
+
export interface JobDescriptor<T = unknown, R = unknown> {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
data: T;
|
|
85
|
+
/**
|
|
86
|
+
* Espera a que el job termine y resuelve con el valor que devolvió el
|
|
87
|
+
* handler. Lanza si el job falló. Requiere una conexión a Redis viva.
|
|
88
|
+
*/
|
|
89
|
+
result(ttlMs?: number): Promise<R>;
|
|
90
|
+
}
|