@zudojs/queue 1.2.0 → 1.4.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.
Files changed (31) hide show
  1. package/README.md +70 -2
  2. package/dist/contextCarrier/contextCarrier.core.d.ts +6 -1
  3. package/dist/contextCarrier/contextCarrier.core.js +23 -3
  4. package/dist/deadLetter/deadLetter.core.d.ts +26 -1
  5. package/dist/deadLetter/deadLetter.core.js +27 -1
  6. package/dist/deadLetter/index.d.ts +2 -1
  7. package/dist/deadLetter/index.js +1 -1
  8. package/dist/inMemoryQueue/inMemoryQueue.core.d.ts +42 -12
  9. package/dist/inMemoryQueue/inMemoryQueue.core.js +140 -95
  10. package/dist/inMemoryQueue/inMemoryQueue.processing.d.ts +2 -0
  11. package/dist/inMemoryQueue/inMemoryQueue.processing.js +11 -2
  12. package/dist/inMemoryQueue/inMemoryQueue.scheduling.d.ts +6 -3
  13. package/dist/inMemoryQueue/inMemoryQueue.scheduling.js +8 -3
  14. package/dist/inMemoryQueue/polling/inMemoryQueue.poller.d.ts +64 -0
  15. package/dist/inMemoryQueue/polling/inMemoryQueue.poller.js +132 -0
  16. package/dist/inMemoryQueue/polling/inMemoryQueue.select.d.ts +26 -0
  17. package/dist/inMemoryQueue/polling/inMemoryQueue.select.js +64 -0
  18. package/dist/inMemoryQueue/polling/index.d.ts +10 -0
  19. package/dist/inMemoryQueue/polling/index.js +10 -0
  20. package/dist/job/job.type.d.ts +5 -1
  21. package/dist/jobContext/jobContext.core.js +1 -0
  22. package/dist/jobContext/jobContext.type.d.ts +7 -0
  23. package/dist/queue/queue.type.d.ts +44 -3
  24. package/dist/queueEmitter/queueEmitter.core.d.ts +20 -1
  25. package/dist/queueEmitter/queueEmitter.core.js +20 -1
  26. package/dist/serializer/serializer.core.d.ts +27 -4
  27. package/dist/serializer/serializer.core.js +31 -12
  28. package/dist/serializer/serializer.type.d.ts +6 -0
  29. package/dist/worker/worker.core.js +44 -2
  30. package/dist/worker/worker.type.d.ts +11 -1
  31. package/package.json +6 -6
package/README.md CHANGED
@@ -69,8 +69,72 @@ await queue.close();
69
69
  - Draining shutdown with a bounded `closeTimeout`, then cooperative abort
70
70
  - Stalled-job reclaim (`stalledAfter`, `maxStalledCount`) for consumers that die
71
71
  mid-job
72
- - Bounded retention of settled jobs, so a long-lived queue does not grow without
73
- limit
72
+ - Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
73
+ queue does not grow without limit
74
+
75
+ ## Polling and process lifetime
76
+
77
+ The queue's own consumer polls every `pollInterval` milliseconds when you set
78
+ one; unset, it starts at 50 ms and backs off to 2000 ms while idle. It never
79
+ waits out that interval when work arrives: `add()`, a delayed job coming due, a
80
+ retry's backoff elapsing, `resume()` and a finished job all wake it at once. A
81
+ `Worker` is woken the same way through `queue.onJobReady`, so its
82
+ `pollInterval` only bounds how often an idle worker re-checks.
83
+
84
+ Pending work keeps the Node.js process alive. While the queue has waiting,
85
+ delayed, retrying or running jobs that one of its processors can run, a script
86
+ whose only work is the queue does not exit before the jobs run. A started
87
+ `Worker` keeps the process alive until `stop()` or `forceStop()`. An idle queue,
88
+ a paused one, work that no processor handles, and a closed queue never hold the
89
+ process open. Pass `keepAlive: false` (to the queue or the worker) for the old
90
+ behaviour, where every timer was unreferenced.
91
+
92
+ ## Payloads
93
+
94
+ Payloads round-trip through the serializer on `add()`, so the stored job is a
95
+ copy. The default `JsonSerializer` preserves `Date`, `BigInt`, `Map`, `Set`,
96
+ `Uint8Array` and `Error`, so a field typed `Date` arrives as a `Date`:
97
+
98
+ ```typescript
99
+ const reminders = createInMemoryQueue<{ at: Date }>(createQueueName("reminders"));
100
+ reminders.process("remind", async (job) => job.data.at.getTime()); // a real Date
101
+ ```
102
+
103
+ `createJsonSerializer({ preserveTypes: false })` gives plain JSON, where a
104
+ `Date` becomes its ISO string (type it as `string`). `PassthroughSerializer`
105
+ (or `serializePayloads: false`) stores payloads by reference, class instances
106
+ included, with no copy and no serialization.
107
+
108
+ ## Ordering
109
+
110
+ Higher `priority` runs first. Within a priority, jobs run in the order they
111
+ became runnable: when they were added, or for a delayed job when its delay
112
+ elapsed, so a delayed job never jumps ahead of jobs that were already waiting
113
+ when it came due. A retried job keeps its original place in line.
114
+
115
+ ## Attempts
116
+
117
+ `job.attempt` counts the attempts already made, so it is `0` while the first
118
+ attempt runs. `context.attemptNumber` is the 1-based number of the attempt in
119
+ progress (`job.attempt + 1`), the same convention as `ctx.attempt` in
120
+ `@zudojs/scheduler`:
121
+
122
+ ```typescript
123
+ queue.process("sync", async (job, context) => {
124
+ context.log(`attempt ${context.attemptNumber} of ${job.maxAttempts}`);
125
+ });
126
+ ```
127
+
128
+ ## Events
129
+
130
+ `queue.events` works without configuration: a queue created without an
131
+ `eventEmitter` gets an in-memory one.
132
+
133
+ ```typescript
134
+ queue.events?.on("job:completed", ({ job, result }) => {
135
+ console.log(job.id, result);
136
+ });
137
+ ```
74
138
 
75
139
  ## Workers, timeouts and context
76
140
 
@@ -120,6 +184,10 @@ Keep captured values small and serializable (ids, not live objects). With
120
184
  `@zudojs/tenancy`, capture the tenant id and restore it with the tenant context
121
185
  storage's `run`.
122
186
 
187
+ The `zudo:context` metadata key is reserved: it is written only by the queue's
188
+ own carriers and is stripped from any `metadata` passed to `add()`, so an
189
+ enqueuer cannot choose the context its job runs under.
190
+
123
191
  Errors with no caller to receive them (a worker's failing poll, a throwing event
124
192
  listener) go to `logger.error` when a logger is configured, and otherwise to
125
193
  `process.emitWarning`, never to `console`.
@@ -13,10 +13,15 @@ export declare const CONTEXT_METADATA_KEY = "zudo:context";
13
13
  /**
14
14
  * Captures every carrier's value into a metadata record.
15
15
  *
16
+ * {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
17
+ * under that key is always discarded, whether or not a carrier captured
18
+ * anything, so an enqueuer cannot forge the tenant, correlation id or trace
19
+ * the processor runs under.
20
+ *
16
21
  * @param carriers - The queue's context carriers.
17
22
  * @param metadata - The job's own metadata, if any.
18
23
  * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
19
- * the input unchanged when no carrier captured anything.
24
+ * without one when no carrier captured anything.
20
25
  */
21
26
  export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
22
27
  /**
@@ -8,17 +8,37 @@
8
8
  * `{ [carrier.key]: value }` record.
9
9
  */
10
10
  export const CONTEXT_METADATA_KEY = "zudo:context";
11
+ /**
12
+ * Strips the reserved context key from caller-supplied metadata.
13
+ *
14
+ * The key is owned by the queue on every path: whatever the enqueuer put
15
+ * there is dropped, so `add()` metadata can never supply a context record of
16
+ * its own. Returns the input untouched when there is nothing to strip, so the
17
+ * common case still allocates nothing.
18
+ */
19
+ function withoutStoredContext(metadata) {
20
+ if (!metadata || !Object.hasOwn(metadata, CONTEXT_METADATA_KEY)) {
21
+ return metadata;
22
+ }
23
+ const { [CONTEXT_METADATA_KEY]: _discarded, ...rest } = metadata;
24
+ return rest;
25
+ }
11
26
  /**
12
27
  * Captures every carrier's value into a metadata record.
13
28
  *
29
+ * {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
30
+ * under that key is always discarded, whether or not a carrier captured
31
+ * anything, so an enqueuer cannot forge the tenant, correlation id or trace
32
+ * the processor runs under.
33
+ *
14
34
  * @param carriers - The queue's context carriers.
15
35
  * @param metadata - The job's own metadata, if any.
16
36
  * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
17
- * the input unchanged when no carrier captured anything.
37
+ * without one when no carrier captured anything.
18
38
  */
19
39
  export function captureContext(carriers, metadata) {
20
40
  if (!carriers || carriers.length === 0)
21
- return metadata;
41
+ return withoutStoredContext(metadata);
22
42
  const captured = {};
23
43
  let any = false;
24
44
  for (const carrier of carriers) {
@@ -29,7 +49,7 @@ export function captureContext(carriers, metadata) {
29
49
  any = true;
30
50
  }
31
51
  if (!any)
32
- return metadata;
52
+ return withoutStoredContext(metadata);
33
53
  return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
34
54
  }
35
55
  /**
@@ -1,9 +1,34 @@
1
1
  import type { Job } from "../job/job.type.js";
2
2
  import type { DeadLetterStore } from "./deadLetter.type.js";
3
+ /**
4
+ * Dead-lettered jobs retained by the in-memory store when no cap is given.
5
+ *
6
+ * Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
7
+ * keeps a full copy of every failed job — payload, metadata and error — so an
8
+ * unbounded one grows for the life of the process at whatever the failure
9
+ * rate happens to be.
10
+ */
11
+ export declare const DEFAULT_DEAD_LETTER_JOBS = 1000;
12
+ /**
13
+ * Options for {@link createInMemoryDeadLetterStore}.
14
+ */
15
+ export interface InMemoryDeadLetterStoreOptions {
16
+ /**
17
+ * Maximum number of dead-lettered jobs retained before the oldest are
18
+ * evicted. Defaults to {@link DEFAULT_DEAD_LETTER_JOBS}. Pass
19
+ * `Number.POSITIVE_INFINITY` for the previous unbounded behaviour.
20
+ */
21
+ readonly maxEntries?: number;
22
+ }
3
23
  /**
4
24
  * Creates an in-memory dead letter store.
25
+ *
26
+ * Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
27
+ * long-lived queue with a steady failure rate does not grow without limit.
28
+ *
29
+ * @param options - Retention options.
5
30
  */
6
- export declare function createInMemoryDeadLetterStore<TData = unknown>(): DeadLetterStore<TData>;
31
+ export declare function createInMemoryDeadLetterStore<TData = unknown>(options?: InMemoryDeadLetterStoreOptions): DeadLetterStore<TData>;
7
32
  /**
8
33
  * Moves a failed job to the dead letter store.
9
34
  */
@@ -1,12 +1,38 @@
1
1
  import { toQueueError } from "@zudojs/errors";
2
+ /**
3
+ * Dead-lettered jobs retained by the in-memory store when no cap is given.
4
+ *
5
+ * Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
6
+ * keeps a full copy of every failed job — payload, metadata and error — so an
7
+ * unbounded one grows for the life of the process at whatever the failure
8
+ * rate happens to be.
9
+ */
10
+ export const DEFAULT_DEAD_LETTER_JOBS = 1_000;
2
11
  /**
3
12
  * Creates an in-memory dead letter store.
13
+ *
14
+ * Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
15
+ * long-lived queue with a steady failure rate does not grow without limit.
16
+ *
17
+ * @param options - Retention options.
4
18
  */
5
- export function createInMemoryDeadLetterStore() {
19
+ export function createInMemoryDeadLetterStore(options) {
6
20
  const store = new Map();
21
+ const configured = options?.maxEntries ?? DEFAULT_DEAD_LETTER_JOBS;
22
+ const maxEntries = configured > 0 ? configured : DEFAULT_DEAD_LETTER_JOBS;
7
23
  return {
8
24
  async add(deadLetterJob) {
25
+ // Re-inserting an existing id must not count as a new entry, so
26
+ // delete first: a Map keeps its original insertion order otherwise,
27
+ // which would also evict the wrong entry.
28
+ store.delete(deadLetterJob.job.id);
9
29
  store.set(deadLetterJob.job.id, deadLetterJob);
30
+ while (store.size > maxEntries) {
31
+ const oldest = store.keys().next().value;
32
+ if (oldest === undefined)
33
+ break;
34
+ store.delete(oldest);
35
+ }
10
36
  },
11
37
  async get(jobId) {
12
38
  return store.get(jobId) ?? null;
@@ -4,6 +4,7 @@
4
4
  * Provides storage and management for jobs that have
5
5
  * exceeded their retry attempts.
6
6
  */
7
- export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
7
+ export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
8
+ export type { InMemoryDeadLetterStoreOptions } from "./deadLetter.core.js";
8
9
  export type { DeadLetterJob, DeadLetterStore } from "./deadLetter.type.js";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -4,5 +4,5 @@
4
4
  * Provides storage and management for jobs that have
5
5
  * exceeded their retry attempts.
6
6
  */
7
- export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
7
+ export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
8
8
  //# sourceMappingURL=index.js.map
@@ -4,6 +4,7 @@ import type { Queue, QueueOptions, QueueStats } from "../queue/queue.type.js";
4
4
  import type { Processor } from "../processor/processor.type.js";
5
5
  import type { JobOptions } from "../jobOptions/jobOptions.type.js";
6
6
  import type { QueueMiddleware } from "../middleware/middleware.type.js";
7
+ import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
7
8
  import type { DeadLetterJob } from "../deadLetter/deadLetter.type.js";
8
9
  /**
9
10
  * In-memory queue implementation.
@@ -23,7 +24,9 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
23
24
  /** Whether the internal poller claims jobs; see `setAutoProcess`. */
24
25
  private autoProcess;
25
26
  private activeCount;
26
- private pollTimer;
27
+ private readonly poller;
28
+ /** Consumers to tell when a job may have become runnable. */
29
+ private readonly readyListeners;
27
30
  private readonly scheduledTimers;
28
31
  private readonly retryTimers;
29
32
  private readonly inFlight;
@@ -32,11 +35,23 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
32
35
  private readonly stalledCounts;
33
36
  private readonly deduplicationIndex;
34
37
  private readonly deadLetterStore;
38
+ /**
39
+ * Whether this queue created its own dead letter store. A store handed in
40
+ * by the caller outlives the queue and is theirs to clear.
41
+ */
42
+ private readonly ownsDeadLetterStore;
35
43
  private readonly emitter;
36
44
  private readonly counters;
37
- private emptySince;
38
- private backoffMs;
39
45
  constructor(name: QueueName, options?: QueueOptions);
46
+ /**
47
+ * Subscribes to "a job may have become runnable": one was added, released
48
+ * or reclaimed, a delay or retry backoff elapsed, or the queue resumed.
49
+ *
50
+ * A `Worker` uses this to claim immediately instead of on its next poll.
51
+ *
52
+ * @returns A function that unsubscribes.
53
+ */
54
+ onJobReady(listener: () => void): () => void;
40
55
  /**
41
56
  * Turns the internal poller on or off as a consumer.
42
57
  *
@@ -46,6 +61,14 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
46
61
  * concurrency apply to every job.
47
62
  */
48
63
  setAutoProcess(enabled: boolean): void;
64
+ /**
65
+ * The emitter this queue publishes lifecycle events on.
66
+ *
67
+ * An in-memory emitter when the queue was created without one, so
68
+ * `queue.events.on(...)` works out of the box and a worker can report its
69
+ * lifecycle unconditionally.
70
+ */
71
+ get events(): QueueEventEmitter;
49
72
  add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
50
73
  process(name: string, processor: Processor<TData>): void;
51
74
  getJob(jobId: JobId): Promise<Job<TData> | null>;
@@ -100,17 +123,18 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
100
123
  */
101
124
  private encodePayload;
102
125
  /**
103
- * Selects the highest-priority job that is due and runnable.
104
- *
105
- * Ties on priority are broken by creation time, oldest first. The
106
- * incumbent is tracked by reference rather than by a sentinel priority,
107
- * so jobs with negative priorities are selectable like any other.
126
+ * Selects the next job that is due and runnable; see {@link selectNextJob}
127
+ * for the ordering.
108
128
  */
109
129
  private selectJob;
110
- private startPolling;
111
- private scheduleNextTick;
112
- private scheduleTick;
113
- private stopPolling;
130
+ /**
131
+ * Whether the poll loop should hold the process open: while the queue is
132
+ * consuming and has work one of its processors can run. Work nothing can
133
+ * consume, a paused queue and `keepAlive: false` never do.
134
+ */
135
+ private shouldKeepAlive;
136
+ /** Wakes the poll loop and tells consumers a job may be runnable. */
137
+ private jobReady;
114
138
  /**
115
139
  * Runs an already-claimed job through this queue's processing pipeline.
116
140
  *
@@ -125,6 +149,12 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
125
149
  /** Fallback timeout for a job that carries none of its own. */
126
150
  timeoutMs?: number;
127
151
  }): Promise<void>;
152
+ /**
153
+ * One poll: promotes due delayed jobs, claims runnable jobs up to the
154
+ * concurrency limit, and reclaims stalled ones.
155
+ *
156
+ * @returns Whether any job was dispatched.
157
+ */
128
158
  private processTick;
129
159
  /**
130
160
  * Returns jobs stuck in `active` to the waiting pool.