@zudojs/queue 1.0.0 → 1.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/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());
@@ -69,6 +72,58 @@ await queue.close();
69
72
  - Bounded retention of settled jobs, so a long-lived queue does not grow without
70
73
  limit
71
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
+ Errors with no caller to receive them (a worker's failing poll, a throwing event
124
+ listener) go to `logger.error` when a logger is configured, and otherwise to
125
+ `process.emitWarning`, never to `console`.
126
+
72
127
  ## Use Cases
73
128
 
74
129
  - Background email sending
@@ -0,0 +1,31 @@
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
+ * @param carriers - The queue's context carriers.
17
+ * @param metadata - The job's own metadata, if any.
18
+ * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
19
+ * the input unchanged when no carrier captured anything.
20
+ */
21
+ export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
22
+ /**
23
+ * Runs `fn` inside every context the job carries, first carrier outermost.
24
+ *
25
+ * @param carriers - The queue's context carriers.
26
+ * @param job - The job about to run.
27
+ * @param fn - The work to run inside the restored context.
28
+ * @returns Whatever `fn` resolves to.
29
+ */
30
+ export declare function runWithContext<T>(carriers: readonly QueueContextCarrier[] | undefined, job: Job<unknown>, fn: () => Promise<T>): Promise<T>;
31
+ //# sourceMappingURL=contextCarrier.core.d.ts.map
@@ -0,0 +1,60 @@
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
+ * Captures every carrier's value into a metadata record.
13
+ *
14
+ * @param carriers - The queue's context carriers.
15
+ * @param metadata - The job's own metadata, if any.
16
+ * @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
17
+ * the input unchanged when no carrier captured anything.
18
+ */
19
+ export function captureContext(carriers, metadata) {
20
+ if (!carriers || carriers.length === 0)
21
+ return metadata;
22
+ const captured = {};
23
+ let any = false;
24
+ for (const carrier of carriers) {
25
+ const value = carrier.capture();
26
+ if (value === undefined)
27
+ continue;
28
+ captured[carrier.key] = value;
29
+ any = true;
30
+ }
31
+ if (!any)
32
+ return metadata;
33
+ return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
34
+ }
35
+ /**
36
+ * Runs `fn` inside every context the job carries, first carrier outermost.
37
+ *
38
+ * @param carriers - The queue's context carriers.
39
+ * @param job - The job about to run.
40
+ * @param fn - The work to run inside the restored context.
41
+ * @returns Whatever `fn` resolves to.
42
+ */
43
+ export function runWithContext(carriers, job, fn) {
44
+ const raw = job.metadata?.[CONTEXT_METADATA_KEY];
45
+ if (!carriers || carriers.length === 0)
46
+ return fn();
47
+ if (typeof raw !== "object" || raw === null)
48
+ return fn();
49
+ const stored = raw;
50
+ let run = fn;
51
+ for (let i = carriers.length - 1; i >= 0; i--) {
52
+ const carrier = carriers[i];
53
+ if (!Object.hasOwn(stored, carrier.key))
54
+ continue;
55
+ const inner = run;
56
+ run = () => carrier.restore(stored[carrier.key], inner);
57
+ }
58
+ return run();
59
+ }
60
+ //# 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
@@ -20,6 +20,8 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
20
20
  private readonly middleware;
21
21
  private paused;
22
22
  private disposed;
23
+ /** Whether the internal poller claims jobs; see `setAutoProcess`. */
24
+ private autoProcess;
23
25
  private activeCount;
24
26
  private pollTimer;
25
27
  private readonly scheduledTimers;
@@ -35,6 +37,15 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
35
37
  private emptySince;
36
38
  private backoffMs;
37
39
  constructor(name: QueueName, options?: QueueOptions);
40
+ /**
41
+ * Turns the internal poller on or off as a consumer.
42
+ *
43
+ * While off, the poller still promotes scheduled jobs and reclaims stalled
44
+ * ones, but claims nothing: an external `Worker` is the only consumer, so
45
+ * stopping it really stops consumption and its middleware, timeout and
46
+ * concurrency apply to every job.
47
+ */
48
+ setAutoProcess(enabled: boolean): void;
38
49
  add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
39
50
  process(name: string, processor: Processor<TData>): void;
40
51
  getJob(jobId: JobId): Promise<Job<TData> | null>;
@@ -7,6 +7,7 @@ import { JsonSerializer } from "../serializer/serializer.core.js";
7
7
  import { createInMemoryDeadLetterStore } from "../deadLetter/deadLetter.core.js";
8
8
  import { 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();
@@ -62,6 +65,18 @@ export class InMemoryQueue {
62
65
  this.emitter = options?.eventEmitter ?? createNoopQueueEventEmitter();
63
66
  this.deadLetterStore =
64
67
  this.options.deadLetterStore ?? createInMemoryDeadLetterStore();
68
+ this.autoProcess = this.options.autoProcess ?? true;
69
+ }
70
+ /**
71
+ * Turns the internal poller on or off as a consumer.
72
+ *
73
+ * While off, the poller still promotes scheduled jobs and reclaims stalled
74
+ * ones, but claims nothing: an external `Worker` is the only consumer, so
75
+ * stopping it really stops consumption and its middleware, timeout and
76
+ * concurrency apply to every job.
77
+ */
78
+ setAutoProcess(enabled) {
79
+ this.autoProcess = enabled;
65
80
  }
66
81
  async add(jobName, data, options) {
67
82
  if (this.disposed)
@@ -74,7 +89,9 @@ export class InMemoryQueue {
74
89
  queueName: this.name,
75
90
  });
76
91
  }
77
- const mergedOptions = { ...this.options.defaultJobOptions, ...options };
92
+ const merged = { ...this.options.defaultJobOptions, ...options };
93
+ const metadata = captureContext(this.options.contextCarriers, merged.metadata);
94
+ const mergedOptions = metadata === merged.metadata ? merged : { ...merged, metadata };
78
95
  if (mergedOptions.deduplicationKey) {
79
96
  const existing = this.deduplicationIndex.get(mergedOptions.deduplicationKey);
80
97
  if (existing && this.jobs.has(existing)) {
@@ -402,6 +419,12 @@ export class InMemoryQueue {
402
419
  await processJob(job, processor, {
403
420
  timeoutMs: job.timeoutMs ?? options?.timeoutMs,
404
421
  abortController,
422
+ ...(this.options.timeoutGraceMs !== undefined
423
+ ? { timeoutGraceMs: this.options.timeoutGraceMs }
424
+ : {}),
425
+ ...(this.options.contextCarriers
426
+ ? { contextCarriers: this.options.contextCarriers }
427
+ : {}),
405
428
  }, {
406
429
  jobs: this.jobs,
407
430
  emitter: this.emitter,
@@ -413,6 +436,9 @@ export class InMemoryQueue {
413
436
  registerRetryTimer: (jobId, timer) => {
414
437
  this.retryTimers.set(jobId, timer);
415
438
  },
439
+ deregisterRetryTimer: (jobId) => {
440
+ this.retryTimers.delete(jobId);
441
+ },
416
442
  onSettled: (settled) => this.recordSettled(settled),
417
443
  isDisposed: () => this.disposed,
418
444
  ...(this.options.logger ? { logger: this.options.logger } : {}),
@@ -427,7 +453,6 @@ export class InMemoryQueue {
427
453
  finally {
428
454
  this.activeCount--;
429
455
  this.inFlight.delete(job.id);
430
- this.retryTimers.delete(job.id);
431
456
  }
432
457
  }
433
458
  async processTick() {
@@ -435,7 +460,7 @@ export class InMemoryQueue {
435
460
  return;
436
461
  const concurrency = Math.max(1, this.options.concurrency ?? 1);
437
462
  let processed = 0;
438
- while (this.activeCount < concurrency) {
463
+ while (this.autoProcess && this.activeCount < concurrency) {
439
464
  // `claimNextJob` only returns jobs that have a registered
440
465
  // processor and moves them out of `waiting`, so this loop always
441
466
  // 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`.
@@ -57,14 +59,18 @@ export async function processJob(job, processor, options, deps) {
57
59
  timeoutMiddleware,
58
60
  ...deps.middleware,
59
61
  ]);
62
+ // The processor promise itself, so a failure (a timeout above all) can
63
+ // wait for it to stop before the slot and the retry are released.
64
+ let running;
60
65
  try {
61
- const result = await middlewareChain({
66
+ const result = await runWithContext(options.contextCarriers, updatedJob, () => middlewareChain({
62
67
  job: updatedJob,
63
68
  context,
64
69
  next: async () => {
65
- return processor(updatedJob, context);
70
+ running = Promise.resolve(processor(updatedJob, context));
71
+ return running;
66
72
  },
67
- });
73
+ }));
68
74
  if (isJobResult(result) && !result.success) {
69
75
  await handleJobFailure(updatedJob, result.error ?? "Job failed", deps);
70
76
  }
@@ -85,6 +91,7 @@ export async function processJob(job, processor, options, deps) {
85
91
  }
86
92
  catch (error) {
87
93
  const errorMessage = error instanceof Error ? error.message : String(error);
94
+ await settleWithin(running, options.timeoutGraceMs ?? DEFAULT_TIMEOUT_GRACE_MS);
88
95
  await handleJobFailure(updatedJob, errorMessage, deps);
89
96
  }
90
97
  finally {
@@ -122,6 +129,7 @@ export async function handleJobFailure(job, errorMessage, deps) {
122
129
  const backoff = resolveBackoff(incrementedJob.backoff);
123
130
  const delay = calculateRetryDelay(incrementedJob.attempt, backoff);
124
131
  const timer = setTimeout(() => {
132
+ deps.deregisterRetryTimer?.(job.id);
125
133
  if (deps.isDisposed()) {
126
134
  return;
127
135
  }
@@ -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.
@@ -171,6 +196,11 @@ export interface Queue<TData = unknown> {
171
196
  getDeadLetterJobs(): Promise<readonly DeadLetterJob<TData>[]>;
172
197
  /** Close the queue, draining in-flight jobs first. */
173
198
  close(): Promise<void>;
199
+ /**
200
+ * Turns the queue's own poller on or off as a consumer. `createWorker`
201
+ * turns it off so a worker is never racing the queue for jobs.
202
+ */
203
+ setAutoProcess?(enabled: boolean): void;
174
204
  }
175
205
  /**
176
206
  * Event types emitted by a queue.
@@ -1,3 +1,4 @@
1
+ import { reportQueueError } from "../queue/queue.report.js";
1
2
  /**
2
3
  * In-memory queue event emitter.
3
4
  *
@@ -14,7 +15,7 @@ export class InMemoryQueueEventEmitter {
14
15
  options.onHandlerError ??
15
16
  ((error, event) => {
16
17
  queueMicrotask(() => {
17
- console.error(`[@zudojs/queue] Listener for "${event}" threw.`, error);
18
+ reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error);
18
19
  });
19
20
  });
20
21
  }
@@ -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;
@@ -17,17 +23,29 @@ export function createWorker(id, queue, options) {
17
23
  const drainTimeout = options?.drainTimeout ?? DEFAULT_DRAIN_TIMEOUT_MS;
18
24
  let pollTimer = null;
19
25
  let activeJobs = 0;
26
+ let polling = false;
20
27
  let abortController = null;
28
+ queue.setAutoProcess?.(false);
21
29
  const onError = options?.onError ??
22
30
  ((error) => {
23
31
  queueMicrotask(() => {
24
- console.error(`[@zudojs/queue] Worker "${id}" poll failed.`, error);
32
+ reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
25
33
  });
26
34
  });
35
+ /**
36
+ * Arms the next poll. At most one timer is ever armed: a delayed poll
37
+ * already pending is left alone, while an immediate poll (capacity just
38
+ * freed up, or a job was just dispatched) supersedes it.
39
+ */
27
40
  const scheduleNextPoll = (delay) => {
28
41
  if (state !== WorkerState.RUNNING) {
29
42
  return;
30
43
  }
44
+ if (pollTimer !== null) {
45
+ if (delay > 0)
46
+ return;
47
+ clearTimeout(pollTimer);
48
+ }
31
49
  pollTimer = setTimeout(runPoll, delay);
32
50
  pollTimer.unref?.();
33
51
  };
@@ -37,34 +55,20 @@ export function createWorker(id, queue, options) {
37
55
  * fatal, would take down the whole application.
38
56
  */
39
57
  const runPoll = () => {
58
+ pollTimer = null;
40
59
  void poll().catch((error) => {
41
60
  onError(error);
42
61
  scheduleNextPoll(pollInterval);
43
62
  });
44
63
  };
45
- const poll = async () => {
46
- if (state !== WorkerState.RUNNING || abortController?.signal.aborted) {
47
- return;
48
- }
49
- if (activeJobs >= concurrency) {
50
- scheduleNextPoll(pollInterval);
51
- return;
52
- }
53
- const job = await queue.claimNextJob();
54
- if (!job) {
55
- scheduleNextPoll(pollInterval);
56
- return;
57
- }
58
- const proc = queue.getProcessor(job.name);
59
- if (!proc) {
60
- // Claimed but unrunnable: release it rather than stranding it in
61
- // `active` where nothing would ever pick it up again.
62
- await queue.releaseJob(job.id);
63
- scheduleNextPoll(pollInterval);
64
- return;
65
- }
66
- activeJobs++;
67
- stats.processed++;
64
+ /**
65
+ * Runs one claimed job to completion and frees its concurrency slot.
66
+ *
67
+ * Deliberately not awaited by `poll`: awaiting it there serialised the
68
+ * worker, so `concurrency` was reported by `getStats()` and honoured by
69
+ * nothing.
70
+ */
71
+ const runClaimedJob = async (job) => {
68
72
  try {
69
73
  // Dispatch through the queue rather than invoking the processor
70
74
  // directly. The queue owns job state, retry, dead-lettering and
@@ -91,10 +95,46 @@ export function createWorker(id, queue, options) {
91
95
  }
92
96
  finally {
93
97
  activeJobs--;
94
- // Poll again immediately while there is capacity, but yield to the
95
- // event loop first so a saturated queue cannot starve timers.
98
+ // A slot just opened: poll again immediately, yielding to the event
99
+ // loop first so a saturated queue cannot starve timers.
100
+ scheduleNextPoll(0);
101
+ }
102
+ };
103
+ const poll = async () => {
104
+ if (polling ||
105
+ state !== WorkerState.RUNNING ||
106
+ abortController?.signal.aborted) {
107
+ return;
108
+ }
109
+ polling = true;
110
+ try {
111
+ if (activeJobs >= concurrency) {
112
+ scheduleNextPoll(pollInterval);
113
+ return;
114
+ }
115
+ const job = await queue.claimNextJob();
116
+ if (!job) {
117
+ scheduleNextPoll(pollInterval);
118
+ return;
119
+ }
120
+ const proc = queue.getProcessor(job.name);
121
+ if (!proc) {
122
+ // Claimed but unrunnable: release it rather than stranding it in
123
+ // `active` where nothing would ever pick it up again.
124
+ await queue.releaseJob(job.id);
125
+ scheduleNextPoll(pollInterval);
126
+ return;
127
+ }
128
+ activeJobs++;
129
+ stats.processed++;
130
+ void runClaimedJob(job);
131
+ // Capacity may remain: look for more work now, not after this job
132
+ // settles.
96
133
  scheduleNextPoll(0);
97
134
  }
135
+ finally {
136
+ polling = false;
137
+ }
98
138
  };
99
139
  const clearPollTimer = () => {
100
140
  if (pollTimer) {
@@ -138,13 +178,18 @@ export function createWorker(id, queue, options) {
138
178
  }
139
179
  state = WorkerState.DRAINING;
140
180
  clearPollTimer();
141
- abortController?.abort();
181
+ // Graceful means graceful: in-flight jobs get `drainTimeout` to
182
+ // finish on their own. Aborting them up front — as this once did —
183
+ // made `stop()` indistinguishable from `forceStop()` for any
184
+ // processor that honours its signal, and turned every routine
185
+ // shutdown into a batch of failed jobs.
142
186
  const deadline = Date.now() + Math.max(0, drainTimeout);
143
187
  while (activeJobs > 0 && Date.now() < deadline) {
144
188
  await new Promise((resolve) => setTimeout(resolve, 25));
145
189
  }
146
190
  if (activeJobs > 0) {
147
191
  onError(new WorkerLifecycleError(`Worker "${id}" still had ${activeJobs} job(s) in flight after ${drainTimeout}ms; forcing stop.`, { workerId: id }));
192
+ abortController?.abort();
148
193
  }
149
194
  clearPollTimer();
150
195
  state = WorkerState.STOPPED;
@@ -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,8 +1,12 @@
1
1
  {
2
2
  "name": "@zudojs/queue",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.",
5
5
  "license": "MIT",
6
+ "author": {
7
+ "name": "Oluwayemi Oyinlola",
8
+ "url": "https://github.com/oyinlola-tech"
9
+ },
6
10
  "type": "module",
7
11
  "main": "./dist/index.js",
8
12
  "module": "./dist/index.js",
@@ -21,9 +25,9 @@
21
25
  "!dist/.tsbuildinfo"
22
26
  ],
23
27
  "dependencies": {
24
- "@zudojs/errors": "1.0.0",
25
- "@zudojs/constants": "1.0.0",
26
- "@zudojs/serialization": "1.0.0"
28
+ "@zudojs/errors": "1.1.0",
29
+ "@zudojs/constants": "1.1.0",
30
+ "@zudojs/serialization": "1.1.0"
27
31
  },
28
32
  "devDependencies": {
29
33
  "typescript": "7.0.2",