@zudojs/queue 1.3.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.
package/README.md CHANGED
@@ -72,6 +72,70 @@ await queue.close();
72
72
  - Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
73
73
  queue does not grow without limit
74
74
 
75
+ ## Polling and process lifetime
76
+
77
+ The queue's own consumer polls every `pollInterval` milliseconds when you set
78
+ one; unset, it starts at 50 ms and backs off to 2000 ms while idle. It never
79
+ waits out that interval when work arrives: `add()`, a delayed job coming due, a
80
+ retry's backoff elapsing, `resume()` and a finished job all wake it at once. A
81
+ `Worker` is woken the same way through `queue.onJobReady`, so its
82
+ `pollInterval` only bounds how often an idle worker re-checks.
83
+
84
+ Pending work keeps the Node.js process alive. While the queue has waiting,
85
+ delayed, retrying or running jobs that one of its processors can run, a script
86
+ whose only work is the queue does not exit before the jobs run. A started
87
+ `Worker` keeps the process alive until `stop()` or `forceStop()`. An idle queue,
88
+ a paused one, work that no processor handles, and a closed queue never hold the
89
+ process open. Pass `keepAlive: false` (to the queue or the worker) for the old
90
+ behaviour, where every timer was unreferenced.
91
+
92
+ ## Payloads
93
+
94
+ Payloads round-trip through the serializer on `add()`, so the stored job is a
95
+ copy. The default `JsonSerializer` preserves `Date`, `BigInt`, `Map`, `Set`,
96
+ `Uint8Array` and `Error`, so a field typed `Date` arrives as a `Date`:
97
+
98
+ ```typescript
99
+ const reminders = createInMemoryQueue<{ at: Date }>(createQueueName("reminders"));
100
+ reminders.process("remind", async (job) => job.data.at.getTime()); // a real Date
101
+ ```
102
+
103
+ `createJsonSerializer({ preserveTypes: false })` gives plain JSON, where a
104
+ `Date` becomes its ISO string (type it as `string`). `PassthroughSerializer`
105
+ (or `serializePayloads: false`) stores payloads by reference, class instances
106
+ included, with no copy and no serialization.
107
+
108
+ ## Ordering
109
+
110
+ Higher `priority` runs first. Within a priority, jobs run in the order they
111
+ became runnable: when they were added, or for a delayed job when its delay
112
+ elapsed, so a delayed job never jumps ahead of jobs that were already waiting
113
+ when it came due. A retried job keeps its original place in line.
114
+
115
+ ## Attempts
116
+
117
+ `job.attempt` counts the attempts already made, so it is `0` while the first
118
+ attempt runs. `context.attemptNumber` is the 1-based number of the attempt in
119
+ progress (`job.attempt + 1`), the same convention as `ctx.attempt` in
120
+ `@zudojs/scheduler`:
121
+
122
+ ```typescript
123
+ queue.process("sync", async (job, context) => {
124
+ context.log(`attempt ${context.attemptNumber} of ${job.maxAttempts}`);
125
+ });
126
+ ```
127
+
128
+ ## Events
129
+
130
+ `queue.events` works without configuration: a queue created without an
131
+ `eventEmitter` gets an in-memory one.
132
+
133
+ ```typescript
134
+ queue.events?.on("job:completed", ({ job, result }) => {
135
+ console.log(job.id, result);
136
+ });
137
+ ```
138
+
75
139
  ## Workers, timeouts and context
76
140
 
77
141
  **One consumer at a time.** `queue.process()` registers a processor and, by
@@ -24,7 +24,9 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
24
24
  /** Whether the internal poller claims jobs; see `setAutoProcess`. */
25
25
  private autoProcess;
26
26
  private activeCount;
27
- private pollTimer;
27
+ private readonly poller;
28
+ /** Consumers to tell when a job may have become runnable. */
29
+ private readonly readyListeners;
28
30
  private readonly scheduledTimers;
29
31
  private readonly retryTimers;
30
32
  private readonly inFlight;
@@ -40,9 +42,16 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
40
42
  private readonly ownsDeadLetterStore;
41
43
  private readonly emitter;
42
44
  private readonly counters;
43
- private emptySince;
44
- private backoffMs;
45
45
  constructor(name: QueueName, options?: QueueOptions);
46
+ /**
47
+ * Subscribes to "a job may have become runnable": one was added, released
48
+ * or reclaimed, a delay or retry backoff elapsed, or the queue resumed.
49
+ *
50
+ * A `Worker` uses this to claim immediately instead of on its next poll.
51
+ *
52
+ * @returns A function that unsubscribes.
53
+ */
54
+ onJobReady(listener: () => void): () => void;
46
55
  /**
47
56
  * Turns the internal poller on or off as a consumer.
48
57
  *
@@ -55,8 +64,9 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
55
64
  /**
56
65
  * The emitter this queue publishes lifecycle events on.
57
66
  *
58
- * A no-op emitter when the queue was created without one, so a worker can
59
- * report its lifecycle unconditionally.
67
+ * An in-memory emitter when the queue was created without one, so
68
+ * `queue.events.on(...)` works out of the box and a worker can report its
69
+ * lifecycle unconditionally.
60
70
  */
61
71
  get events(): QueueEventEmitter;
62
72
  add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
@@ -113,17 +123,18 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
113
123
  */
114
124
  private encodePayload;
115
125
  /**
116
- * Selects the highest-priority job that is due and runnable.
117
- *
118
- * Ties on priority are broken by creation time, oldest first. The
119
- * incumbent is tracked by reference rather than by a sentinel priority,
120
- * so jobs with negative priorities are selectable like any other.
126
+ * Selects the next job that is due and runnable; see {@link selectNextJob}
127
+ * for the ordering.
121
128
  */
122
129
  private selectJob;
123
- private startPolling;
124
- private scheduleNextTick;
125
- private scheduleTick;
126
- private stopPolling;
130
+ /**
131
+ * Whether the poll loop should hold the process open: while the queue is
132
+ * consuming and has work one of its processors can run. Work nothing can
133
+ * consume, a paused queue and `keepAlive: false` never do.
134
+ */
135
+ private shouldKeepAlive;
136
+ /** Wakes the poll loop and tells consumers a job may be runnable. */
137
+ private jobReady;
127
138
  /**
128
139
  * Runs an already-claimed job through this queue's processing pipeline.
129
140
  *
@@ -138,6 +149,12 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
138
149
  /** Fallback timeout for a job that carries none of its own. */
139
150
  timeoutMs?: number;
140
151
  }): Promise<void>;
152
+ /**
153
+ * One poll: promotes due delayed jobs, claims runnable jobs up to the
154
+ * concurrency limit, and reclaims stalled ones.
155
+ *
156
+ * @returns Whether any job was dispatched.
157
+ */
141
158
  private processTick;
142
159
  /**
143
160
  * Returns jobs stuck in `active` to the waiting pool.
@@ -5,10 +5,11 @@ 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
7
  import { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, } from "../deadLetter/deadLetter.core.js";
8
- import { InMemoryQueueEventEmitter, createNoopQueueEventEmitter, } from "../queueEmitter/queueEmitter.core.js";
8
+ import { InMemoryQueueEventEmitter } from "../queueEmitter/queueEmitter.core.js";
9
9
  import { processJob } from "./inMemoryQueue.processing.js";
10
10
  import { captureContext } from "../contextCarrier/contextCarrier.core.js";
11
11
  import { scheduleJob, promoteDueScheduledJobs, } from "./inMemoryQueue.scheduling.js";
12
+ import { QueuePoller, hasPendingWork, selectNextJob } from "./polling/index.js";
12
13
  /** Terminal states a job never leaves. */
13
14
  const TERMINAL_STATES = new Set([
14
15
  JobStateEnum.COMPLETED,
@@ -38,7 +39,9 @@ export class InMemoryQueue {
38
39
  /** Whether the internal poller claims jobs; see `setAutoProcess`. */
39
40
  autoProcess;
40
41
  activeCount = 0;
41
- pollTimer = null;
42
+ poller;
43
+ /** Consumers to tell when a job may have become runnable. */
44
+ readyListeners = new Set();
42
45
  scheduledTimers = new Map();
43
46
  retryTimers = new Map();
44
47
  inFlight = new Map();
@@ -60,14 +63,16 @@ export class InMemoryQueue {
60
63
  retriedCount: 0,
61
64
  deadLetteredCount: 0,
62
65
  };
63
- emptySince = 0;
64
- backoffMs = 50;
65
66
  constructor(name, options) {
66
67
  this.name = name;
67
68
  this.options = options ?? {};
68
69
  this.serializer = this.options.serializer ?? JsonSerializer;
69
70
  this.middleware = this.options.middleware ?? [];
70
- this.emitter = options?.eventEmitter ?? createNoopQueueEventEmitter();
71
+ // A working emitter by default: `queue.events` used to be a silent no-op
72
+ // unless one was passed in.
73
+ this.emitter =
74
+ options?.eventEmitter ??
75
+ new InMemoryQueueEventEmitter(this.options.logger ? { logger: this.options.logger } : {});
71
76
  // A supplied emitter is built before the queue exists, so it cannot have
72
77
  // been given the queue's logger. Hand it over, so a throwing listener is
73
78
  // reported through structured logging rather than `process.emitWarning`.
@@ -82,6 +87,28 @@ export class InMemoryQueue {
82
87
  maxEntries: DEFAULT_DEAD_LETTER_JOBS,
83
88
  });
84
89
  this.autoProcess = this.options.autoProcess ?? true;
90
+ this.poller = new QueuePoller({
91
+ ...(this.options.pollInterval !== undefined
92
+ ? { pollInterval: this.options.pollInterval }
93
+ : {}),
94
+ tick: () => this.processTick(),
95
+ isLive: () => !this.disposed && this.processors.size > 0,
96
+ shouldKeepAlive: () => this.shouldKeepAlive(),
97
+ });
98
+ }
99
+ /**
100
+ * Subscribes to "a job may have become runnable": one was added, released
101
+ * or reclaimed, a delay or retry backoff elapsed, or the queue resumed.
102
+ *
103
+ * A `Worker` uses this to claim immediately instead of on its next poll.
104
+ *
105
+ * @returns A function that unsubscribes.
106
+ */
107
+ onJobReady(listener) {
108
+ this.readyListeners.add(listener);
109
+ return () => {
110
+ this.readyListeners.delete(listener);
111
+ };
85
112
  }
86
113
  /**
87
114
  * Turns the internal poller on or off as a consumer.
@@ -93,12 +120,15 @@ export class InMemoryQueue {
93
120
  */
94
121
  setAutoProcess(enabled) {
95
122
  this.autoProcess = enabled;
123
+ if (enabled)
124
+ this.poller.wake();
96
125
  }
97
126
  /**
98
127
  * The emitter this queue publishes lifecycle events on.
99
128
  *
100
- * A no-op emitter when the queue was created without one, so a worker can
101
- * report its lifecycle unconditionally.
129
+ * An in-memory emitter when the queue was created without one, so
130
+ * `queue.events.on(...)` works out of the box and a worker can report its
131
+ * lifecycle unconditionally.
102
132
  */
103
133
  get events() {
104
134
  return this.emitter;
@@ -143,10 +173,13 @@ export class InMemoryQueue {
143
173
  this.deduplicationIndex.set(mergedOptions.deduplicationKey, jobId);
144
174
  }
145
175
  if (job.state === JobStateEnum.SCHEDULED && job.scheduledAt) {
146
- scheduleJob(job, this.scheduledTimers, this.jobs);
176
+ scheduleJob(job, this.scheduledTimers, this.jobs, () => this.jobReady());
147
177
  }
148
- this.backoffMs = 50;
149
- this.emptySince = 0;
178
+ // Wake the poller and any worker now. Resetting the back-off without
179
+ // re-arming the pending timer left a job added after an idle spell
180
+ // waiting up to 2 s; a delayed job wakes it too, so the pending work
181
+ // holds the process open.
182
+ this.jobReady();
150
183
  return job;
151
184
  }
152
185
  process(name, processor) {
@@ -154,8 +187,8 @@ export class InMemoryQueue {
154
187
  throw new QueueDisposedError(this.name);
155
188
  assertProcessor(processor, name);
156
189
  this.processors.set(name, processor);
157
- if (!this.pollTimer)
158
- this.startPolling();
190
+ // Jobs already waiting under this name are runnable now.
191
+ this.poller.wake();
159
192
  }
160
193
  async getJob(jobId) {
161
194
  return this.jobs.get(jobId) ?? null;
@@ -199,6 +232,7 @@ export class InMemoryQueue {
199
232
  return false;
200
233
  }
201
234
  this.jobs.set(jobId, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
235
+ this.jobReady();
202
236
  return true;
203
237
  }
204
238
  getProcessor(name) {
@@ -263,10 +297,7 @@ export class InMemoryQueue {
263
297
  if (this.disposed)
264
298
  throw new QueueDisposedError(this.name);
265
299
  this.paused = false;
266
- this.backoffMs = 50;
267
- this.emptySince = 0;
268
- if (!this.pollTimer && this.processors.size > 0)
269
- this.startPolling();
300
+ this.jobReady();
270
301
  }
271
302
  isPaused() {
272
303
  return this.paused;
@@ -288,7 +319,8 @@ export class InMemoryQueue {
288
319
  // Stop accepting and dispatching work before draining, so the set of
289
320
  // in-flight jobs cannot grow while we wait for it.
290
321
  this.disposed = true;
291
- this.stopPolling();
322
+ this.poller.stop();
323
+ this.readyListeners.clear();
292
324
  for (const timer of this.scheduledTimers.values())
293
325
  clearTimeout(timer);
294
326
  this.scheduledTimers.clear();
@@ -309,8 +341,6 @@ export class InMemoryQueue {
309
341
  }
310
342
  this.activeCount = 0;
311
343
  this.paused = false;
312
- this.emptySince = 0;
313
- this.backoffMs = 50;
314
344
  }
315
345
  /**
316
346
  * Waits for in-flight jobs to settle, aborting them past the timeout.
@@ -345,7 +375,10 @@ export class InMemoryQueue {
345
375
  * Round-trips a payload through the configured serializer.
346
376
  */
347
377
  encodePayload(jobId, data) {
348
- if (this.options.serializePayloads === false) {
378
+ // A passthrough serializer means "store the payload as given": there is
379
+ // no string form to round-trip through.
380
+ if (this.options.serializePayloads === false ||
381
+ this.serializer.passthrough === true) {
349
382
  return data;
350
383
  }
351
384
  try {
@@ -356,62 +389,36 @@ export class InMemoryQueue {
356
389
  }
357
390
  }
358
391
  /**
359
- * Selects the highest-priority job that is due and runnable.
360
- *
361
- * Ties on priority are broken by creation time, oldest first. The
362
- * incumbent is tracked by reference rather than by a sentinel priority,
363
- * so jobs with negative priorities are selectable like any other.
392
+ * Selects the next job that is due and runnable; see {@link selectNextJob}
393
+ * for the ordering.
364
394
  */
365
395
  selectJob(predicate) {
366
- const now = Date.now();
367
- let nextJob = null;
368
- for (const job of this.jobs.values()) {
369
- if (job.state !== JobStateEnum.WAITING)
370
- continue;
371
- if (job.scheduledAt && new Date(job.scheduledAt).getTime() > now)
372
- continue;
373
- if (predicate && !predicate(job))
374
- continue;
375
- if (nextJob === null) {
376
- nextJob = job;
377
- continue;
378
- }
379
- if (job.priority > nextJob.priority) {
380
- nextJob = job;
381
- continue;
382
- }
383
- if (job.priority === nextJob.priority) {
384
- const candidateTime = new Date(job.createdAt).getTime();
385
- const incumbentTime = new Date(nextJob.createdAt).getTime();
386
- if (candidateTime < incumbentTime)
387
- nextJob = job;
388
- }
389
- }
390
- return nextJob;
391
- }
392
- startPolling() {
393
- this.scheduleTick(this.options.pollInterval ?? 50);
394
- }
395
- scheduleNextTick() {
396
- this.scheduleTick(this.backoffMs);
396
+ return selectNextJob(this.jobs.values(), Date.now(), predicate);
397
397
  }
398
- scheduleTick(interval) {
399
- if (this.disposed)
400
- return;
401
- this.pollTimer = setTimeout(() => {
402
- this.pollTimer = null;
403
- this.processTick().finally(() => {
404
- if (!this.disposed && this.processors.size > 0)
405
- this.scheduleNextTick();
406
- });
407
- }, interval);
408
- // The poll timer must not be the reason a process stays alive.
409
- this.pollTimer.unref?.();
398
+ /**
399
+ * Whether the poll loop should hold the process open: while the queue is
400
+ * consuming and has work one of its processors can run. Work nothing can
401
+ * consume, a paused queue and `keepAlive: false` never do.
402
+ */
403
+ shouldKeepAlive() {
404
+ if (this.options.keepAlive === false)
405
+ return false;
406
+ if (this.disposed || this.paused || !this.autoProcess)
407
+ return false;
408
+ if (this.activeCount > 0)
409
+ return true;
410
+ return hasPendingWork(this.jobs.values(), (job) => this.processors.has(job.name));
410
411
  }
411
- stopPolling() {
412
- if (this.pollTimer) {
413
- clearTimeout(this.pollTimer);
414
- this.pollTimer = null;
412
+ /** Wakes the poll loop and tells consumers a job may be runnable. */
413
+ jobReady() {
414
+ this.poller.wake();
415
+ for (const listener of [...this.readyListeners]) {
416
+ try {
417
+ listener();
418
+ }
419
+ catch {
420
+ // A consumer's wake-up hook must not break the producer.
421
+ }
415
422
  }
416
423
  }
417
424
  /**
@@ -476,6 +483,7 @@ export class InMemoryQueue {
476
483
  this.retryTimers.delete(jobId);
477
484
  },
478
485
  onSettled: (settled) => this.recordSettled(settled),
486
+ onJobReady: () => this.jobReady(),
479
487
  isDisposed: () => this.disposed,
480
488
  ...(this.options.logger ? { logger: this.options.logger } : {}),
481
489
  });
@@ -489,15 +497,26 @@ export class InMemoryQueue {
489
497
  finally {
490
498
  this.activeCount--;
491
499
  this.inFlight.delete(job.id);
500
+ // A slot is free: claim the next job now rather than on the next poll.
501
+ this.poller.wake();
492
502
  if (forwardAbort && consumerSignal) {
493
503
  consumerSignal.removeEventListener("abort", forwardAbort);
494
504
  }
495
505
  }
496
506
  }
507
+ /**
508
+ * One poll: promotes due delayed jobs, claims runnable jobs up to the
509
+ * concurrency limit, and reclaims stalled ones.
510
+ *
511
+ * @returns Whether any job was dispatched.
512
+ */
497
513
  async processTick() {
498
514
  if (this.paused || this.disposed)
499
- return;
515
+ return false;
500
516
  const concurrency = Math.max(1, this.options.concurrency ?? 1);
517
+ // Promote first, so a job whose delay just elapsed is claimable in this
518
+ // same tick.
519
+ promoteDueScheduledJobs(this.jobs, this.scheduledTimers);
501
520
  let processed = 0;
502
521
  while (this.autoProcess && this.activeCount < concurrency) {
503
522
  // `claimNextJob` only returns jobs that have a registered
@@ -513,22 +532,8 @@ export class InMemoryQueue {
513
532
  // sees the dispatch immediately.
514
533
  void this.runJob(job);
515
534
  }
516
- if (processed > 0) {
517
- this.backoffMs = 50;
518
- this.emptySince = 0;
519
- }
520
- else if (this.emptySince === 0) {
521
- this.emptySince = Date.now();
522
- this.backoffMs = 50;
523
- }
524
- else {
525
- const elapsed = Date.now() - this.emptySince;
526
- if (elapsed > 500) {
527
- this.backoffMs = Math.min(this.backoffMs * 2, 2000);
528
- }
529
- }
530
- promoteDueScheduledJobs(this.jobs, this.scheduledTimers);
531
535
  this.reclaimStalledJobs();
536
+ return processed > 0;
532
537
  }
533
538
  /**
534
539
  * Returns jobs stuck in `active` to the waiting pool.
@@ -581,6 +586,7 @@ export class InMemoryQueue {
581
586
  }
582
587
  this.jobs.set(job.id, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
583
588
  this.emitter.emit("job:failed", { job, error });
589
+ this.jobReady();
584
590
  }
585
591
  }
586
592
  /**
@@ -37,6 +37,8 @@ export interface ProcessJobDependencies<TData> {
37
37
  readonly deregisterRetryTimer?: (jobId: JobId) => void;
38
38
  /** Invoked whenever a job reaches a terminal state. */
39
39
  readonly onSettled?: (job: Job<TData>) => void;
40
+ /** Invoked when a retrying job's backoff elapses and it is runnable again. */
41
+ readonly onJobReady?: () => void;
40
42
  /** Whether the owning queue has been disposed. */
41
43
  readonly isDisposed: () => boolean;
42
44
  /**
@@ -148,10 +148,11 @@ export async function handleJobFailure(job, errorMessage, deps) {
148
148
  failedAt: undefined,
149
149
  });
150
150
  jobs.set(job.id, waitingJob);
151
+ deps.onJobReady?.();
151
152
  }
152
153
  }, delay);
153
- // `unref` keeps a pending retry from holding the process open; the
154
- // queue clears the timer explicitly on close.
154
+ // Unreferenced: the queue's poll loop decides whether a pending retry
155
+ // holds the process open. The queue clears the timer on close.
155
156
  timer.unref?.();
156
157
  deps.registerRetryTimer(job.id, timer);
157
158
  return;
@@ -4,10 +4,13 @@ import type { JobId } from "../jobTypes/jobTypes.type.js";
4
4
  * Schedule a job for future execution.
5
5
  *
6
6
  * The timer is registered so the queue can clear it on close, and
7
- * unreferenced so a scheduled job never by itself keeps the process
8
- * alive.
7
+ * unreferenced: the queue's poll loop, not each job's timer, decides whether
8
+ * pending work holds the process open.
9
+ *
10
+ * @param onDue - Told when the job has been promoted to `waiting`, so a
11
+ * consumer can claim it now rather than on its next poll.
9
12
  */
10
- export declare function scheduleJob<TData>(job: Job<TData>, scheduledTimers: Map<JobId, ReturnType<typeof setTimeout>>, jobs: Map<string, Job<TData>>): void;
13
+ export declare function scheduleJob<TData>(job: Job<TData>, scheduledTimers: Map<JobId, ReturnType<typeof setTimeout>>, jobs: Map<string, Job<TData>>, onDue?: () => void): void;
11
14
  /**
12
15
  * Promotes scheduled jobs whose time has arrived.
13
16
  *
@@ -5,10 +5,13 @@ import { MAX_TIMER_DELAY } from "../retryPolicy/retryPolicy.core.js";
5
5
  * Schedule a job for future execution.
6
6
  *
7
7
  * The timer is registered so the queue can clear it on close, and
8
- * unreferenced so a scheduled job never by itself keeps the process
9
- * alive.
8
+ * unreferenced: the queue's poll loop, not each job's timer, decides whether
9
+ * pending work holds the process open.
10
+ *
11
+ * @param onDue - Told when the job has been promoted to `waiting`, so a
12
+ * consumer can claim it now rather than on its next poll.
10
13
  */
11
- export function scheduleJob(job, scheduledTimers, jobs) {
14
+ export function scheduleJob(job, scheduledTimers, jobs, onDue) {
12
15
  if (!job.scheduledAt) {
13
16
  return;
14
17
  }
@@ -17,6 +20,7 @@ export function scheduleJob(job, scheduledTimers, jobs) {
17
20
  // An unparseable schedule would otherwise fire immediately via NaN
18
21
  // coercion; promote the job instead of guessing at a delay.
19
22
  jobs.set(job.id, updateJobState(job, JobStateEnum.WAITING));
23
+ onDue?.();
20
24
  return;
21
25
  }
22
26
  const delay = Math.min(Math.max(0, scheduledTime - Date.now()), MAX_TIMER_DELAY);
@@ -29,6 +33,7 @@ export function scheduleJob(job, scheduledTimers, jobs) {
29
33
  const currentJob = jobs.get(job.id);
30
34
  if (currentJob && currentJob.state === JobStateEnum.SCHEDULED) {
31
35
  jobs.set(job.id, updateJobState(currentJob, JobStateEnum.WAITING));
36
+ onDue?.();
32
37
  }
33
38
  }, delay);
34
39
  timer.unref?.();
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The in-memory queue's poll loop.
3
+ *
4
+ * A tick promotes due delayed jobs, claims runnable ones and reclaims stalled
5
+ * ones. Three defects lived here: `pollInterval` applied to the first tick
6
+ * only (every later tick used a built-in 50 ms that backed off to 2 s while
7
+ * idle), nothing woke the loop when work arrived (so an add after an idle
8
+ * spell waited out the back-off), and every timer was unreferenced (so a
9
+ * script whose only work was a queue exited before any job ran).
10
+ *
11
+ * @module inMemoryQueue/polling/inMemoryQueue.poller
12
+ */
13
+ /** First interval, and the floor of the idle back-off, when none is set. */
14
+ export declare const DEFAULT_POLL_INTERVAL_MS = 50;
15
+ /** Ceiling of the idle back-off when no `pollInterval` is set. */
16
+ export declare const MAX_IDLE_POLL_INTERVAL_MS = 2000;
17
+ /** What the poller drives. */
18
+ export interface QueuePollerOptions {
19
+ /**
20
+ * A fixed interval between ticks. When set it is used for every tick;
21
+ * when unset the interval starts at {@link DEFAULT_POLL_INTERVAL_MS} and
22
+ * backs off while idle.
23
+ */
24
+ readonly pollInterval?: number;
25
+ /** Runs one tick. Resolves true when it dispatched any job. */
26
+ readonly tick: () => Promise<boolean>;
27
+ /** Whether the loop should keep running at all. */
28
+ readonly isLive: () => boolean;
29
+ /** Whether the armed timer should hold the process open. */
30
+ readonly shouldKeepAlive: () => boolean;
31
+ }
32
+ /**
33
+ * Drives a queue's ticks: a fixed or backing-off interval, an immediate
34
+ * `wake()` when work may have arrived, and at most one tick at a time.
35
+ */
36
+ export declare class QueuePoller {
37
+ private readonly options;
38
+ private timer;
39
+ private armedDelay;
40
+ private ticking;
41
+ private wakeRequested;
42
+ private backoffMs;
43
+ private emptySince;
44
+ constructor(options: QueuePollerOptions);
45
+ /** Whether a tick is armed or running. */
46
+ get isRunning(): boolean;
47
+ /** Arms the first tick, unless the loop is already running. */
48
+ start(): void;
49
+ /** Runs a tick as soon as possible, and clears the idle back-off. */
50
+ wake(): void;
51
+ /** Clears the idle back-off without arming anything. */
52
+ resetBackoff(): void;
53
+ /** Disarms the loop. A tick already running finishes but re-arms nothing. */
54
+ stop(): void;
55
+ private arm;
56
+ /**
57
+ * Referenced while there is work a consumer can run, so the process waits
58
+ * for it; unreferenced otherwise, so an idle queue never holds it open.
59
+ */
60
+ private applyKeepAlive;
61
+ private run;
62
+ private nextDelay;
63
+ }
64
+ //# sourceMappingURL=inMemoryQueue.poller.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The in-memory queue's poll loop.
3
+ *
4
+ * A tick promotes due delayed jobs, claims runnable ones and reclaims stalled
5
+ * ones. Three defects lived here: `pollInterval` applied to the first tick
6
+ * only (every later tick used a built-in 50 ms that backed off to 2 s while
7
+ * idle), nothing woke the loop when work arrived (so an add after an idle
8
+ * spell waited out the back-off), and every timer was unreferenced (so a
9
+ * script whose only work was a queue exited before any job ran).
10
+ *
11
+ * @module inMemoryQueue/polling/inMemoryQueue.poller
12
+ */
13
+ /** First interval, and the floor of the idle back-off, when none is set. */
14
+ export const DEFAULT_POLL_INTERVAL_MS = 50;
15
+ /** Ceiling of the idle back-off when no `pollInterval` is set. */
16
+ export const MAX_IDLE_POLL_INTERVAL_MS = 2_000;
17
+ /** How long the loop must find nothing before an unset interval backs off. */
18
+ const IDLE_BEFORE_BACKOFF_MS = 500;
19
+ /**
20
+ * Drives a queue's ticks: a fixed or backing-off interval, an immediate
21
+ * `wake()` when work may have arrived, and at most one tick at a time.
22
+ */
23
+ export class QueuePoller {
24
+ options;
25
+ timer = null;
26
+ armedDelay = 0;
27
+ ticking = false;
28
+ wakeRequested = false;
29
+ backoffMs = DEFAULT_POLL_INTERVAL_MS;
30
+ emptySince = 0;
31
+ constructor(options) {
32
+ this.options = options;
33
+ }
34
+ /** Whether a tick is armed or running. */
35
+ get isRunning() {
36
+ return this.timer !== null || this.ticking;
37
+ }
38
+ /** Arms the first tick, unless the loop is already running. */
39
+ start() {
40
+ if (this.isRunning)
41
+ return;
42
+ this.arm(this.options.pollInterval ?? DEFAULT_POLL_INTERVAL_MS);
43
+ }
44
+ /** Runs a tick as soon as possible, and clears the idle back-off. */
45
+ wake() {
46
+ this.resetBackoff();
47
+ if (!this.options.isLive())
48
+ return;
49
+ if (this.ticking) {
50
+ this.wakeRequested = true;
51
+ return;
52
+ }
53
+ if (this.timer !== null && this.armedDelay === 0) {
54
+ // Already due; the work that woke us may change whether it holds the
55
+ // process open.
56
+ this.applyKeepAlive();
57
+ return;
58
+ }
59
+ this.arm(0);
60
+ }
61
+ /** Clears the idle back-off without arming anything. */
62
+ resetBackoff() {
63
+ this.backoffMs = DEFAULT_POLL_INTERVAL_MS;
64
+ this.emptySince = 0;
65
+ }
66
+ /** Disarms the loop. A tick already running finishes but re-arms nothing. */
67
+ stop() {
68
+ if (this.timer !== null)
69
+ clearTimeout(this.timer);
70
+ this.timer = null;
71
+ this.wakeRequested = false;
72
+ this.resetBackoff();
73
+ }
74
+ arm(delay) {
75
+ if (!this.options.isLive())
76
+ return;
77
+ if (this.timer !== null)
78
+ clearTimeout(this.timer);
79
+ this.armedDelay = delay;
80
+ this.timer = setTimeout(() => {
81
+ this.timer = null;
82
+ void this.run();
83
+ }, delay);
84
+ this.applyKeepAlive();
85
+ }
86
+ /**
87
+ * Referenced while there is work a consumer can run, so the process waits
88
+ * for it; unreferenced otherwise, so an idle queue never holds it open.
89
+ */
90
+ applyKeepAlive() {
91
+ if (this.timer === null)
92
+ return;
93
+ if (this.options.shouldKeepAlive())
94
+ this.timer.ref?.();
95
+ else
96
+ this.timer.unref?.();
97
+ }
98
+ async run() {
99
+ this.ticking = true;
100
+ let dispatched = false;
101
+ try {
102
+ dispatched = await this.options.tick();
103
+ }
104
+ catch {
105
+ // A tick never rejects by design; a defect must not stop the loop.
106
+ }
107
+ finally {
108
+ this.ticking = false;
109
+ }
110
+ if (!this.options.isLive() || this.timer !== null)
111
+ return;
112
+ const woken = this.wakeRequested;
113
+ this.wakeRequested = false;
114
+ this.arm(woken ? 0 : this.nextDelay(dispatched));
115
+ }
116
+ nextDelay(dispatched) {
117
+ if (this.options.pollInterval !== undefined) {
118
+ return Math.max(0, this.options.pollInterval);
119
+ }
120
+ if (dispatched) {
121
+ this.resetBackoff();
122
+ }
123
+ else if (this.emptySince === 0) {
124
+ this.emptySince = Date.now();
125
+ }
126
+ else if (Date.now() - this.emptySince > IDLE_BEFORE_BACKOFF_MS) {
127
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_IDLE_POLL_INTERVAL_MS);
128
+ }
129
+ return this.backoffMs;
130
+ }
131
+ }
132
+ //# sourceMappingURL=inMemoryQueue.poller.js.map
@@ -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>;
@@ -209,6 +232,15 @@ export interface Queue<TData = unknown> {
209
232
  * turns it off so a worker is never racing the queue for jobs.
210
233
  */
211
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;
212
244
  }
213
245
  /**
214
246
  * Event types emitted by a queue.
@@ -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,6 +25,8 @@ 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
31
  /**
30
32
  * Reports the worker's own lifecycle on the queue's emitter.
@@ -64,7 +66,19 @@ export function createWorker(id, queue, options) {
64
66
  clearTimeout(pollTimer);
65
67
  }
66
68
  pollTimer = setTimeout(runPoll, delay);
67
- 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;
68
82
  };
69
83
  /**
70
84
  * Wraps `poll` so a rejection can never escape as an unhandled promise
@@ -174,6 +188,7 @@ export function createWorker(id, queue, options) {
174
188
  abortController = new AbortController();
175
189
  try {
176
190
  state = WorkerState.RUNNING;
191
+ stopWatching = queue.onJobReady?.(onJobReady);
177
192
  emitLifecycle("worker:started");
178
193
  scheduleNextPoll(0);
179
194
  }
@@ -192,10 +207,12 @@ export function createWorker(id, queue, options) {
192
207
  if (state !== WorkerState.RUNNING && state !== WorkerState.STARTING) {
193
208
  // Still clear any timer armed before the state moved on.
194
209
  clearPollTimer();
210
+ unwatch();
195
211
  return;
196
212
  }
197
213
  state = WorkerState.DRAINING;
198
214
  clearPollTimer();
215
+ unwatch();
199
216
  // Graceful means graceful: in-flight jobs get `drainTimeout` to
200
217
  // finish on their own. Aborting them up front — as this once did —
201
218
  // made `stop()` indistinguishable from `forceStop()` for any
@@ -217,6 +234,7 @@ export function createWorker(id, queue, options) {
217
234
  const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
218
235
  abortController?.abort();
219
236
  clearPollTimer();
237
+ unwatch();
220
238
  state = WorkerState.STOPPED;
221
239
  // A worker that never started never stopped: reporting it would give a
222
240
  // readiness listener a transition that did not happen.
@@ -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.3.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.2.0",
29
- "@zudojs/constants": "1.1.1",
30
- "@zudojs/serialization": "1.1.1"
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
  },