@zudojs/queue 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-queue](https://zudojs.oyinlola.site/docs/packages-queue) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-queue.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -43,9 +49,6 @@ await queue.add(
43
49
  // The queue polls in the background; give it a tick before reading counts.
44
50
  await new Promise((resolve) => setTimeout(resolve, 100));
45
51
 
46
- // The queue polls in the background; give it a tick before reading counts.
47
- await new Promise((resolve) => setTimeout(resolve, 100));
48
-
49
52
  // Jobs that exhaust their attempts land here rather than vanishing.
50
53
  console.log((await queue.getDeadLetterJobs()).length);
51
54
  console.log(await queue.getStats());
@@ -66,8 +69,64 @@ await queue.close();
66
69
  - Draining shutdown with a bounded `closeTimeout`, then cooperative abort
67
70
  - Stalled-job reclaim (`stalledAfter`, `maxStalledCount`) for consumers that die
68
71
  mid-job
69
- - Bounded retention of settled jobs, so a long-lived queue does not grow without
70
- 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
+ ## Workers, timeouts and context
76
+
77
+ **One consumer at a time.** `queue.process()` registers a processor and, by
78
+ default, the queue's own poller runs jobs. Creating a `Worker` for the queue
79
+ turns that poller off as a consumer (`queue.setAutoProcess(false)`), so the
80
+ worker's `middleware`, `timeoutMs` and `concurrency` apply to every job, and
81
+ `worker.stop()` really stops consumption. Pass `autoProcess: false` to keep the
82
+ queue from consuming before any worker exists.
83
+
84
+ **Timeouts are cooperative.** A timeout aborts `context.signal`; honour it. The
85
+ job's concurrency slot and its retry wait up to `timeoutGraceMs` (5000 ms by
86
+ default) for the processor to settle, so a retry never runs beside the attempt
87
+ it replaces. A processor still running after the grace period is abandoned.
88
+
89
+ **Carrying context across the queue.** AsyncLocalStorage does not follow a job
90
+ into the poller or worker that runs it. A `QueueContextCarrier` captures a value
91
+ at `add()` into job metadata and restores it around the middleware and the
92
+ processor:
93
+
94
+ ```typescript
95
+ import { AsyncLocalStorage } from "node:async_hooks";
96
+ import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
97
+ import type { QueueContextCarrier } from "@zudojs/queue";
98
+
99
+ const tenants = new AsyncLocalStorage<{ tenantId: string }>();
100
+
101
+ const tenantCarrier: QueueContextCarrier<string> = {
102
+ key: "tenantId",
103
+ capture: () => tenants.getStore()?.tenantId,
104
+ restore: (tenantId, run) => tenants.run({ tenantId }, run),
105
+ };
106
+
107
+ const jobs = createInMemoryQueue<{ id: number }>(createQueueName("reports"), {
108
+ contextCarriers: [tenantCarrier],
109
+ });
110
+ jobs.process("build", async () => {
111
+ console.log("building for", tenants.getStore()?.tenantId); // "acme"
112
+ });
113
+
114
+ await tenants.run({ tenantId: "acme" }, () => jobs.add("build", { id: 1 }));
115
+ await new Promise((resolve) => setTimeout(resolve, 100));
116
+ await jobs.close();
117
+ ```
118
+
119
+ Keep captured values small and serializable (ids, not live objects). With
120
+ `@zudojs/tenancy`, capture the tenant id and restore it with the tenant context
121
+ storage's `run`.
122
+
123
+ The `zudo:context` metadata key is reserved: it is written only by the queue's
124
+ own carriers and is stripped from any `metadata` passed to `add()`, so an
125
+ enqueuer cannot choose the context its job runs under.
126
+
127
+ Errors with no caller to receive them (a worker's failing poll, a throwing event
128
+ listener) go to `logger.error` when a logger is configured, and otherwise to
129
+ `process.emitWarning`, never to `console`.
71
130
 
72
131
  ## Use Cases
73
132
 
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Context capture at `add()` and restoration around processing.
3
+ *
4
+ * @module contextCarrier/contextCarrier.core
5
+ */
6
+ import type { Job } from "../job/job.type.js";
7
+ import type { QueueContextCarrier } from "./contextCarrier.type.js";
8
+ /**
9
+ * Job metadata key under which captured context values are stored, as a
10
+ * `{ [carrier.key]: value }` record.
11
+ */
12
+ export declare const CONTEXT_METADATA_KEY = "zudo:context";
13
+ /**
14
+ * Captures every carrier's value into a metadata record.
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
+ *
21
+ * @param carriers - The queue's context carriers.
22
+ * @param metadata - The job's own metadata, if any.
23
+ * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
24
+ * without one when no carrier captured anything.
25
+ */
26
+ export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
27
+ /**
28
+ * Runs `fn` inside every context the job carries, first carrier outermost.
29
+ *
30
+ * @param carriers - The queue's context carriers.
31
+ * @param job - The job about to run.
32
+ * @param fn - The work to run inside the restored context.
33
+ * @returns Whatever `fn` resolves to.
34
+ */
35
+ export declare function runWithContext<T>(carriers: readonly QueueContextCarrier[] | undefined, job: Job<unknown>, fn: () => Promise<T>): Promise<T>;
36
+ //# sourceMappingURL=contextCarrier.core.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Context capture at `add()` and restoration around processing.
3
+ *
4
+ * @module contextCarrier/contextCarrier.core
5
+ */
6
+ /**
7
+ * Job metadata key under which captured context values are stored, as a
8
+ * `{ [carrier.key]: value }` record.
9
+ */
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
+ }
26
+ /**
27
+ * Captures every carrier's value into a metadata record.
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
+ *
34
+ * @param carriers - The queue's context carriers.
35
+ * @param metadata - The job's own metadata, if any.
36
+ * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
37
+ * without one when no carrier captured anything.
38
+ */
39
+ export function captureContext(carriers, metadata) {
40
+ if (!carriers || carriers.length === 0)
41
+ return withoutStoredContext(metadata);
42
+ const captured = {};
43
+ let any = false;
44
+ for (const carrier of carriers) {
45
+ const value = carrier.capture();
46
+ if (value === undefined)
47
+ continue;
48
+ captured[carrier.key] = value;
49
+ any = true;
50
+ }
51
+ if (!any)
52
+ return withoutStoredContext(metadata);
53
+ return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
54
+ }
55
+ /**
56
+ * Runs `fn` inside every context the job carries, first carrier outermost.
57
+ *
58
+ * @param carriers - The queue's context carriers.
59
+ * @param job - The job about to run.
60
+ * @param fn - The work to run inside the restored context.
61
+ * @returns Whatever `fn` resolves to.
62
+ */
63
+ export function runWithContext(carriers, job, fn) {
64
+ const raw = job.metadata?.[CONTEXT_METADATA_KEY];
65
+ if (!carriers || carriers.length === 0)
66
+ return fn();
67
+ if (typeof raw !== "object" || raw === null)
68
+ return fn();
69
+ const stored = raw;
70
+ let run = fn;
71
+ for (let i = carriers.length - 1; i >= 0; i--) {
72
+ const carrier = carriers[i];
73
+ if (!Object.hasOwn(stored, carrier.key))
74
+ continue;
75
+ const inner = run;
76
+ run = () => carrier.restore(stored[carrier.key], inner);
77
+ }
78
+ return run();
79
+ }
80
+ //# sourceMappingURL=contextCarrier.core.js.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Context carrier contract.
3
+ *
4
+ * @module contextCarrier/contextCarrier.type
5
+ */
6
+ /**
7
+ * Captures one piece of ambient context when a job is added and restores it
8
+ * while the job runs.
9
+ *
10
+ * AsyncLocalStorage does not follow a job from the request that enqueued it
11
+ * into the poller or worker that runs it, so without a carrier every job runs
12
+ * with no tenant, no correlation id and no trace. Keep the captured value
13
+ * small and serializable (an id, not a live object): a broker-backed queue
14
+ * has to store it with the job.
15
+ *
16
+ * @example
17
+ * ```typescript
18
+ * const tenantCarrier: QueueContextCarrier<string> = {
19
+ * key: "tenantId",
20
+ * capture: () => tenantStorage.get()?.tenant?.id,
21
+ * restore: (tenantId, run) =>
22
+ * tenantStorage.run(contextFor(tenantId), run),
23
+ * };
24
+ * createInMemoryQueue("emails", { contextCarriers: [tenantCarrier] });
25
+ * ```
26
+ */
27
+ export interface QueueContextCarrier<TValue = unknown> {
28
+ /** Unique name under which the value is stored with the job. */
29
+ readonly key: string;
30
+ /**
31
+ * Reads the value from the caller's context at `add()`. Returning
32
+ * `undefined` stores nothing.
33
+ */
34
+ capture(): TValue | undefined;
35
+ /**
36
+ * Runs `run` inside the restored context. Called only for jobs that
37
+ * carry a value for this carrier's `key`.
38
+ */
39
+ restore<T>(value: TValue, run: () => Promise<T>): Promise<T>;
40
+ }
41
+ //# sourceMappingURL=contextCarrier.type.d.ts.map
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Context carrier contract.
3
+ *
4
+ * @module contextCarrier/contextCarrier.type
5
+ */
6
+ export {};
7
+ //# sourceMappingURL=contextCarrier.type.js.map
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @zudojs/queue/contextCarrier
3
+ *
4
+ * Carries ambient execution context (tenant, correlation id, trace ids)
5
+ * across the queue boundary: captured into job metadata at `add()` and
6
+ * restored around the processor, so a background job runs in the context of
7
+ * the request that enqueued it.
8
+ */
9
+ export type { QueueContextCarrier } from "./contextCarrier.type.js";
10
+ export { CONTEXT_METADATA_KEY, captureContext, runWithContext, } from "./contextCarrier.core.js";
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @zudojs/queue/contextCarrier
3
+ *
4
+ * Carries ambient execution context (tenant, correlation id, trace ids)
5
+ * across the queue boundary: captured into job metadata at `add()` and
6
+ * restored around the processor, so a background job runs in the context of
7
+ * the request that enqueued it.
8
+ */
9
+ export { CONTEXT_METADATA_KEY, captureContext, runWithContext, } from "./contextCarrier.core.js";
10
+ //# sourceMappingURL=index.js.map
@@ -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.
@@ -20,6 +21,8 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
20
21
  private readonly middleware;
21
22
  private paused;
22
23
  private disposed;
24
+ /** Whether the internal poller claims jobs; see `setAutoProcess`. */
25
+ private autoProcess;
23
26
  private activeCount;
24
27
  private pollTimer;
25
28
  private readonly scheduledTimers;
@@ -30,11 +33,32 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
30
33
  private readonly stalledCounts;
31
34
  private readonly deduplicationIndex;
32
35
  private readonly deadLetterStore;
36
+ /**
37
+ * Whether this queue created its own dead letter store. A store handed in
38
+ * by the caller outlives the queue and is theirs to clear.
39
+ */
40
+ private readonly ownsDeadLetterStore;
33
41
  private readonly emitter;
34
42
  private readonly counters;
35
43
  private emptySince;
36
44
  private backoffMs;
37
45
  constructor(name: QueueName, options?: QueueOptions);
46
+ /**
47
+ * Turns the internal poller on or off as a consumer.
48
+ *
49
+ * While off, the poller still promotes scheduled jobs and reclaims stalled
50
+ * ones, but claims nothing: an external `Worker` is the only consumer, so
51
+ * stopping it really stops consumption and its middleware, timeout and
52
+ * concurrency apply to every job.
53
+ */
54
+ setAutoProcess(enabled: boolean): void;
55
+ /**
56
+ * The emitter this queue publishes lifecycle events on.
57
+ *
58
+ * A no-op emitter when the queue was created without one, so a worker can
59
+ * report its lifecycle unconditionally.
60
+ */
61
+ get events(): QueueEventEmitter;
38
62
  add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
39
63
  process(name: string, processor: Processor<TData>): void;
40
64
  getJob(jobId: JobId): Promise<Job<TData> | null>;
@@ -4,9 +4,10 @@ import { assertProcessor } from "../processor/processor.type.js";
4
4
  import { createJob, updateJobState } from "../job/job.core.js";
5
5
  import { JobState as JobStateEnum, createJobName, } from "../jobTypes/jobTypes.type.js";
6
6
  import { JsonSerializer } from "../serializer/serializer.core.js";
7
- import { createInMemoryDeadLetterStore } from "../deadLetter/deadLetter.core.js";
8
- import { createNoopQueueEventEmitter } from "../queueEmitter/queueEmitter.core.js";
7
+ import { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, } from "../deadLetter/deadLetter.core.js";
8
+ import { InMemoryQueueEventEmitter, createNoopQueueEventEmitter, } from "../queueEmitter/queueEmitter.core.js";
9
9
  import { processJob } from "./inMemoryQueue.processing.js";
10
+ import { captureContext } from "../contextCarrier/contextCarrier.core.js";
10
11
  import { scheduleJob, promoteDueScheduledJobs, } from "./inMemoryQueue.scheduling.js";
11
12
  /** Terminal states a job never leaves. */
12
13
  const TERMINAL_STATES = new Set([
@@ -34,6 +35,8 @@ export class InMemoryQueue {
34
35
  middleware;
35
36
  paused = false;
36
37
  disposed = false;
38
+ /** Whether the internal poller claims jobs; see `setAutoProcess`. */
39
+ autoProcess;
37
40
  activeCount = 0;
38
41
  pollTimer = null;
39
42
  scheduledTimers = new Map();
@@ -44,6 +47,11 @@ export class InMemoryQueue {
44
47
  stalledCounts = new Map();
45
48
  deduplicationIndex = new Map();
46
49
  deadLetterStore;
50
+ /**
51
+ * Whether this queue created its own dead letter store. A store handed in
52
+ * by the caller outlives the queue and is theirs to clear.
53
+ */
54
+ ownsDeadLetterStore;
47
55
  emitter;
48
56
  counters = {
49
57
  processedCount: 0,
@@ -60,8 +68,40 @@ export class InMemoryQueue {
60
68
  this.serializer = this.options.serializer ?? JsonSerializer;
61
69
  this.middleware = this.options.middleware ?? [];
62
70
  this.emitter = options?.eventEmitter ?? createNoopQueueEventEmitter();
71
+ // A supplied emitter is built before the queue exists, so it cannot have
72
+ // been given the queue's logger. Hand it over, so a throwing listener is
73
+ // reported through structured logging rather than `process.emitWarning`.
74
+ if (this.options.logger &&
75
+ this.emitter instanceof InMemoryQueueEventEmitter) {
76
+ this.emitter.setLogger(this.options.logger);
77
+ }
78
+ this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
63
79
  this.deadLetterStore =
64
- this.options.deadLetterStore ?? createInMemoryDeadLetterStore();
80
+ this.options.deadLetterStore ??
81
+ createInMemoryDeadLetterStore({
82
+ maxEntries: DEFAULT_DEAD_LETTER_JOBS,
83
+ });
84
+ this.autoProcess = this.options.autoProcess ?? true;
85
+ }
86
+ /**
87
+ * Turns the internal poller on or off as a consumer.
88
+ *
89
+ * While off, the poller still promotes scheduled jobs and reclaims stalled
90
+ * ones, but claims nothing: an external `Worker` is the only consumer, so
91
+ * stopping it really stops consumption and its middleware, timeout and
92
+ * concurrency apply to every job.
93
+ */
94
+ setAutoProcess(enabled) {
95
+ this.autoProcess = enabled;
96
+ }
97
+ /**
98
+ * The emitter this queue publishes lifecycle events on.
99
+ *
100
+ * A no-op emitter when the queue was created without one, so a worker can
101
+ * report its lifecycle unconditionally.
102
+ */
103
+ get events() {
104
+ return this.emitter;
65
105
  }
66
106
  async add(jobName, data, options) {
67
107
  if (this.disposed)
@@ -74,7 +114,9 @@ export class InMemoryQueue {
74
114
  queueName: this.name,
75
115
  });
76
116
  }
77
- const mergedOptions = { ...this.options.defaultJobOptions, ...options };
117
+ const merged = { ...this.options.defaultJobOptions, ...options };
118
+ const metadata = captureContext(this.options.contextCarriers, merged.metadata);
119
+ const mergedOptions = metadata === merged.metadata ? merged : { ...merged, metadata };
78
120
  if (mergedOptions.deduplicationKey) {
79
121
  const existing = this.deduplicationIndex.get(mergedOptions.deduplicationKey);
80
122
  if (existing && this.jobs.has(existing)) {
@@ -260,6 +302,11 @@ export class InMemoryQueue {
260
302
  this.settledOrder.length = 0;
261
303
  this.stalledCounts.clear();
262
304
  this.inFlight.clear();
305
+ // A dead letter store the queue created dies with it; one handed in by
306
+ // the caller is theirs and is left alone.
307
+ if (this.ownsDeadLetterStore) {
308
+ await this.deadLetterStore.clear();
309
+ }
263
310
  this.activeCount = 0;
264
311
  this.paused = false;
265
312
  this.emptySince = 0;
@@ -383,17 +430,23 @@ export class InMemoryQueue {
383
430
  }
384
431
  const abortController = new AbortController();
385
432
  // A consumer's own signal (a worker draining, say) must reach the
386
- // job it dispatched.
387
- if (options?.signal) {
388
- if (options.signal.aborted) {
389
- abortController.abort(options.signal.reason);
433
+ // job it dispatched. The forwarder is held so it can be removed once
434
+ // the job settles: a worker uses one long-lived signal for every job
435
+ // it dispatches, so a listener left behind accumulates for the life
436
+ // of the worker and pins that job's controller with it.
437
+ const consumerSignal = options?.signal;
438
+ let forwardAbort;
439
+ if (consumerSignal) {
440
+ if (consumerSignal.aborted) {
441
+ abortController.abort(consumerSignal.reason);
390
442
  }
391
443
  else {
392
- options.signal.addEventListener("abort", () => {
444
+ forwardAbort = () => {
393
445
  if (!abortController.signal.aborted) {
394
- abortController.abort(options.signal?.reason);
446
+ abortController.abort(consumerSignal.reason);
395
447
  }
396
- }, { once: true });
448
+ };
449
+ consumerSignal.addEventListener("abort", forwardAbort, { once: true });
397
450
  }
398
451
  }
399
452
  this.inFlight.set(job.id, abortController);
@@ -402,6 +455,12 @@ export class InMemoryQueue {
402
455
  await processJob(job, processor, {
403
456
  timeoutMs: job.timeoutMs ?? options?.timeoutMs,
404
457
  abortController,
458
+ ...(this.options.timeoutGraceMs !== undefined
459
+ ? { timeoutGraceMs: this.options.timeoutGraceMs }
460
+ : {}),
461
+ ...(this.options.contextCarriers
462
+ ? { contextCarriers: this.options.contextCarriers }
463
+ : {}),
405
464
  }, {
406
465
  jobs: this.jobs,
407
466
  emitter: this.emitter,
@@ -413,6 +472,9 @@ export class InMemoryQueue {
413
472
  registerRetryTimer: (jobId, timer) => {
414
473
  this.retryTimers.set(jobId, timer);
415
474
  },
475
+ deregisterRetryTimer: (jobId) => {
476
+ this.retryTimers.delete(jobId);
477
+ },
416
478
  onSettled: (settled) => this.recordSettled(settled),
417
479
  isDisposed: () => this.disposed,
418
480
  ...(this.options.logger ? { logger: this.options.logger } : {}),
@@ -427,7 +489,9 @@ export class InMemoryQueue {
427
489
  finally {
428
490
  this.activeCount--;
429
491
  this.inFlight.delete(job.id);
430
- this.retryTimers.delete(job.id);
492
+ if (forwardAbort && consumerSignal) {
493
+ consumerSignal.removeEventListener("abort", forwardAbort);
494
+ }
431
495
  }
432
496
  }
433
497
  async processTick() {
@@ -435,7 +499,7 @@ export class InMemoryQueue {
435
499
  return;
436
500
  const concurrency = Math.max(1, this.options.concurrency ?? 1);
437
501
  let processed = 0;
438
- while (this.activeCount < concurrency) {
502
+ while (this.autoProcess && this.activeCount < concurrency) {
439
503
  // `claimNextJob` only returns jobs that have a registered
440
504
  // processor and moves them out of `waiting`, so this loop always
441
505
  // terminates. Returning an unrunnable job here is what previously
@@ -5,6 +5,7 @@ import type { QueueMiddleware } from "../middleware/middleware.type.js";
5
5
  import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
6
6
  import type { DeadLetterStore } from "../deadLetter/deadLetter.type.js";
7
7
  import type { QueueLogger } from "../queue/queue.type.js";
8
+ import type { QueueContextCarrier } from "../contextCarrier/contextCarrier.type.js";
8
9
  /**
9
10
  * Mutable throughput counters.
10
11
  *
@@ -32,6 +33,8 @@ export interface ProcessJobDependencies<TData> {
32
33
  * queue can clear it on close instead of leaking it.
33
34
  */
34
35
  readonly registerRetryTimer: (jobId: JobId, timer: ReturnType<typeof setTimeout>) => void;
36
+ /** Forgets a retry timer once it has fired. */
37
+ readonly deregisterRetryTimer?: (jobId: JobId) => void;
35
38
  /** Invoked whenever a job reaches a terminal state. */
36
39
  readonly onSettled?: (job: Job<TData>) => void;
37
40
  /** Whether the owning queue has been disposed. */
@@ -52,6 +55,10 @@ export interface ProcessJobDependencies<TData> {
52
55
  export declare function processJob<TData>(job: Job<TData>, processor: Processor<TData>, options: {
53
56
  timeoutMs?: number;
54
57
  abortController?: AbortController;
58
+ /** See `QueueOptions.timeoutGraceMs`. */
59
+ timeoutGraceMs?: number;
60
+ /** See `QueueOptions.contextCarriers`. */
61
+ contextCarriers?: readonly QueueContextCarrier[];
55
62
  }, deps: ProcessJobDependencies<TData>): Promise<void>;
56
63
  /**
57
64
  * Handle job failure with retry logic.
@@ -5,6 +5,8 @@ import { createJobContext } from "../jobContext/jobContext.core.js";
5
5
  import { createMiddlewareChain, createTimeoutMiddleware, } from "../middleware/middleware.core.js";
6
6
  import { calculateRetryDelay, resolveBackoff, shouldRetry, } from "../retryPolicy/retryPolicy.core.js";
7
7
  import { moveToDeadLetter } from "../deadLetter/deadLetter.core.js";
8
+ import { runWithContext } from "../contextCarrier/contextCarrier.core.js";
9
+ import { DEFAULT_TIMEOUT_GRACE_MS, settleWithin, } from "./inMemoryQueue.settle.js";
8
10
  import { JobMaxAttemptsError } from "@zudojs/errors";
9
11
  /**
10
12
  * Narrows a processor's return value to a `JobResult`.
@@ -46,10 +48,15 @@ export async function processJob(job, processor, options, deps) {
46
48
  },
47
49
  });
48
50
  const timeoutMs = options.timeoutMs ?? DEFAULT_JOB_OPTIONS.timeout ?? 30_000;
51
+ // Whether this job's own timeout is what aborted it. An abort from
52
+ // anywhere else — a draining worker, `close()`, a consumer's signal — is a
53
+ // cancellation, and is reported as one.
54
+ let timedOut = false;
49
55
  const timeoutMiddleware = createTimeoutMiddleware(timeoutMs, () => {
50
56
  // Let a cooperative processor observe the timeout and stop working
51
57
  // instead of running on with its result discarded.
52
58
  if (!abortController.signal.aborted) {
59
+ timedOut = true;
53
60
  abortController.abort(new Error(`Job "${updatedJob.id}" timed out after ${timeoutMs}ms.`));
54
61
  }
55
62
  });
@@ -57,14 +64,18 @@ export async function processJob(job, processor, options, deps) {
57
64
  timeoutMiddleware,
58
65
  ...deps.middleware,
59
66
  ]);
67
+ // The processor promise itself, so a failure (a timeout above all) can
68
+ // wait for it to stop before the slot and the retry are released.
69
+ let running;
60
70
  try {
61
- const result = await middlewareChain({
71
+ const result = await runWithContext(options.contextCarriers, updatedJob, () => middlewareChain({
62
72
  job: updatedJob,
63
73
  context,
64
74
  next: async () => {
65
- return processor(updatedJob, context);
75
+ running = Promise.resolve(processor(updatedJob, context));
76
+ return running;
66
77
  },
67
- });
78
+ }));
68
79
  if (isJobResult(result) && !result.success) {
69
80
  await handleJobFailure(updatedJob, result.error ?? "Job failed", deps);
70
81
  }
@@ -85,6 +96,10 @@ export async function processJob(job, processor, options, deps) {
85
96
  }
86
97
  catch (error) {
87
98
  const errorMessage = error instanceof Error ? error.message : String(error);
99
+ await settleWithin(running, options.timeoutGraceMs ?? DEFAULT_TIMEOUT_GRACE_MS);
100
+ if (abortController.signal.aborted && !timedOut) {
101
+ emitter.emit("job:cancelled", { job: updatedJob });
102
+ }
88
103
  await handleJobFailure(updatedJob, errorMessage, deps);
89
104
  }
90
105
  finally {
@@ -122,6 +137,7 @@ export async function handleJobFailure(job, errorMessage, deps) {
122
137
  const backoff = resolveBackoff(incrementedJob.backoff);
123
138
  const delay = calculateRetryDelay(incrementedJob.attempt, backoff);
124
139
  const timer = setTimeout(() => {
140
+ deps.deregisterRetryTimer?.(job.id);
125
141
  if (deps.isDisposed()) {
126
142
  return;
127
143
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Waiting for a timed-out processor to actually stop.
3
+ *
4
+ * The timeout middleware rejects as soon as the timer fires, but the
5
+ * processor promise it raced keeps running until it notices the aborted
6
+ * signal. Freeing the concurrency slot and scheduling the retry at that
7
+ * moment let the retry run beside the original attempt.
8
+ *
9
+ * @module inMemoryQueue/inMemoryQueue.settle
10
+ */
11
+ /** Default grace for a timed-out processor to settle, in milliseconds. */
12
+ export declare const DEFAULT_TIMEOUT_GRACE_MS = 5000;
13
+ /**
14
+ * Resolves once `pending` settles or `graceMs` elapses, whichever is first.
15
+ *
16
+ * Never rejects: the processor's own outcome has already been superseded by
17
+ * the failure being handled.
18
+ *
19
+ * @param pending - The processor promise, if the processor was started.
20
+ * @param graceMs - The longest to wait.
21
+ * @returns True when the processor settled within the grace period.
22
+ */
23
+ export declare function settleWithin(pending: Promise<unknown> | undefined, graceMs: number): Promise<boolean>;
24
+ //# sourceMappingURL=inMemoryQueue.settle.d.ts.map
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Waiting for a timed-out processor to actually stop.
3
+ *
4
+ * The timeout middleware rejects as soon as the timer fires, but the
5
+ * processor promise it raced keeps running until it notices the aborted
6
+ * signal. Freeing the concurrency slot and scheduling the retry at that
7
+ * moment let the retry run beside the original attempt.
8
+ *
9
+ * @module inMemoryQueue/inMemoryQueue.settle
10
+ */
11
+ /** Default grace for a timed-out processor to settle, in milliseconds. */
12
+ export const DEFAULT_TIMEOUT_GRACE_MS = 5_000;
13
+ /**
14
+ * Resolves once `pending` settles or `graceMs` elapses, whichever is first.
15
+ *
16
+ * Never rejects: the processor's own outcome has already been superseded by
17
+ * the failure being handled.
18
+ *
19
+ * @param pending - The processor promise, if the processor was started.
20
+ * @param graceMs - The longest to wait.
21
+ * @returns True when the processor settled within the grace period.
22
+ */
23
+ export async function settleWithin(pending, graceMs) {
24
+ if (pending === undefined)
25
+ return true;
26
+ let timer;
27
+ const settled = pending.then(() => true, () => true);
28
+ const expired = new Promise((resolve) => {
29
+ timer = setTimeout(() => resolve(false), Math.max(0, graceMs));
30
+ });
31
+ try {
32
+ return await Promise.race([settled, expired]);
33
+ }
34
+ finally {
35
+ if (timer !== undefined)
36
+ clearTimeout(timer);
37
+ }
38
+ }
39
+ //# sourceMappingURL=inMemoryQueue.settle.js.map
package/dist/index.d.ts CHANGED
@@ -23,4 +23,5 @@ export * from "./middleware/index.js";
23
23
  export * from "./worker/index.js";
24
24
  export * from "./inMemoryQueue/index.js";
25
25
  export * from "./deadLetter/index.js";
26
+ export * from "./contextCarrier/index.js";
26
27
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -39,4 +39,6 @@ export * from "./worker/index.js";
39
39
  export * from "./inMemoryQueue/index.js";
40
40
  // Dead letter
41
41
  export * from "./deadLetter/index.js";
42
+ // Context propagation across the queue boundary
43
+ export * from "./contextCarrier/index.js";
42
44
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Last-resort reporting for failures nobody else can receive.
3
+ *
4
+ * A worker's poll failure or a throwing event listener has no caller to
5
+ * reject. They used to go to `console.error`, bypassing structured logging
6
+ * and redaction. They now go to the configured logger's `error`, and without
7
+ * one to `process.emitWarning`, which Node prints once and applications can
8
+ * intercept with `process.on("warning")`.
9
+ *
10
+ * @module queue/queue.report
11
+ */
12
+ import type { QueueLogger } from "./queue.type.js";
13
+ /**
14
+ * Report an error that has no caller to receive it.
15
+ *
16
+ * @param message - What failed.
17
+ * @param error - The failure.
18
+ * @param logger - The configured logger, if any.
19
+ */
20
+ export declare function reportQueueError(message: string, error: unknown, logger?: QueueLogger): void;
21
+ //# sourceMappingURL=queue.report.d.ts.map
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Last-resort reporting for failures nobody else can receive.
3
+ *
4
+ * A worker's poll failure or a throwing event listener has no caller to
5
+ * reject. They used to go to `console.error`, bypassing structured logging
6
+ * and redaction. They now go to the configured logger's `error`, and without
7
+ * one to `process.emitWarning`, which Node prints once and applications can
8
+ * intercept with `process.on("warning")`.
9
+ *
10
+ * @module queue/queue.report
11
+ */
12
+ /**
13
+ * Report an error that has no caller to receive it.
14
+ *
15
+ * @param message - What failed.
16
+ * @param error - The failure.
17
+ * @param logger - The configured logger, if any.
18
+ */
19
+ export function reportQueueError(message, error, logger) {
20
+ if (logger?.error) {
21
+ logger.error(message, { error });
22
+ return;
23
+ }
24
+ process.emitWarning(error instanceof Error ? error : new Error(String(error)), { type: "ZudoQueueWarning", detail: message });
25
+ }
26
+ //# sourceMappingURL=queue.report.js.map
@@ -5,6 +5,7 @@ import type { Processor } from "../processor/processor.type.js";
5
5
  import type { Serializer } from "../serializer/serializer.type.js";
6
6
  import type { QueueMiddleware } from "../middleware/middleware.type.js";
7
7
  import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
8
+ import type { QueueContextCarrier } from "../contextCarrier/contextCarrier.type.js";
8
9
  import type { DeadLetterJob, DeadLetterStore } from "../deadLetter/deadLetter.type.js";
9
10
  /**
10
11
  * Somewhere for a job to write a log line.
@@ -13,6 +14,8 @@ import type { DeadLetterJob, DeadLetterStore } from "../deadLetter/deadLetter.ty
13
14
  */
14
15
  export interface QueueLogger {
15
16
  info(message: string, data?: Record<string, unknown>): void;
17
+ /** Receives failures that have no caller to reject (poll errors). */
18
+ error?(message: string, data?: Record<string, unknown>): void;
16
19
  }
17
20
  /**
18
21
  * Options for creating a queue.
@@ -79,6 +82,28 @@ export interface QueueOptions {
79
82
  * reclaimed again. Defaults to 3.
80
83
  */
81
84
  readonly maxStalledCount?: number;
85
+ /**
86
+ * Whether the queue's own poller claims and runs jobs. Defaults to
87
+ * `true`. Creating a `Worker` for the queue turns it off, so the worker —
88
+ * with its middleware, timeout and concurrency — is the only consumer.
89
+ */
90
+ readonly autoProcess?: boolean;
91
+ /**
92
+ * After a job times out, how long its concurrency slot and its retry wait
93
+ * for the processor to actually settle, in milliseconds. Defaults to 5000.
94
+ *
95
+ * A timeout aborts `context.signal`; a processor that ignores the signal
96
+ * keeps running. Without the wait, the retry started beside it and one
97
+ * job ran several times at once. Past this grace the processor is
98
+ * abandoned and the job fails anyway, so processors must honour the
99
+ * signal.
100
+ */
101
+ readonly timeoutGraceMs?: number;
102
+ /**
103
+ * Context carried from `add()` into the processor (tenant, correlation
104
+ * id, trace ids). See {@link QueueContextCarrier}.
105
+ */
106
+ readonly contextCarriers?: readonly QueueContextCarrier[];
82
107
  }
83
108
  /**
84
109
  * Statistics for a queue.
@@ -113,6 +138,14 @@ export interface QueueStats {
113
138
  export interface Queue<TData = unknown> {
114
139
  /** Queue name. */
115
140
  readonly name: QueueName;
141
+ /**
142
+ * The emitter this queue publishes lifecycle events on, when it has one.
143
+ *
144
+ * Exposed so a `Worker` can report its own lifecycle
145
+ * (`worker:started`, `worker:stopped`, `worker:error`) on the same
146
+ * emitter as the jobs it runs.
147
+ */
148
+ readonly events?: QueueEventEmitter;
116
149
  /** Add a job to the queue. */
117
150
  add(name: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
118
151
  /** Process jobs with a processor. */
@@ -171,6 +204,11 @@ export interface Queue<TData = unknown> {
171
204
  getDeadLetterJobs(): Promise<readonly DeadLetterJob<TData>[]>;
172
205
  /** Close the queue, draining in-flight jobs first. */
173
206
  close(): Promise<void>;
207
+ /**
208
+ * Turns the queue's own poller on or off as a consumer. `createWorker`
209
+ * turns it off so a worker is never racing the queue for jobs.
210
+ */
211
+ setAutoProcess?(enabled: boolean): void;
174
212
  }
175
213
  /**
176
214
  * Event types emitted by a queue.
@@ -198,6 +236,7 @@ export type QueueEventMap = {
198
236
  job: Job;
199
237
  attempt: number;
200
238
  };
239
+ /** A running job was aborted from outside — a drain, a close, a cancel. */
201
240
  "job:cancelled": {
202
241
  job: Job;
203
242
  };
@@ -1,5 +1,5 @@
1
1
  import type { QueueEventEmitter } from "./queueEmitter.type.js";
2
- import type { QueueEventMap } from "../queue/queue.type.js";
2
+ import type { QueueEventMap, QueueLogger } from "../queue/queue.type.js";
3
3
  type EventName = keyof QueueEventMap;
4
4
  type Handler<T extends EventName> = (data: QueueEventMap[T]) => void;
5
5
  /**
@@ -12,6 +12,12 @@ export interface QueueEventEmitterOptions {
12
12
  * the emitting code path.
13
13
  */
14
14
  readonly onHandlerError?: (error: unknown, event: EventName) => void;
15
+ /**
16
+ * Receives a throwing listener's error when no `onHandlerError` is given.
17
+ * Without one the failure goes to `process.emitWarning`, bypassing
18
+ * structured logging and redaction.
19
+ */
20
+ readonly logger?: QueueLogger;
15
21
  }
16
22
  /**
17
23
  * In-memory queue event emitter.
@@ -24,7 +30,20 @@ export interface QueueEventEmitterOptions {
24
30
  export declare class InMemoryQueueEventEmitter implements QueueEventEmitter {
25
31
  private readonly handlers;
26
32
  private readonly onHandlerError;
33
+ /** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
34
+ private readonly hasCustomHandlerError;
35
+ private logger;
27
36
  constructor(options?: QueueEventEmitterOptions);
37
+ /**
38
+ * Adopts a logger for the default handler-error report.
39
+ *
40
+ * Called by a queue that was configured with a logger, since the emitter is
41
+ * built before the queue exists and cannot have been given it. A logger or
42
+ * an `onHandlerError` supplied at construction always wins.
43
+ *
44
+ * @param logger - Destination for a throwing listener's error.
45
+ */
46
+ setLogger(logger: QueueLogger): void;
28
47
  emit<K extends EventName>(event: K, data: QueueEventMap[K]): void;
29
48
  on<K extends EventName>(event: K, handler: Handler<K>): () => void;
30
49
  /**
@@ -1,3 +1,4 @@
1
+ import { reportQueueError } from "../queue/queue.report.js";
1
2
  /**
2
3
  * In-memory queue event emitter.
3
4
  *
@@ -9,15 +10,34 @@
9
10
  export class InMemoryQueueEventEmitter {
10
11
  handlers = new Map();
11
12
  onHandlerError;
13
+ /** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
14
+ hasCustomHandlerError;
15
+ logger;
12
16
  constructor(options = {}) {
17
+ this.hasCustomHandlerError = options.onHandlerError !== undefined;
18
+ this.logger = options.logger;
13
19
  this.onHandlerError =
14
20
  options.onHandlerError ??
15
21
  ((error, event) => {
16
22
  queueMicrotask(() => {
17
- console.error(`[@zudojs/queue] Listener for "${event}" threw.`, error);
23
+ reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error, this.logger);
18
24
  });
19
25
  });
20
26
  }
27
+ /**
28
+ * Adopts a logger for the default handler-error report.
29
+ *
30
+ * Called by a queue that was configured with a logger, since the emitter is
31
+ * built before the queue exists and cannot have been given it. A logger or
32
+ * an `onHandlerError` supplied at construction always wins.
33
+ *
34
+ * @param logger - Destination for a throwing listener's error.
35
+ */
36
+ setLogger(logger) {
37
+ if (this.hasCustomHandlerError || this.logger)
38
+ return;
39
+ this.logger = logger;
40
+ }
21
41
  emit(event, data) {
22
42
  const handlers = this.handlers.get(event);
23
43
  if (!handlers) {
@@ -6,6 +6,11 @@ import type { Worker, WorkerOptions } from "./worker.type.js";
6
6
  * The worker claims each job before running it, so a job is never picked
7
7
  * up twice — by this worker on its next poll, or by another worker on the
8
8
  * same queue.
9
+ *
10
+ * Creating a worker turns the queue's own poller off as a consumer
11
+ * (`queue.setAutoProcess(false)`), so the worker — with its middleware,
12
+ * timeout and concurrency — is the only thing running jobs, and `stop()`
13
+ * really stops consumption.
9
14
  */
10
15
  export declare function createWorker<TData>(id: string, queue: Queue<TData>, options?: WorkerOptions): Worker<TData>;
11
16
  /**
@@ -1,5 +1,6 @@
1
1
  import { JobState as JobStateEnum, WorkerState, } from "../jobTypes/jobTypes.type.js";
2
2
  import { WorkerLifecycleError } from "@zudojs/errors";
3
+ import { reportQueueError } from "../queue/queue.report.js";
3
4
  /** How long `stop()` waits for in-flight jobs before forcing a stop. */
4
5
  const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
5
6
  /**
@@ -8,6 +9,11 @@ const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
8
9
  * The worker claims each job before running it, so a job is never picked
9
10
  * up twice — by this worker on its next poll, or by another worker on the
10
11
  * same queue.
12
+ *
13
+ * Creating a worker turns the queue's own poller off as a consumer
14
+ * (`queue.setAutoProcess(false)`), so the worker — with its middleware,
15
+ * timeout and concurrency — is the only thing running jobs, and `stop()`
16
+ * really stops consumption.
11
17
  */
12
18
  export function createWorker(id, queue, options) {
13
19
  let state = WorkerState.CREATED;
@@ -19,12 +25,30 @@ export function createWorker(id, queue, options) {
19
25
  let activeJobs = 0;
20
26
  let polling = false;
21
27
  let abortController = null;
22
- const onError = options?.onError ??
28
+ queue.setAutoProcess?.(false);
29
+ /**
30
+ * Reports the worker's own lifecycle on the queue's emitter.
31
+ *
32
+ * `worker:started`, `worker:stopped` and `worker:error` were part of
33
+ * `QueueEventMap` with nothing emitting them, so a consumer subscribing for
34
+ * readiness never heard from the worker.
35
+ */
36
+ const emitLifecycle = (event) => {
37
+ queue.events?.emit(event, { workerId: id });
38
+ };
39
+ const reportWorkerError = options?.onError ??
23
40
  ((error) => {
24
41
  queueMicrotask(() => {
25
- console.error(`[@zudojs/queue] Worker "${id}" poll failed.`, error);
42
+ reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
26
43
  });
27
44
  });
45
+ const onError = (error) => {
46
+ queue.events?.emit("worker:error", {
47
+ workerId: id,
48
+ error: error instanceof Error ? error : new Error(String(error)),
49
+ });
50
+ reportWorkerError(error);
51
+ };
28
52
  /**
29
53
  * Arms the next poll. At most one timer is ever armed: a delayed poll
30
54
  * already pending is left alone, while an immediate poll (capacity just
@@ -150,6 +174,7 @@ export function createWorker(id, queue, options) {
150
174
  abortController = new AbortController();
151
175
  try {
152
176
  state = WorkerState.RUNNING;
177
+ emitLifecycle("worker:started");
153
178
  scheduleNextPoll(0);
154
179
  }
155
180
  catch (error) {
@@ -186,11 +211,17 @@ export function createWorker(id, queue, options) {
186
211
  }
187
212
  clearPollTimer();
188
213
  state = WorkerState.STOPPED;
214
+ emitLifecycle("worker:stopped");
189
215
  },
190
216
  async forceStop() {
217
+ const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
191
218
  abortController?.abort();
192
219
  clearPollTimer();
193
220
  state = WorkerState.STOPPED;
221
+ // A worker that never started never stopped: reporting it would give a
222
+ // readiness listener a transition that did not happen.
223
+ if (wasLive)
224
+ emitLifecycle("worker:stopped");
194
225
  },
195
226
  isRunning() {
196
227
  return state === WorkerState.RUNNING;
@@ -1,4 +1,4 @@
1
- import type { Queue } from "../queue/queue.type.js";
1
+ import type { Queue, QueueLogger } from "../queue/queue.type.js";
2
2
  import type { QueueMiddleware } from "../middleware/middleware.type.js";
3
3
  import type { WorkerState } from "../jobTypes/jobTypes.type.js";
4
4
  /**
@@ -25,10 +25,13 @@ export interface WorkerOptions {
25
25
  readonly drainTimeout?: number;
26
26
  /**
27
27
  * Invoked for errors raised outside a job — a failing poll, a job that
28
- * threw, or a drain that timed out. Defaults to reporting on the
29
- * console. Poll errors are never left as unhandled rejections.
28
+ * threw, or a drain that timed out. Defaults to `logger.error`, or to
29
+ * `process.emitWarning` without a logger. Poll errors are never left as
30
+ * unhandled rejections.
30
31
  */
31
32
  readonly onError?: (error: unknown) => void;
33
+ /** Receives errors when no `onError` is given. */
34
+ readonly logger?: QueueLogger;
32
35
  }
33
36
  /**
34
37
  * Worker lifecycle states.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/queue",
3
- "version": "1.1.0",
3
+ "version": "1.3.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.0.1",
29
- "@zudojs/constants": "1.0.1",
30
- "@zudojs/serialization": "1.0.1"
28
+ "@zudojs/errors": "1.2.0",
29
+ "@zudojs/constants": "1.1.1",
30
+ "@zudojs/serialization": "1.1.1"
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "7.0.2",