@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.
- package/README.md +70 -2
- package/dist/contextCarrier/contextCarrier.core.d.ts +6 -1
- package/dist/contextCarrier/contextCarrier.core.js +23 -3
- package/dist/deadLetter/deadLetter.core.d.ts +26 -1
- package/dist/deadLetter/deadLetter.core.js +27 -1
- package/dist/deadLetter/index.d.ts +2 -1
- package/dist/deadLetter/index.js +1 -1
- package/dist/inMemoryQueue/inMemoryQueue.core.d.ts +42 -12
- package/dist/inMemoryQueue/inMemoryQueue.core.js +140 -95
- package/dist/inMemoryQueue/inMemoryQueue.processing.d.ts +2 -0
- package/dist/inMemoryQueue/inMemoryQueue.processing.js +11 -2
- package/dist/inMemoryQueue/inMemoryQueue.scheduling.d.ts +6 -3
- package/dist/inMemoryQueue/inMemoryQueue.scheduling.js +8 -3
- package/dist/inMemoryQueue/polling/inMemoryQueue.poller.d.ts +64 -0
- package/dist/inMemoryQueue/polling/inMemoryQueue.poller.js +132 -0
- package/dist/inMemoryQueue/polling/inMemoryQueue.select.d.ts +26 -0
- package/dist/inMemoryQueue/polling/inMemoryQueue.select.js +64 -0
- package/dist/inMemoryQueue/polling/index.d.ts +10 -0
- package/dist/inMemoryQueue/polling/index.js +10 -0
- package/dist/job/job.type.d.ts +5 -1
- package/dist/jobContext/jobContext.core.js +1 -0
- package/dist/jobContext/jobContext.type.d.ts +7 -0
- package/dist/queue/queue.type.d.ts +44 -3
- package/dist/queueEmitter/queueEmitter.core.d.ts +20 -1
- package/dist/queueEmitter/queueEmitter.core.js +20 -1
- package/dist/serializer/serializer.core.d.ts +27 -4
- package/dist/serializer/serializer.core.js +31 -12
- package/dist/serializer/serializer.type.d.ts +6 -0
- package/dist/worker/worker.core.js +44 -2
- package/dist/worker/worker.type.d.ts +11 -1
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
451
|
+
forwardAbort = () => {
|
|
410
452
|
if (!abortController.signal.aborted) {
|
|
411
|
-
abortController.abort(
|
|
453
|
+
abortController.abort(consumerSignal.reason);
|
|
412
454
|
}
|
|
413
|
-
}
|
|
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
|
-
//
|
|
146
|
-
// queue clears the timer
|
|
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
|
|
8
|
-
*
|
|
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
|
|
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
|
|
9
|
-
*
|
|
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
|