@zudojs/queue 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +70 -2
  2. package/dist/contextCarrier/contextCarrier.core.d.ts +6 -1
  3. package/dist/contextCarrier/contextCarrier.core.js +23 -3
  4. package/dist/deadLetter/deadLetter.core.d.ts +26 -1
  5. package/dist/deadLetter/deadLetter.core.js +27 -1
  6. package/dist/deadLetter/index.d.ts +2 -1
  7. package/dist/deadLetter/index.js +1 -1
  8. package/dist/inMemoryQueue/inMemoryQueue.core.d.ts +42 -12
  9. package/dist/inMemoryQueue/inMemoryQueue.core.js +140 -95
  10. package/dist/inMemoryQueue/inMemoryQueue.processing.d.ts +2 -0
  11. package/dist/inMemoryQueue/inMemoryQueue.processing.js +11 -2
  12. package/dist/inMemoryQueue/inMemoryQueue.scheduling.d.ts +6 -3
  13. package/dist/inMemoryQueue/inMemoryQueue.scheduling.js +8 -3
  14. package/dist/inMemoryQueue/polling/inMemoryQueue.poller.d.ts +64 -0
  15. package/dist/inMemoryQueue/polling/inMemoryQueue.poller.js +132 -0
  16. package/dist/inMemoryQueue/polling/inMemoryQueue.select.d.ts +26 -0
  17. package/dist/inMemoryQueue/polling/inMemoryQueue.select.js +64 -0
  18. package/dist/inMemoryQueue/polling/index.d.ts +10 -0
  19. package/dist/inMemoryQueue/polling/index.js +10 -0
  20. package/dist/job/job.type.d.ts +5 -1
  21. package/dist/jobContext/jobContext.core.js +1 -0
  22. package/dist/jobContext/jobContext.type.d.ts +7 -0
  23. package/dist/queue/queue.type.d.ts +44 -3
  24. package/dist/queueEmitter/queueEmitter.core.d.ts +20 -1
  25. package/dist/queueEmitter/queueEmitter.core.js +20 -1
  26. package/dist/serializer/serializer.core.d.ts +27 -4
  27. package/dist/serializer/serializer.core.js +31 -12
  28. package/dist/serializer/serializer.type.d.ts +6 -0
  29. package/dist/worker/worker.core.js +44 -2
  30. package/dist/worker/worker.type.d.ts +11 -1
  31. package/package.json +6 -6
@@ -4,11 +4,12 @@ import { assertProcessor } from "../processor/processor.type.js";
4
4
  import { createJob, updateJobState } from "../job/job.core.js";
5
5
  import { JobState as JobStateEnum, createJobName, } from "../jobTypes/jobTypes.type.js";
6
6
  import { JsonSerializer } from "../serializer/serializer.core.js";
7
- import { createInMemoryDeadLetterStore } from "../deadLetter/deadLetter.core.js";
8
- import { createNoopQueueEventEmitter } from "../queueEmitter/queueEmitter.core.js";
7
+ import { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, } from "../deadLetter/deadLetter.core.js";
8
+ import { InMemoryQueueEventEmitter } 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();
@@ -47,6 +50,11 @@ export class InMemoryQueue {
47
50
  stalledCounts = new Map();
48
51
  deduplicationIndex = new Map();
49
52
  deadLetterStore;
53
+ /**
54
+ * Whether this queue created its own dead letter store. A store handed in
55
+ * by the caller outlives the queue and is theirs to clear.
56
+ */
57
+ ownsDeadLetterStore;
50
58
  emitter;
51
59
  counters = {
52
60
  processedCount: 0,
@@ -55,17 +63,52 @@ export class InMemoryQueue {
55
63
  retriedCount: 0,
56
64
  deadLetteredCount: 0,
57
65
  };
58
- emptySince = 0;
59
- backoffMs = 50;
60
66
  constructor(name, options) {
61
67
  this.name = name;
62
68
  this.options = options ?? {};
63
69
  this.serializer = this.options.serializer ?? JsonSerializer;
64
70
  this.middleware = this.options.middleware ?? [];
65
- 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 } : {});
76
+ // A supplied emitter is built before the queue exists, so it cannot have
77
+ // been given the queue's logger. Hand it over, so a throwing listener is
78
+ // reported through structured logging rather than `process.emitWarning`.
79
+ if (this.options.logger &&
80
+ this.emitter instanceof InMemoryQueueEventEmitter) {
81
+ this.emitter.setLogger(this.options.logger);
82
+ }
83
+ this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
66
84
  this.deadLetterStore =
67
- this.options.deadLetterStore ?? createInMemoryDeadLetterStore();
85
+ this.options.deadLetterStore ??
86
+ createInMemoryDeadLetterStore({
87
+ maxEntries: DEFAULT_DEAD_LETTER_JOBS,
88
+ });
68
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
+ };
69
112
  }
70
113
  /**
71
114
  * Turns the internal poller on or off as a consumer.
@@ -77,6 +120,18 @@ export class InMemoryQueue {
77
120
  */
78
121
  setAutoProcess(enabled) {
79
122
  this.autoProcess = enabled;
123
+ if (enabled)
124
+ this.poller.wake();
125
+ }
126
+ /**
127
+ * The emitter this queue publishes lifecycle events on.
128
+ *
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.
132
+ */
133
+ get events() {
134
+ return this.emitter;
80
135
  }
81
136
  async add(jobName, data, options) {
82
137
  if (this.disposed)
@@ -118,10 +173,13 @@ export class InMemoryQueue {
118
173
  this.deduplicationIndex.set(mergedOptions.deduplicationKey, jobId);
119
174
  }
120
175
  if (job.state === JobStateEnum.SCHEDULED && job.scheduledAt) {
121
- scheduleJob(job, this.scheduledTimers, this.jobs);
176
+ scheduleJob(job, this.scheduledTimers, this.jobs, () => this.jobReady());
122
177
  }
123
- this.backoffMs = 50;
124
- 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();
125
183
  return job;
126
184
  }
127
185
  process(name, processor) {
@@ -129,8 +187,8 @@ export class InMemoryQueue {
129
187
  throw new QueueDisposedError(this.name);
130
188
  assertProcessor(processor, name);
131
189
  this.processors.set(name, processor);
132
- if (!this.pollTimer)
133
- this.startPolling();
190
+ // Jobs already waiting under this name are runnable now.
191
+ this.poller.wake();
134
192
  }
135
193
  async getJob(jobId) {
136
194
  return this.jobs.get(jobId) ?? null;
@@ -174,6 +232,7 @@ export class InMemoryQueue {
174
232
  return false;
175
233
  }
176
234
  this.jobs.set(jobId, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
235
+ this.jobReady();
177
236
  return true;
178
237
  }
179
238
  getProcessor(name) {
@@ -238,10 +297,7 @@ export class InMemoryQueue {
238
297
  if (this.disposed)
239
298
  throw new QueueDisposedError(this.name);
240
299
  this.paused = false;
241
- this.backoffMs = 50;
242
- this.emptySince = 0;
243
- if (!this.pollTimer && this.processors.size > 0)
244
- this.startPolling();
300
+ this.jobReady();
245
301
  }
246
302
  isPaused() {
247
303
  return this.paused;
@@ -263,7 +319,8 @@ export class InMemoryQueue {
263
319
  // Stop accepting and dispatching work before draining, so the set of
264
320
  // in-flight jobs cannot grow while we wait for it.
265
321
  this.disposed = true;
266
- this.stopPolling();
322
+ this.poller.stop();
323
+ this.readyListeners.clear();
267
324
  for (const timer of this.scheduledTimers.values())
268
325
  clearTimeout(timer);
269
326
  this.scheduledTimers.clear();
@@ -277,10 +334,13 @@ export class InMemoryQueue {
277
334
  this.settledOrder.length = 0;
278
335
  this.stalledCounts.clear();
279
336
  this.inFlight.clear();
337
+ // A dead letter store the queue created dies with it; one handed in by
338
+ // the caller is theirs and is left alone.
339
+ if (this.ownsDeadLetterStore) {
340
+ await this.deadLetterStore.clear();
341
+ }
280
342
  this.activeCount = 0;
281
343
  this.paused = false;
282
- this.emptySince = 0;
283
- this.backoffMs = 50;
284
344
  }
285
345
  /**
286
346
  * Waits for in-flight jobs to settle, aborting them past the timeout.
@@ -315,7 +375,10 @@ export class InMemoryQueue {
315
375
  * Round-trips a payload through the configured serializer.
316
376
  */
317
377
  encodePayload(jobId, data) {
318
- 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) {
319
382
  return data;
320
383
  }
321
384
  try {
@@ -326,62 +389,36 @@ export class InMemoryQueue {
326
389
  }
327
390
  }
328
391
  /**
329
- * Selects the highest-priority job that is due and runnable.
330
- *
331
- * Ties on priority are broken by creation time, oldest first. The
332
- * incumbent is tracked by reference rather than by a sentinel priority,
333
- * 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.
334
394
  */
335
395
  selectJob(predicate) {
336
- const now = Date.now();
337
- let nextJob = null;
338
- for (const job of this.jobs.values()) {
339
- if (job.state !== JobStateEnum.WAITING)
340
- continue;
341
- if (job.scheduledAt && new Date(job.scheduledAt).getTime() > now)
342
- continue;
343
- if (predicate && !predicate(job))
344
- continue;
345
- if (nextJob === null) {
346
- nextJob = job;
347
- continue;
348
- }
349
- if (job.priority > nextJob.priority) {
350
- nextJob = job;
351
- continue;
352
- }
353
- if (job.priority === nextJob.priority) {
354
- const candidateTime = new Date(job.createdAt).getTime();
355
- const incumbentTime = new Date(nextJob.createdAt).getTime();
356
- if (candidateTime < incumbentTime)
357
- nextJob = job;
358
- }
359
- }
360
- return nextJob;
361
- }
362
- startPolling() {
363
- this.scheduleTick(this.options.pollInterval ?? 50);
364
- }
365
- scheduleNextTick() {
366
- this.scheduleTick(this.backoffMs);
396
+ return selectNextJob(this.jobs.values(), Date.now(), predicate);
367
397
  }
368
- scheduleTick(interval) {
369
- if (this.disposed)
370
- return;
371
- this.pollTimer = setTimeout(() => {
372
- this.pollTimer = null;
373
- this.processTick().finally(() => {
374
- if (!this.disposed && this.processors.size > 0)
375
- this.scheduleNextTick();
376
- });
377
- }, interval);
378
- // The poll timer must not be the reason a process stays alive.
379
- 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));
380
411
  }
381
- stopPolling() {
382
- if (this.pollTimer) {
383
- clearTimeout(this.pollTimer);
384
- 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
+ }
385
422
  }
386
423
  }
387
424
  /**
@@ -400,17 +437,23 @@ export class InMemoryQueue {
400
437
  }
401
438
  const abortController = new AbortController();
402
439
  // A consumer's own signal (a worker draining, say) must reach the
403
- // job it dispatched.
404
- if (options?.signal) {
405
- if (options.signal.aborted) {
406
- abortController.abort(options.signal.reason);
440
+ // job it dispatched. The forwarder is held so it can be removed once
441
+ // the job settles: a worker uses one long-lived signal for every job
442
+ // it dispatches, so a listener left behind accumulates for the life
443
+ // of the worker and pins that job's controller with it.
444
+ const consumerSignal = options?.signal;
445
+ let forwardAbort;
446
+ if (consumerSignal) {
447
+ if (consumerSignal.aborted) {
448
+ abortController.abort(consumerSignal.reason);
407
449
  }
408
450
  else {
409
- options.signal.addEventListener("abort", () => {
451
+ forwardAbort = () => {
410
452
  if (!abortController.signal.aborted) {
411
- abortController.abort(options.signal?.reason);
453
+ abortController.abort(consumerSignal.reason);
412
454
  }
413
- }, { once: true });
455
+ };
456
+ consumerSignal.addEventListener("abort", forwardAbort, { once: true });
414
457
  }
415
458
  }
416
459
  this.inFlight.set(job.id, abortController);
@@ -440,6 +483,7 @@ export class InMemoryQueue {
440
483
  this.retryTimers.delete(jobId);
441
484
  },
442
485
  onSettled: (settled) => this.recordSettled(settled),
486
+ onJobReady: () => this.jobReady(),
443
487
  isDisposed: () => this.disposed,
444
488
  ...(this.options.logger ? { logger: this.options.logger } : {}),
445
489
  });
@@ -453,12 +497,26 @@ export class InMemoryQueue {
453
497
  finally {
454
498
  this.activeCount--;
455
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();
502
+ if (forwardAbort && consumerSignal) {
503
+ consumerSignal.removeEventListener("abort", forwardAbort);
504
+ }
456
505
  }
457
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
+ */
458
513
  async processTick() {
459
514
  if (this.paused || this.disposed)
460
- return;
515
+ return false;
461
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);
462
520
  let processed = 0;
463
521
  while (this.autoProcess && this.activeCount < concurrency) {
464
522
  // `claimNextJob` only returns jobs that have a registered
@@ -474,22 +532,8 @@ export class InMemoryQueue {
474
532
  // sees the dispatch immediately.
475
533
  void this.runJob(job);
476
534
  }
477
- if (processed > 0) {
478
- this.backoffMs = 50;
479
- this.emptySince = 0;
480
- }
481
- else if (this.emptySince === 0) {
482
- this.emptySince = Date.now();
483
- this.backoffMs = 50;
484
- }
485
- else {
486
- const elapsed = Date.now() - this.emptySince;
487
- if (elapsed > 500) {
488
- this.backoffMs = Math.min(this.backoffMs * 2, 2000);
489
- }
490
- }
491
- promoteDueScheduledJobs(this.jobs, this.scheduledTimers);
492
535
  this.reclaimStalledJobs();
536
+ return processed > 0;
493
537
  }
494
538
  /**
495
539
  * Returns jobs stuck in `active` to the waiting pool.
@@ -542,6 +586,7 @@ export class InMemoryQueue {
542
586
  }
543
587
  this.jobs.set(job.id, updateJobState(job, JobStateEnum.WAITING, { startedAt: undefined }));
544
588
  this.emitter.emit("job:failed", { job, error });
589
+ this.jobReady();
545
590
  }
546
591
  }
547
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
  /**
@@ -48,10 +48,15 @@ export async function processJob(job, processor, options, deps) {
48
48
  },
49
49
  });
50
50
  const timeoutMs = options.timeoutMs ?? DEFAULT_JOB_OPTIONS.timeout ?? 30_000;
51
+ // Whether this job's own timeout is what aborted it. An abort from
52
+ // anywhere else — a draining worker, `close()`, a consumer's signal — is a
53
+ // cancellation, and is reported as one.
54
+ let timedOut = false;
51
55
  const timeoutMiddleware = createTimeoutMiddleware(timeoutMs, () => {
52
56
  // Let a cooperative processor observe the timeout and stop working
53
57
  // instead of running on with its result discarded.
54
58
  if (!abortController.signal.aborted) {
59
+ timedOut = true;
55
60
  abortController.abort(new Error(`Job "${updatedJob.id}" timed out after ${timeoutMs}ms.`));
56
61
  }
57
62
  });
@@ -92,6 +97,9 @@ export async function processJob(job, processor, options, deps) {
92
97
  catch (error) {
93
98
  const errorMessage = error instanceof Error ? error.message : String(error);
94
99
  await settleWithin(running, options.timeoutGraceMs ?? DEFAULT_TIMEOUT_GRACE_MS);
100
+ if (abortController.signal.aborted && !timedOut) {
101
+ emitter.emit("job:cancelled", { job: updatedJob });
102
+ }
95
103
  await handleJobFailure(updatedJob, errorMessage, deps);
96
104
  }
97
105
  finally {
@@ -140,10 +148,11 @@ export async function handleJobFailure(job, errorMessage, deps) {
140
148
  failedAt: undefined,
141
149
  });
142
150
  jobs.set(job.id, waitingJob);
151
+ deps.onJobReady?.();
143
152
  }
144
153
  }, delay);
145
- // `unref` keeps a pending retry from holding the process open; the
146
- // 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.
147
156
  timer.unref?.();
148
157
  deps.registerRetryTimer(job.id, timer);
149
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