@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 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
- type JobHandler = (job: {
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: any;
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
- }) => Promise<void>;
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: any, opts?: JobOptions): Promise<{
72
- id: string;
73
- name: string;
74
- data: any;
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 { id: job.id, name, data };
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.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, "Job failed");
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
- const closePromises = [];
127
+ this.stopped = true;
109
128
  if (this.worker) {
110
- closePromises.push(this.worker.close());
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
- closePromises.push(this.queue.close());
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
- return {
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.1.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.0",
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 { WorkerManagerOptions, JobOptions, JobHandler } from './types';
4
+ import type {
5
+ WorkerManagerOptions,
6
+ JobOptions,
7
+ JobHandler,
8
+ RepeatSpec,
9
+ JobDescriptor,
10
+ DeadLetterPayload,
11
+ } from './types';
5
12
 
6
- export type { WorkerManagerOptions, JobOptions, JobHandler } from './types';
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
- private handlers: Map<string, JobHandler> = new Map();
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(name: string, data: any, opts?: JobOptions) {
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 { id: job.id!, name, data };
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.app?.logger.error({ jobId: job?.id, jobName: job?.name, err }, 'Job failed');
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
- const closePromises: Promise<void>[] = [];
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
- closePromises.push(this.worker.close());
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
- closePromises.push(this.queue.close());
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
- return {
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
- export interface JobData {
31
- [key: string]: any;
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
- export type JobHandler = (job: { id: string; name: string; data: any; attemptsMade: number }) => Promise<void>;
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
+ }