@zudojs/queue 1.3.0 → 1.4.1

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
@@ -57,6 +57,35 @@ console.log(await queue.getStats());
57
57
  await queue.close();
58
58
  ```
59
59
 
60
+ ## Dead-letter store
61
+
62
+ Each queue keeps the most recent 1000 dead-lettered jobs in a store of its own,
63
+ cleared by `close()`. To choose the cap, or to keep the jobs after the queue
64
+ closes, pass a store as `deadLetterStore`. No type annotation is needed, for
65
+ an untyped store or one typed for the queue's payload.
66
+
67
+ ```typescript
68
+ import {
69
+ createInMemoryDeadLetterStore,
70
+ createInMemoryQueue,
71
+ createQueueName,
72
+ } from "@zudojs/queue";
73
+
74
+ // Keep only the 50 most recent failures.
75
+ const store = createInMemoryDeadLetterStore({ maxEntries: 50 });
76
+ const broken = createInMemoryQueue(createQueueName("broken"), {
77
+ deadLetterStore: store,
78
+ });
79
+
80
+ // A store typed for the payload, so its entries read as DeadLetterJob<Email>.
81
+ const failedEmails = createInMemoryDeadLetterStore<Email>();
82
+ const emails = createInMemoryQueue<Email>(createQueueName("emails"), {
83
+ deadLetterStore: failedEmails,
84
+ });
85
+ ```
86
+
87
+ `close()` leaves a store you passed in alone; its contents are yours.
88
+
60
89
  ## Features
61
90
 
62
91
  - In-memory queue for development and testing
@@ -72,6 +101,70 @@ await queue.close();
72
101
  - Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
73
102
  queue does not grow without limit
74
103
 
104
+ ## Polling and process lifetime
105
+
106
+ The queue's own consumer polls every `pollInterval` milliseconds when you set
107
+ one; unset, it starts at 50 ms and backs off to 2000 ms while idle. It never
108
+ waits out that interval when work arrives: `add()`, a delayed job coming due, a
109
+ retry's backoff elapsing, `resume()` and a finished job all wake it at once. A
110
+ `Worker` is woken the same way through `queue.onJobReady`, so its
111
+ `pollInterval` only bounds how often an idle worker re-checks.
112
+
113
+ Pending work keeps the Node.js process alive. While the queue has waiting,
114
+ delayed, retrying or running jobs that one of its processors can run, a script
115
+ whose only work is the queue does not exit before the jobs run. A started
116
+ `Worker` keeps the process alive until `stop()` or `forceStop()`. An idle queue,
117
+ a paused one, work that no processor handles, and a closed queue never hold the
118
+ process open. Pass `keepAlive: false` (to the queue or the worker) for the old
119
+ behaviour, where every timer was unreferenced.
120
+
121
+ ## Payloads
122
+
123
+ Payloads round-trip through the serializer on `add()`, so the stored job is a
124
+ copy. The default `JsonSerializer` preserves `Date`, `BigInt`, `Map`, `Set`,
125
+ `Uint8Array` and `Error`, so a field typed `Date` arrives as a `Date`:
126
+
127
+ ```typescript
128
+ const reminders = createInMemoryQueue<{ at: Date }>(createQueueName("reminders"));
129
+ reminders.process("remind", async (job) => job.data.at.getTime()); // a real Date
130
+ ```
131
+
132
+ `createJsonSerializer({ preserveTypes: false })` gives plain JSON, where a
133
+ `Date` becomes its ISO string (type it as `string`). `PassthroughSerializer`
134
+ (or `serializePayloads: false`) stores payloads by reference, class instances
135
+ included, with no copy and no serialization.
136
+
137
+ ## Ordering
138
+
139
+ Higher `priority` runs first. Within a priority, jobs run in the order they
140
+ became runnable: when they were added, or for a delayed job when its delay
141
+ elapsed, so a delayed job never jumps ahead of jobs that were already waiting
142
+ when it came due. A retried job keeps its original place in line.
143
+
144
+ ## Attempts
145
+
146
+ `job.attempt` counts the attempts already made, so it is `0` while the first
147
+ attempt runs. `context.attemptNumber` is the 1-based number of the attempt in
148
+ progress (`job.attempt + 1`), the same convention as `ctx.attempt` in
149
+ `@zudojs/scheduler`:
150
+
151
+ ```typescript
152
+ queue.process("sync", async (job, context) => {
153
+ context.log(`attempt ${context.attemptNumber} of ${job.maxAttempts}`);
154
+ });
155
+ ```
156
+
157
+ ## Events
158
+
159
+ `queue.events` works without configuration: a queue created without an
160
+ `eventEmitter` gets an in-memory one.
161
+
162
+ ```typescript
163
+ queue.events?.on("job:completed", ({ job, result }) => {
164
+ console.log(job.id, result);
165
+ });
166
+ ```
167
+
75
168
  ## Workers, timeouts and context
76
169
 
77
170
  **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`.
@@ -76,12 +81,37 @@ export class InMemoryQueue {
76
81
  this.emitter.setLogger(this.options.logger);
77
82
  }
78
83
  this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
84
+ // A caller's store is typed `DeadLetterStore<unknown>` (see
85
+ // `QueueOptions.deadLetterStore`); this queue only ever adds its own
86
+ // `TData` jobs to it, so it is read back as a store of `TData`.
79
87
  this.deadLetterStore =
80
88
  this.options.deadLetterStore ??
81
89
  createInMemoryDeadLetterStore({
82
90
  maxEntries: DEFAULT_DEAD_LETTER_JOBS,
83
91
  });
84
92
  this.autoProcess = this.options.autoProcess ?? true;
93
+ this.poller = new QueuePoller({
94
+ ...(this.options.pollInterval !== undefined
95
+ ? { pollInterval: this.options.pollInterval }
96
+ : {}),
97
+ tick: () => this.processTick(),
98
+ isLive: () => !this.disposed && this.processors.size > 0,
99
+ shouldKeepAlive: () => this.shouldKeepAlive(),
100
+ });
101
+ }
102
+ /**
103
+ * Subscribes to "a job may have become runnable": one was added, released
104
+ * or reclaimed, a delay or retry backoff elapsed, or the queue resumed.
105
+ *
106
+ * A `Worker` uses this to claim immediately instead of on its next poll.
107
+ *
108
+ * @returns A function that unsubscribes.
109
+ */
110
+ onJobReady(listener) {
111
+ this.readyListeners.add(listener);
112
+ return () => {
113
+ this.readyListeners.delete(listener);
114
+ };
85
115
  }
86
116
  /**
87
117
  * Turns the internal poller on or off as a consumer.
@@ -93,12 +123,15 @@ export class InMemoryQueue {
93
123
  */
94
124
  setAutoProcess(enabled) {
95
125
  this.autoProcess = enabled;
126
+ if (enabled)
127
+ this.poller.wake();
96
128
  }
97
129
  /**
98
130
  * The emitter this queue publishes lifecycle events on.
99
131
  *
100
- * A no-op emitter when the queue was created without one, so a worker can
101
- * report its lifecycle unconditionally.
132
+ * An in-memory emitter when the queue was created without one, so
133
+ * `queue.events.on(...)` works out of the box and a worker can report its
134
+ * lifecycle unconditionally.
102
135
  */
103
136
  get events() {
104
137
  return this.emitter;
@@ -143,10 +176,13 @@ export class InMemoryQueue {
143
176
  this.deduplicationIndex.set(mergedOptions.deduplicationKey, jobId);
144
177
  }
145
178
  if (job.state === JobStateEnum.SCHEDULED && job.scheduledAt) {
146
- scheduleJob(job, this.scheduledTimers, this.jobs);
179
+ scheduleJob(job, this.scheduledTimers, this.jobs, () => this.jobReady());
147
180
  }
148
- this.backoffMs = 50;
149
- this.emptySince = 0;
181
+ // Wake the poller and any worker now. Resetting the back-off without
182
+ // re-arming the pending timer left a job added after an idle spell
183
+ // waiting up to 2 s; a delayed job wakes it too, so the pending work
184
+ // holds the process open.
185
+ this.jobReady();
150
186
  return job;
151
187
  }
152
188
  process(name, processor) {
@@ -154,8 +190,8 @@ export class InMemoryQueue {
154
190
  throw new QueueDisposedError(this.name);
155
191
  assertProcessor(processor, name);
156
192
  this.processors.set(name, processor);
157
- if (!this.pollTimer)
158
- this.startPolling();
193
+ // Jobs already waiting under this name are runnable now.
194
+ this.poller.wake();
159
195
  }
160
196
  async getJob(jobId) {
161
197
  return this.jobs.get(jobId) ?? null;
@@ -199,6 +235,7 @@ export class InMemoryQueue {
199
235
  return false;
200
236
  }
201
237
  this.jobs.set(jobId, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
238
+ this.jobReady();
202
239
  return true;
203
240
  }
204
241
  getProcessor(name) {
@@ -263,10 +300,7 @@ export class InMemoryQueue {
263
300
  if (this.disposed)
264
301
  throw new QueueDisposedError(this.name);
265
302
  this.paused = false;
266
- this.backoffMs = 50;
267
- this.emptySince = 0;
268
- if (!this.pollTimer && this.processors.size > 0)
269
- this.startPolling();
303
+ this.jobReady();
270
304
  }
271
305
  isPaused() {
272
306
  return this.paused;
@@ -288,7 +322,8 @@ export class InMemoryQueue {
288
322
  // Stop accepting and dispatching work before draining, so the set of
289
323
  // in-flight jobs cannot grow while we wait for it.
290
324
  this.disposed = true;
291
- this.stopPolling();
325
+ this.poller.stop();
326
+ this.readyListeners.clear();
292
327
  for (const timer of this.scheduledTimers.values())
293
328
  clearTimeout(timer);
294
329
  this.scheduledTimers.clear();
@@ -309,8 +344,6 @@ export class InMemoryQueue {
309
344
  }
310
345
  this.activeCount = 0;
311
346
  this.paused = false;
312
- this.emptySince = 0;
313
- this.backoffMs = 50;
314
347
  }
315
348
  /**
316
349
  * Waits for in-flight jobs to settle, aborting them past the timeout.
@@ -345,7 +378,10 @@ export class InMemoryQueue {
345
378
  * Round-trips a payload through the configured serializer.
346
379
  */
347
380
  encodePayload(jobId, data) {
348
- if (this.options.serializePayloads === false) {
381
+ // A passthrough serializer means "store the payload as given": there is
382
+ // no string form to round-trip through.
383
+ if (this.options.serializePayloads === false ||
384
+ this.serializer.passthrough === true) {
349
385
  return data;
350
386
  }
351
387
  try {
@@ -356,62 +392,36 @@ export class InMemoryQueue {
356
392
  }
357
393
  }
358
394
  /**
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.
395
+ * Selects the next job that is due and runnable; see {@link selectNextJob}
396
+ * for the ordering.
364
397
  */
365
398
  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);
399
+ return selectNextJob(this.jobs.values(), Date.now(), predicate);
397
400
  }
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?.();
401
+ /**
402
+ * Whether the poll loop should hold the process open: while the queue is
403
+ * consuming and has work one of its processors can run. Work nothing can
404
+ * consume, a paused queue and `keepAlive: false` never do.
405
+ */
406
+ shouldKeepAlive() {
407
+ if (this.options.keepAlive === false)
408
+ return false;
409
+ if (this.disposed || this.paused || !this.autoProcess)
410
+ return false;
411
+ if (this.activeCount > 0)
412
+ return true;
413
+ return hasPendingWork(this.jobs.values(), (job) => this.processors.has(job.name));
410
414
  }
411
- stopPolling() {
412
- if (this.pollTimer) {
413
- clearTimeout(this.pollTimer);
414
- this.pollTimer = null;
415
+ /** Wakes the poll loop and tells consumers a job may be runnable. */
416
+ jobReady() {
417
+ this.poller.wake();
418
+ for (const listener of [...this.readyListeners]) {
419
+ try {
420
+ listener();
421
+ }
422
+ catch {
423
+ // A consumer's wake-up hook must not break the producer.
424
+ }
415
425
  }
416
426
  }
417
427
  /**
@@ -476,6 +486,7 @@ export class InMemoryQueue {
476
486
  this.retryTimers.delete(jobId);
477
487
  },
478
488
  onSettled: (settled) => this.recordSettled(settled),
489
+ onJobReady: () => this.jobReady(),
479
490
  isDisposed: () => this.disposed,
480
491
  ...(this.options.logger ? { logger: this.options.logger } : {}),
481
492
  });
@@ -489,15 +500,26 @@ export class InMemoryQueue {
489
500
  finally {
490
501
  this.activeCount--;
491
502
  this.inFlight.delete(job.id);
503
+ // A slot is free: claim the next job now rather than on the next poll.
504
+ this.poller.wake();
492
505
  if (forwardAbort && consumerSignal) {
493
506
  consumerSignal.removeEventListener("abort", forwardAbort);
494
507
  }
495
508
  }
496
509
  }
510
+ /**
511
+ * One poll: promotes due delayed jobs, claims runnable jobs up to the
512
+ * concurrency limit, and reclaims stalled ones.
513
+ *
514
+ * @returns Whether any job was dispatched.
515
+ */
497
516
  async processTick() {
498
517
  if (this.paused || this.disposed)
499
- return;
518
+ return false;
500
519
  const concurrency = Math.max(1, this.options.concurrency ?? 1);
520
+ // Promote first, so a job whose delay just elapsed is claimable in this
521
+ // same tick.
522
+ promoteDueScheduledJobs(this.jobs, this.scheduledTimers);
501
523
  let processed = 0;
502
524
  while (this.autoProcess && this.activeCount < concurrency) {
503
525
  // `claimNextJob` only returns jobs that have a registered
@@ -513,22 +535,8 @@ export class InMemoryQueue {
513
535
  // sees the dispatch immediately.
514
536
  void this.runJob(job);
515
537
  }
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
538
  this.reclaimStalledJobs();
539
+ return processed > 0;
532
540
  }
533
541
  /**
534
542
  * Returns jobs stuck in `active` to the waiting pool.
@@ -581,6 +589,7 @@ export class InMemoryQueue {
581
589
  }
582
590
  this.jobs.set(job.id, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
583
591
  this.emitter.emit("job:failed", { job, error });
592
+ this.jobReady();
584
593
  }
585
594
  }
586
595
  /**
@@ -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,16 +32,46 @@ 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
- /** Store that receives jobs which exhausted their attempts. */
44
- readonly deadLetterStore?: DeadLetterStore<never>;
66
+ /**
67
+ * Store that receives jobs which exhausted their attempts.
68
+ *
69
+ * Any store is accepted without an annotation: the untyped
70
+ * `createInMemoryDeadLetterStore()` and one typed for the queue's payload,
71
+ * `createInMemoryDeadLetterStore<TData>()`. (1.4.0 typed this
72
+ * `DeadLetterStore<never>`, which rejected both.)
73
+ */
74
+ readonly deadLetterStore?: DeadLetterStore<unknown>;
45
75
  /**
46
76
  * Whether `add()` rejects while the queue is paused.
47
77
  *
@@ -209,6 +239,15 @@ export interface Queue<TData = unknown> {
209
239
  * turns it off so a worker is never racing the queue for jobs.
210
240
  */
211
241
  setAutoProcess?(enabled: boolean): void;
242
+ /**
243
+ * Subscribes to "a job may have become runnable" (added, released,
244
+ * reclaimed, a delay or retry backoff elapsed, the queue resumed). A
245
+ * `Worker` uses it to claim at once instead of on its next poll; a queue
246
+ * without it is simply polled.
247
+ *
248
+ * @returns A function that unsubscribes.
249
+ */
250
+ onJobReady?(listener: () => void): () => void;
212
251
  }
213
252
  /**
214
253
  * 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.1",
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.1"
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
  },