@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
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Which job runs next, and whether any job is still owed a run.
3
+ *
4
+ * @module inMemoryQueue/polling/inMemoryQueue.select
5
+ */
6
+ import type { Job } from "../../job/job.type.js";
7
+ /**
8
+ * When a job became runnable, in epoch milliseconds: its creation, or for a
9
+ * delayed job the moment its delay elapsed.
10
+ *
11
+ * Ordering a due delayed job by `createdAt` let it jump ahead of every job
12
+ * that had been ready and waiting while it was still delayed.
13
+ */
14
+ export declare function runnableAt(job: Job<unknown>): number;
15
+ /**
16
+ * Selects the job that runs next among those that are waiting and due.
17
+ *
18
+ * Higher priority first; within a priority, the job that became runnable
19
+ * first ({@link runnableAt}); among equals, the one added first. The
20
+ * incumbent is tracked by reference rather than by a sentinel priority, so
21
+ * negative priorities are selectable like any other.
22
+ */
23
+ export declare function selectNextJob<TData>(jobs: Iterable<Job<TData>>, now: number, predicate?: (job: Job<TData>) => boolean): Job<TData> | null;
24
+ /** Whether any job a consumer can run is still owed a run. */
25
+ export declare function hasPendingWork<TData>(jobs: Iterable<Job<TData>>, isConsumable: (job: Job<TData>) => boolean): boolean;
26
+ //# sourceMappingURL=inMemoryQueue.select.d.ts.map
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Which job runs next, and whether any job is still owed a run.
3
+ *
4
+ * @module inMemoryQueue/polling/inMemoryQueue.select
5
+ */
6
+ import { JobState as JobStateEnum } from "../../jobTypes/jobTypes.type.js";
7
+ /** States in which a job still has a run ahead of it. */
8
+ const PENDING_STATES = new Set([
9
+ JobStateEnum.WAITING,
10
+ JobStateEnum.SCHEDULED,
11
+ JobStateEnum.RETRYING,
12
+ JobStateEnum.ACTIVE,
13
+ ]);
14
+ /**
15
+ * When a job became runnable, in epoch milliseconds: its creation, or for a
16
+ * delayed job the moment its delay elapsed.
17
+ *
18
+ * Ordering a due delayed job by `createdAt` let it jump ahead of every job
19
+ * that had been ready and waiting while it was still delayed.
20
+ */
21
+ export function runnableAt(job) {
22
+ const created = new Date(job.createdAt).getTime();
23
+ if (!job.scheduledAt)
24
+ return created;
25
+ const scheduled = new Date(job.scheduledAt).getTime();
26
+ return Number.isNaN(scheduled) ? created : Math.max(created, scheduled);
27
+ }
28
+ /**
29
+ * Selects the job that runs next among those that are waiting and due.
30
+ *
31
+ * Higher priority first; within a priority, the job that became runnable
32
+ * first ({@link runnableAt}); among equals, the one added first. The
33
+ * incumbent is tracked by reference rather than by a sentinel priority, so
34
+ * negative priorities are selectable like any other.
35
+ */
36
+ export function selectNextJob(jobs, now, predicate) {
37
+ let next = null;
38
+ let nextAt = 0;
39
+ for (const job of jobs) {
40
+ if (job.state !== JobStateEnum.WAITING)
41
+ continue;
42
+ if (job.scheduledAt && new Date(job.scheduledAt).getTime() > now)
43
+ continue;
44
+ if (predicate && !predicate(job))
45
+ continue;
46
+ const at = runnableAt(job);
47
+ if (next === null ||
48
+ job.priority > next.priority ||
49
+ (job.priority === next.priority && at < nextAt)) {
50
+ next = job;
51
+ nextAt = at;
52
+ }
53
+ }
54
+ return next;
55
+ }
56
+ /** Whether any job a consumer can run is still owed a run. */
57
+ export function hasPendingWork(jobs, isConsumable) {
58
+ for (const job of jobs) {
59
+ if (PENDING_STATES.has(job.state) && isConsumable(job))
60
+ return true;
61
+ }
62
+ return false;
63
+ }
64
+ //# sourceMappingURL=inMemoryQueue.select.js.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @zudojs/queue/inMemoryQueue/polling
3
+ *
4
+ * The in-memory queue's poll loop and job selection: a fixed or backing-off
5
+ * interval, immediate wake-ups when work arrives, keep-alive while work a
6
+ * consumer can run is pending, and runnable-time ordering.
7
+ */
8
+ export * from "./inMemoryQueue.poller.js";
9
+ export * from "./inMemoryQueue.select.js";
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @zudojs/queue/inMemoryQueue/polling
3
+ *
4
+ * The in-memory queue's poll loop and job selection: a fixed or backing-off
5
+ * interval, immediate wake-ups when work arrives, keep-alive while work a
6
+ * consumer can run is pending, and runnable-time ordering.
7
+ */
8
+ export * from "./inMemoryQueue.poller.js";
9
+ export * from "./inMemoryQueue.select.js";
10
+ //# sourceMappingURL=index.js.map
@@ -15,7 +15,11 @@ export interface Job<TData = unknown> {
15
15
  readonly data: TData;
16
16
  /** Current state of the job. */
17
17
  readonly state: JobState;
18
- /** Number of attempts made. */
18
+ /**
19
+ * Number of attempts already made, so `0` while the first attempt runs.
20
+ * Inside a processor, `context.attemptNumber` is the 1-based number of
21
+ * the attempt in progress (`attempt + 1`).
22
+ */
19
23
  readonly attempt: number;
20
24
  /** Maximum number of attempts allowed. */
21
25
  readonly maxAttempts: number;
@@ -4,6 +4,7 @@
4
4
  export function createJobContext(job, signal, options = {}) {
5
5
  return {
6
6
  job,
7
+ attemptNumber: job.attempt + 1,
7
8
  signal,
8
9
  updateProgress: async (progress) => {
9
10
  if (options.onProgress) {
@@ -6,6 +6,13 @@ import type { JobProgress } from "../jobResult/jobResult.type.js";
6
6
  export interface JobContext<TData = unknown> {
7
7
  /** The job being processed. */
8
8
  readonly job: Job<TData>;
9
+ /**
10
+ * The attempt in progress, 1-based: `1` on the first run, `2` on the first
11
+ * retry. Always `job.attempt + 1`, since `job.attempt` counts the attempts
12
+ * already made. Matches `ctx.attempt` / `ctx.attemptNumber` in
13
+ * `@zudojs/scheduler`.
14
+ */
15
+ readonly attemptNumber: number;
9
16
  /** AbortSignal for cancellation support. */
10
17
  readonly signal: AbortSignal;
11
18
  /** Update job progress. */
@@ -32,13 +32,36 @@ export interface QueueOptions {
32
32
  readonly concurrency?: number;
33
33
  /** Default job options. */
34
34
  readonly defaultJobOptions?: Partial<JobOptions>;
35
- /** Serializer for job payloads. */
35
+ /**
36
+ * Serializer for job payloads. Defaults to `JsonSerializer`, which
37
+ * round-trips `Date`, `BigInt`, `Map`, `Set`, `Uint8Array` and `Error`.
38
+ */
36
39
  readonly serializer?: Serializer;
37
40
  /** Middleware for job processing. */
38
41
  readonly middleware?: QueueMiddleware[];
39
- /** Poll interval in milliseconds. */
42
+ /**
43
+ * Milliseconds between polls of the queue's own consumer.
44
+ *
45
+ * When set, every poll uses it. When unset, polls start at 50 ms and back
46
+ * off to 2000 ms while the queue is idle. Either way the poller does not
47
+ * wait for its next poll when work arrives: `add()`, a delayed job coming
48
+ * due, a retry's backoff elapsing, `resume()` and a job finishing all wake
49
+ * it immediately.
50
+ */
40
51
  readonly pollInterval?: number;
41
- /** Event emitter for queue lifecycle events. */
52
+ /**
53
+ * Whether pending work holds the Node.js process open. Defaults to `true`:
54
+ * while the queue has waiting, delayed, retrying or running jobs that one of
55
+ * its processors can run, its poll timer is referenced, so a script whose
56
+ * only work is the queue does not exit before the jobs run. An idle queue,
57
+ * a paused one, work no processor handles, and a closed queue never hold it.
58
+ * Set to `false` to leave every timer unreferenced.
59
+ */
60
+ readonly keepAlive?: boolean;
61
+ /**
62
+ * Event emitter for queue lifecycle events. Defaults to an in-memory
63
+ * emitter, reachable as `queue.events`.
64
+ */
42
65
  readonly eventEmitter?: QueueEventEmitter;
43
66
  /** Store that receives jobs which exhausted their attempts. */
44
67
  readonly deadLetterStore?: DeadLetterStore<never>;
@@ -138,6 +161,14 @@ export interface QueueStats {
138
161
  export interface Queue<TData = unknown> {
139
162
  /** Queue name. */
140
163
  readonly name: QueueName;
164
+ /**
165
+ * The emitter this queue publishes lifecycle events on, when it has one.
166
+ *
167
+ * Exposed so a `Worker` can report its own lifecycle
168
+ * (`worker:started`, `worker:stopped`, `worker:error`) on the same
169
+ * emitter as the jobs it runs.
170
+ */
171
+ readonly events?: QueueEventEmitter;
141
172
  /** Add a job to the queue. */
142
173
  add(name: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
143
174
  /** Process jobs with a processor. */
@@ -201,6 +232,15 @@ export interface Queue<TData = unknown> {
201
232
  * turns it off so a worker is never racing the queue for jobs.
202
233
  */
203
234
  setAutoProcess?(enabled: boolean): void;
235
+ /**
236
+ * Subscribes to "a job may have become runnable" (added, released,
237
+ * reclaimed, a delay or retry backoff elapsed, the queue resumed). A
238
+ * `Worker` uses it to claim at once instead of on its next poll; a queue
239
+ * without it is simply polled.
240
+ *
241
+ * @returns A function that unsubscribes.
242
+ */
243
+ onJobReady?(listener: () => void): () => void;
204
244
  }
205
245
  /**
206
246
  * Event types emitted by a queue.
@@ -228,6 +268,7 @@ export type QueueEventMap = {
228
268
  job: Job;
229
269
  attempt: number;
230
270
  };
271
+ /** A running job was aborted from outside — a drain, a close, a cancel. */
231
272
  "job:cancelled": {
232
273
  job: Job;
233
274
  };
@@ -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
  /**
@@ -10,15 +10,34 @@ import { reportQueueError } from "../queue/queue.report.js";
10
10
  export class InMemoryQueueEventEmitter {
11
11
  handlers = new Map();
12
12
  onHandlerError;
13
+ /** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
14
+ hasCustomHandlerError;
15
+ logger;
13
16
  constructor(options = {}) {
17
+ this.hasCustomHandlerError = options.onHandlerError !== undefined;
18
+ this.logger = options.logger;
14
19
  this.onHandlerError =
15
20
  options.onHandlerError ??
16
21
  ((error, event) => {
17
22
  queueMicrotask(() => {
18
- reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error);
23
+ reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error, this.logger);
19
24
  });
20
25
  });
21
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
+ }
22
41
  emit(event, data) {
23
42
  const handlers = this.handlers.get(event);
24
43
  if (!handlers) {
@@ -2,19 +2,42 @@
2
2
  * @zudojs/queue — Serializer
3
3
  *
4
4
  * Job payload serialization using @zudojs/serialization's JSONSerializer.
5
+ *
6
+ * The default preserves types. Plain JSON turned a `Date` into a string
7
+ * while `Queue<{ d: Date }>` still typed it as a `Date`, so a processor
8
+ * calling `job.data.d.getTime()` crashed; `BigInt` was rejected outright.
9
+ * With `preserveTypes`, `Date`, `BigInt`, `Map`, `Set`, `Uint8Array` and
10
+ * `Error` all come back as themselves, and output for plain JSON data is
11
+ * unchanged.
5
12
  */
6
13
  import type { Serializer } from "./serializer.type.js";
7
- /** Default JSON serializer backed by @zudojs/serialization. */
14
+ /**
15
+ * Default serializer: JSON that round-trips `Date`, `BigInt`, `Map`, `Set`,
16
+ * `Uint8Array` and `Error`, backed by @zudojs/serialization.
17
+ */
8
18
  export declare const JsonSerializer: Serializer;
9
19
  /**
10
- * Creates a serializer with custom options.
20
+ * Creates a JSON serializer with custom options.
11
21
  */
12
22
  export declare function createJsonSerializer(options?: {
13
23
  /** Indentation width. Any value turns on pretty-printing. */
14
24
  space?: number;
15
- /** Preserve BigInt, Date, Map, Set and Uint8Array across the round trip. */
25
+ /**
26
+ * Preserve BigInt, Date, Map, Set, Uint8Array and Error across the round
27
+ * trip. Defaults to `true`, like `JsonSerializer`. With `false` a `Date`
28
+ * comes back as its ISO string, so type such payload fields as `string`.
29
+ */
16
30
  preserveTypes?: boolean;
17
31
  }): Serializer;
18
- /** No-op serializer that passes data through unchanged. */
32
+ /**
33
+ * Serializer that keeps payloads as they are.
34
+ *
35
+ * It sets `passthrough`, so the in-memory queue stores payloads by reference
36
+ * (class instances, functions and all) instead of round-tripping them. Used
37
+ * standalone it must still honour the `Serializer` contract of producing a
38
+ * string: a string is returned untouched, anything else is JSON-encoded, and
39
+ * `deserialize` parses JSON where it can and returns the raw string where it
40
+ * cannot.
41
+ */
19
42
  export declare const PassthroughSerializer: Serializer;
20
43
  //# sourceMappingURL=serializer.core.d.ts.map
@@ -2,48 +2,67 @@
2
2
  * @zudojs/queue — Serializer
3
3
  *
4
4
  * Job payload serialization using @zudojs/serialization's JSONSerializer.
5
+ *
6
+ * The default preserves types. Plain JSON turned a `Date` into a string
7
+ * while `Queue<{ d: Date }>` still typed it as a `Date`, so a processor
8
+ * calling `job.data.d.getTime()` crashed; `BigInt` was rejected outright.
9
+ * With `preserveTypes`, `Date`, `BigInt`, `Map`, `Set`, `Uint8Array` and
10
+ * `Error` all come back as themselves, and output for plain JSON data is
11
+ * unchanged.
5
12
  */
6
13
  import { JSONSerializer } from "@zudojs/serialization";
7
- /** Default JSON serializer backed by @zudojs/serialization. */
14
+ const inner = new JSONSerializer();
15
+ /**
16
+ * Default serializer: JSON that round-trips `Date`, `BigInt`, `Map`, `Set`,
17
+ * `Uint8Array` and `Error`, backed by @zudojs/serialization.
18
+ */
8
19
  export const JsonSerializer = Object.freeze({
9
20
  serialize(data) {
10
- return new JSONSerializer().serialize(data);
21
+ return inner.serialize(data, { preserveTypes: true });
11
22
  },
12
23
  deserialize(data) {
13
- return new JSONSerializer().deserialize(data);
24
+ return inner.deserialize(data, { preserveTypes: true });
14
25
  },
15
26
  });
16
27
  /**
17
- * Creates a serializer with custom options.
28
+ * Creates a JSON serializer with custom options.
18
29
  */
19
30
  export function createJsonSerializer(options) {
20
- const inner = new JSONSerializer();
31
+ const preserveTypes = options?.preserveTypes ?? true;
21
32
  return Object.freeze({
22
33
  serialize(data) {
23
34
  return inner.serialize(data, {
24
35
  pretty: options?.space !== undefined,
25
- preserveTypes: options?.preserveTypes,
36
+ preserveTypes,
26
37
  indent: options?.space,
27
38
  });
28
39
  },
29
40
  deserialize(data) {
30
- return inner.deserialize(data, {
31
- preserveTypes: options?.preserveTypes,
32
- });
41
+ return inner.deserialize(data, { preserveTypes });
33
42
  },
34
43
  });
35
44
  }
36
- /** No-op serializer that passes data through unchanged. */
45
+ /**
46
+ * Serializer that keeps payloads as they are.
47
+ *
48
+ * It sets `passthrough`, so the in-memory queue stores payloads by reference
49
+ * (class instances, functions and all) instead of round-tripping them. Used
50
+ * standalone it must still honour the `Serializer` contract of producing a
51
+ * string: a string is returned untouched, anything else is JSON-encoded, and
52
+ * `deserialize` parses JSON where it can and returns the raw string where it
53
+ * cannot.
54
+ */
37
55
  export const PassthroughSerializer = Object.freeze({
56
+ passthrough: true,
38
57
  serialize(data) {
39
58
  if (typeof data === "string") {
40
59
  return data;
41
60
  }
42
- return new JSONSerializer().serialize(data);
61
+ return inner.serialize(data);
43
62
  },
44
63
  deserialize(data) {
45
64
  try {
46
- return new JSONSerializer().deserialize(data);
65
+ return inner.deserialize(data);
47
66
  }
48
67
  catch {
49
68
  return data;
@@ -4,6 +4,12 @@
4
4
  * Provides serialization and deserialization of job data.
5
5
  */
6
6
  export interface Serializer {
7
+ /**
8
+ * Marks a serializer that stores values as given. The in-memory queue
9
+ * skips its round trip for such a serializer and keeps the payload by
10
+ * reference, exactly as with `serializePayloads: false`.
11
+ */
12
+ readonly passthrough?: boolean;
7
13
  /** Serialize data to a string. */
8
14
  serialize<T>(data: T): string;
9
15
  /** Deserialize a string to data. */
@@ -25,13 +25,32 @@ export function createWorker(id, queue, options) {
25
25
  let activeJobs = 0;
26
26
  let polling = false;
27
27
  let abortController = null;
28
+ const keepAlive = options?.keepAlive ?? true;
29
+ let stopWatching;
28
30
  queue.setAutoProcess?.(false);
29
- const onError = options?.onError ??
31
+ /**
32
+ * Reports the worker's own lifecycle on the queue's emitter.
33
+ *
34
+ * `worker:started`, `worker:stopped` and `worker:error` were part of
35
+ * `QueueEventMap` with nothing emitting them, so a consumer subscribing for
36
+ * readiness never heard from the worker.
37
+ */
38
+ const emitLifecycle = (event) => {
39
+ queue.events?.emit(event, { workerId: id });
40
+ };
41
+ const reportWorkerError = options?.onError ??
30
42
  ((error) => {
31
43
  queueMicrotask(() => {
32
44
  reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
33
45
  });
34
46
  });
47
+ const onError = (error) => {
48
+ queue.events?.emit("worker:error", {
49
+ workerId: id,
50
+ error: error instanceof Error ? error : new Error(String(error)),
51
+ });
52
+ reportWorkerError(error);
53
+ };
35
54
  /**
36
55
  * Arms the next poll. At most one timer is ever armed: a delayed poll
37
56
  * already pending is left alone, while an immediate poll (capacity just
@@ -47,7 +66,19 @@ export function createWorker(id, queue, options) {
47
66
  clearTimeout(pollTimer);
48
67
  }
49
68
  pollTimer = setTimeout(runPoll, delay);
50
- pollTimer.unref?.();
69
+ // A started worker is the process's reason to stay alive until it is
70
+ // stopped; an unreferenced timer let a script exit before any job ran.
71
+ if (!keepAlive)
72
+ pollTimer.unref?.();
73
+ };
74
+ /** Claims at once when the queue reports a runnable job, if a slot is free. */
75
+ const onJobReady = () => {
76
+ if (activeJobs < concurrency)
77
+ scheduleNextPoll(0);
78
+ };
79
+ const unwatch = () => {
80
+ stopWatching?.();
81
+ stopWatching = undefined;
51
82
  };
52
83
  /**
53
84
  * Wraps `poll` so a rejection can never escape as an unhandled promise
@@ -157,6 +188,8 @@ export function createWorker(id, queue, options) {
157
188
  abortController = new AbortController();
158
189
  try {
159
190
  state = WorkerState.RUNNING;
191
+ stopWatching = queue.onJobReady?.(onJobReady);
192
+ emitLifecycle("worker:started");
160
193
  scheduleNextPoll(0);
161
194
  }
162
195
  catch (error) {
@@ -174,10 +207,12 @@ export function createWorker(id, queue, options) {
174
207
  if (state !== WorkerState.RUNNING && state !== WorkerState.STARTING) {
175
208
  // Still clear any timer armed before the state moved on.
176
209
  clearPollTimer();
210
+ unwatch();
177
211
  return;
178
212
  }
179
213
  state = WorkerState.DRAINING;
180
214
  clearPollTimer();
215
+ unwatch();
181
216
  // Graceful means graceful: in-flight jobs get `drainTimeout` to
182
217
  // finish on their own. Aborting them up front — as this once did —
183
218
  // made `stop()` indistinguishable from `forceStop()` for any
@@ -193,11 +228,18 @@ export function createWorker(id, queue, options) {
193
228
  }
194
229
  clearPollTimer();
195
230
  state = WorkerState.STOPPED;
231
+ emitLifecycle("worker:stopped");
196
232
  },
197
233
  async forceStop() {
234
+ const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
198
235
  abortController?.abort();
199
236
  clearPollTimer();
237
+ unwatch();
200
238
  state = WorkerState.STOPPED;
239
+ // A worker that never started never stopped: reporting it would give a
240
+ // readiness listener a transition that did not happen.
241
+ if (wasLive)
242
+ emitLifecycle("worker:stopped");
201
243
  },
202
244
  isRunning() {
203
245
  return state === WorkerState.RUNNING;
@@ -7,8 +7,18 @@ import type { WorkerState } from "../jobTypes/jobTypes.type.js";
7
7
  export interface WorkerOptions {
8
8
  /** Maximum number of jobs to process concurrently. */
9
9
  readonly concurrency?: number;
10
- /** Poll interval in milliseconds. */
10
+ /**
11
+ * Milliseconds between polls while idle. Defaults to 100. A queue that
12
+ * supports `onJobReady` (the in-memory queue does) wakes the worker as soon
13
+ * as a job becomes runnable, so this bounds only how often it re-checks.
14
+ */
11
15
  readonly pollInterval?: number;
16
+ /**
17
+ * Whether a started worker holds the Node.js process open until `stop()`
18
+ * or `forceStop()`. Defaults to `true`; `false` leaves its timers
19
+ * unreferenced.
20
+ */
21
+ readonly keepAlive?: boolean;
12
22
  /**
13
23
  * Default job timeout in milliseconds, applied to jobs that do not
14
24
  * carry their own. Stall detection is a queue-level concern; configure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/queue",
3
- "version": "1.2.0",
3
+ "version": "1.4.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,13 +25,13 @@
25
25
  "!dist/.tsbuildinfo"
26
26
  ],
27
27
  "dependencies": {
28
- "@zudojs/errors": "1.1.0",
29
- "@zudojs/constants": "1.1.0",
30
- "@zudojs/serialization": "1.1.0"
28
+ "@zudojs/errors": "1.3.0",
29
+ "@zudojs/constants": "1.1.2",
30
+ "@zudojs/serialization": "1.2.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "typescript": "7.0.2",
34
- "vitest": "^4.1.11"
34
+ "vitest": "^5.0.1"
35
35
  },
36
36
  "engines": {
37
37
  "node": ">=24.0.0"
@@ -46,7 +46,7 @@
46
46
  "background-jobs",
47
47
  "worker"
48
48
  ],
49
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
49
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-queue",
50
50
  "bugs": {
51
51
  "url": "https://github.com/oyinlola-tech/zudo/issues"
52
52
  },