@zudojs/queue 1.4.0 → 1.5.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/README.md CHANGED
@@ -57,6 +57,35 @@ console.log(await queue.getStats());
57
57
  await queue.close();
58
58
  ```
59
59
 
60
+ ## Dead-letter store
61
+
62
+ Each queue keeps the most recent 1000 dead-lettered jobs in a store of its own,
63
+ cleared by `close()`. To choose the cap, or to keep the jobs after the queue
64
+ closes, pass a store as `deadLetterStore`. No type annotation is needed, for
65
+ an untyped store or one typed for the queue's payload.
66
+
67
+ ```typescript
68
+ import {
69
+ createInMemoryDeadLetterStore,
70
+ createInMemoryQueue,
71
+ createQueueName,
72
+ } from "@zudojs/queue";
73
+
74
+ // Keep only the 50 most recent failures.
75
+ const store = createInMemoryDeadLetterStore({ maxEntries: 50 });
76
+ const broken = createInMemoryQueue(createQueueName("broken"), {
77
+ deadLetterStore: store,
78
+ });
79
+
80
+ // A store typed for the payload, so its entries read as DeadLetterJob<Email>.
81
+ const failedEmails = createInMemoryDeadLetterStore<Email>();
82
+ const emails = createInMemoryQueue<Email>(createQueueName("emails"), {
83
+ deadLetterStore: failedEmails,
84
+ });
85
+ ```
86
+
87
+ `close()` leaves a store you passed in alone; its contents are yours.
88
+
60
89
  ## Features
61
90
 
62
91
  - In-memory queue for development and testing
@@ -136,6 +165,32 @@ queue.events?.on("job:completed", ({ job, result }) => {
136
165
  });
137
166
  ```
138
167
 
168
+ `job:failed` fires on **every** failed attempt, not only the last, and a
169
+ processor failure reports `job.state === "failed"` on a retryable attempt
170
+ and on the final one alike, with `job.attempt` still counting the attempts
171
+ before this one. What follows tells them apart:
172
+
173
+ | Attempt | `job:failed` `job.state` | Then |
174
+ |---------|--------------------------|------|
175
+ | Retryable (attempts left) | `"failed"` | `job:retrying` (`"retrying"`); after the backoff the job is `"waiting"` again |
176
+ | Final (attempts exhausted) | `"failed"` | `job:dead-lettered` (`"dead_letter"`) |
177
+ | Stalled, reclaimed | `"active"` (as found), `JobStalledError` | the job is `"waiting"` again |
178
+ | Stalled `maxStalledCount` times | `"dead_letter"`, `JobStalledError` | `job:dead-lettered` |
179
+
180
+ To alert only on dead letters, subscribe to `job:dead-lettered`. It fires once
181
+ per job, when the job enters the dead-letter store, with the entry's `error`
182
+ (`JobMaxAttemptsError`, or `JobStalledError` for a stalled job) and `reason`
183
+ (the last attempt's error message, or `"Stalled N time(s)."`):
184
+
185
+ ```typescript
186
+ queue.events?.on("job:dead-lettered", ({ job, error, reason }) => {
187
+ alerts.page(`${job.name} ${job.id} dead-lettered: ${reason}`, error);
188
+ });
189
+ ```
190
+
191
+ It fires even if a custom `deadLetterStore.add()` rejects, in step with
192
+ `getStats().deadLettered`. Before 1.5.0 there was no dead-letter event.
193
+
139
194
  ## Workers, timeouts and context
140
195
 
141
196
  **One consumer at a time.** `queue.process()` registers a processor and, by
@@ -81,6 +81,9 @@ export class InMemoryQueue {
81
81
  this.emitter.setLogger(this.options.logger);
82
82
  }
83
83
  this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
84
+ // A caller's store is typed `DeadLetterStore<unknown>` (see
85
+ // `QueueOptions.deadLetterStore`); this queue only ever adds its own
86
+ // `TData` jobs to it, so it is read back as a store of `TData`.
84
87
  this.deadLetterStore =
85
88
  this.options.deadLetterStore ??
86
89
  createInMemoryDeadLetterStore({
@@ -571,16 +574,22 @@ export class InMemoryQueue {
571
574
  failedAt: new Date().toISOString(),
572
575
  });
573
576
  this.jobs.set(job.id, deadLettered);
577
+ const reason = `Stalled ${count} time(s).`;
574
578
  void this.deadLetterStore
575
579
  .add({
576
580
  job: deadLettered,
577
581
  deadLetterAt: new Date(),
578
582
  error,
579
583
  attempts: deadLettered.attempt,
580
- reason: `Stalled ${count} time(s).`,
584
+ reason,
581
585
  })
582
586
  .catch(() => { });
583
587
  this.emitter.emit("job:failed", { job: deadLettered, error });
588
+ this.emitter.emit("job:dead-lettered", {
589
+ job: deadLettered,
590
+ error,
591
+ reason,
592
+ });
584
593
  this.recordSettled(deadLettered);
585
594
  continue;
586
595
  }
@@ -171,6 +171,11 @@ export async function handleJobFailure(job, errorMessage, deps) {
171
171
  }
172
172
  const deadLetterJob = updateJobState(incrementedJob, JobStateEnum.DEAD_LETTER, { error: maxAttemptsError.message });
173
173
  jobs.set(job.id, deadLetterJob);
174
+ emitter.emit("job:dead-lettered", {
175
+ job: deadLetterJob,
176
+ error: maxAttemptsError,
177
+ reason: errorMessage,
178
+ });
174
179
  deps.onSettled?.(deadLetterJob);
175
180
  }
176
181
  //# sourceMappingURL=inMemoryQueue.processing.js.map
@@ -63,8 +63,15 @@ export interface QueueOptions {
63
63
  * emitter, reachable as `queue.events`.
64
64
  */
65
65
  readonly eventEmitter?: QueueEventEmitter;
66
- /** Store that receives jobs which exhausted their attempts. */
67
- readonly deadLetterStore?: DeadLetterStore<never>;
66
+ /**
67
+ * Store that receives jobs which exhausted their attempts.
68
+ *
69
+ * Any store is accepted without an annotation: the untyped
70
+ * `createInMemoryDeadLetterStore()` and one typed for the queue's payload,
71
+ * `createInMemoryDeadLetterStore<TData>()`. (1.4.0 typed this
72
+ * `DeadLetterStore<never>`, which rejected both.)
73
+ */
74
+ readonly deadLetterStore?: DeadLetterStore<unknown>;
68
75
  /**
69
76
  * Whether `add()` rejects while the queue is paused.
70
77
  *
@@ -260,6 +267,11 @@ export type QueueEventMap = {
260
267
  job: Job;
261
268
  result: unknown;
262
269
  };
270
+ /**
271
+ * An attempt failed. Fires on every failed attempt, retryable or final;
272
+ * `job.state` is `"failed"` for a processor failure either way. Use
273
+ * `job:retrying` or `job:dead-lettered` to tell the two apart.
274
+ */
263
275
  "job:failed": {
264
276
  job: Job;
265
277
  error: Error;
@@ -268,6 +280,17 @@ export type QueueEventMap = {
268
280
  job: Job;
269
281
  attempt: number;
270
282
  };
283
+ /**
284
+ * The job was moved to the dead-letter store (attempts exhausted, or
285
+ * stalled `maxStalledCount` times). Fires once per job, after the
286
+ * `job:failed` for its last attempt; `job.state` is `"dead_letter"` and
287
+ * `error` / `reason` are the dead-letter entry's.
288
+ */
289
+ "job:dead-lettered": {
290
+ job: Job;
291
+ error: Error;
292
+ reason?: string;
293
+ };
271
294
  /** A running job was aborted from outside — a drain, a close, a cancel. */
272
295
  "job:cancelled": {
273
296
  job: Job;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/queue",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -25,9 +25,9 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/errors": "1.3.0",
29
- "@zudojs/constants": "1.1.2",
30
- "@zudojs/serialization": "1.2.0"
28
+ "@zudojs/errors": "1.3.1",
29
+ "@zudojs/constants": "1.1.3",
30
+ "@zudojs/serialization": "1.2.2"
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "7.0.2",