@zudojs/queue 1.2.0 → 1.3.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 +6 -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 +13 -0
- package/dist/inMemoryQueue/inMemoryQueue.core.js +49 -10
- package/dist/inMemoryQueue/inMemoryQueue.processing.js +8 -0
- package/dist/queue/queue.type.d.ts +9 -0
- package/dist/queueEmitter/queueEmitter.core.d.ts +20 -1
- package/dist/queueEmitter/queueEmitter.core.js +20 -1
- package/dist/worker/worker.core.js +25 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -69,8 +69,8 @@ await queue.close();
|
|
|
69
69
|
- Draining shutdown with a bounded `closeTimeout`, then cooperative abort
|
|
70
70
|
- Stalled-job reclaim (`stalledAfter`, `maxStalledCount`) for consumers that die
|
|
71
71
|
mid-job
|
|
72
|
-
- Bounded retention of settled jobs, so a long-lived
|
|
73
|
-
limit
|
|
72
|
+
- Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
|
|
73
|
+
queue does not grow without limit
|
|
74
74
|
|
|
75
75
|
## Workers, timeouts and context
|
|
76
76
|
|
|
@@ -120,6 +120,10 @@ Keep captured values small and serializable (ids, not live objects). With
|
|
|
120
120
|
`@zudojs/tenancy`, capture the tenant id and restore it with the tenant context
|
|
121
121
|
storage's `run`.
|
|
122
122
|
|
|
123
|
+
The `zudo:context` metadata key is reserved: it is written only by the queue's
|
|
124
|
+
own carriers and is stripped from any `metadata` passed to `add()`, so an
|
|
125
|
+
enqueuer cannot choose the context its job runs under.
|
|
126
|
+
|
|
123
127
|
Errors with no caller to receive them (a worker's failing poll, a throwing event
|
|
124
128
|
listener) go to `logger.error` when a logger is configured, and otherwise to
|
|
125
129
|
`process.emitWarning`, never to `console`.
|
|
@@ -13,10 +13,15 @@ export declare const CONTEXT_METADATA_KEY = "zudo:context";
|
|
|
13
13
|
/**
|
|
14
14
|
* Captures every carrier's value into a metadata record.
|
|
15
15
|
*
|
|
16
|
+
* {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
|
|
17
|
+
* under that key is always discarded, whether or not a carrier captured
|
|
18
|
+
* anything, so an enqueuer cannot forge the tenant, correlation id or trace
|
|
19
|
+
* the processor runs under.
|
|
20
|
+
*
|
|
16
21
|
* @param carriers - The queue's context carriers.
|
|
17
22
|
* @param metadata - The job's own metadata, if any.
|
|
18
23
|
* @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
|
|
19
|
-
*
|
|
24
|
+
* without one when no carrier captured anything.
|
|
20
25
|
*/
|
|
21
26
|
export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
|
|
22
27
|
/**
|
|
@@ -8,17 +8,37 @@
|
|
|
8
8
|
* `{ [carrier.key]: value }` record.
|
|
9
9
|
*/
|
|
10
10
|
export const CONTEXT_METADATA_KEY = "zudo:context";
|
|
11
|
+
/**
|
|
12
|
+
* Strips the reserved context key from caller-supplied metadata.
|
|
13
|
+
*
|
|
14
|
+
* The key is owned by the queue on every path: whatever the enqueuer put
|
|
15
|
+
* there is dropped, so `add()` metadata can never supply a context record of
|
|
16
|
+
* its own. Returns the input untouched when there is nothing to strip, so the
|
|
17
|
+
* common case still allocates nothing.
|
|
18
|
+
*/
|
|
19
|
+
function withoutStoredContext(metadata) {
|
|
20
|
+
if (!metadata || !Object.hasOwn(metadata, CONTEXT_METADATA_KEY)) {
|
|
21
|
+
return metadata;
|
|
22
|
+
}
|
|
23
|
+
const { [CONTEXT_METADATA_KEY]: _discarded, ...rest } = metadata;
|
|
24
|
+
return rest;
|
|
25
|
+
}
|
|
11
26
|
/**
|
|
12
27
|
* Captures every carrier's value into a metadata record.
|
|
13
28
|
*
|
|
29
|
+
* {@link CONTEXT_METADATA_KEY} is owned by the queue: a value the caller put
|
|
30
|
+
* under that key is always discarded, whether or not a carrier captured
|
|
31
|
+
* anything, so an enqueuer cannot forge the tenant, correlation id or trace
|
|
32
|
+
* the processor runs under.
|
|
33
|
+
*
|
|
14
34
|
* @param carriers - The queue's context carriers.
|
|
15
35
|
* @param metadata - The job's own metadata, if any.
|
|
16
36
|
* @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
|
|
17
|
-
*
|
|
37
|
+
* without one when no carrier captured anything.
|
|
18
38
|
*/
|
|
19
39
|
export function captureContext(carriers, metadata) {
|
|
20
40
|
if (!carriers || carriers.length === 0)
|
|
21
|
-
return metadata;
|
|
41
|
+
return withoutStoredContext(metadata);
|
|
22
42
|
const captured = {};
|
|
23
43
|
let any = false;
|
|
24
44
|
for (const carrier of carriers) {
|
|
@@ -29,7 +49,7 @@ export function captureContext(carriers, metadata) {
|
|
|
29
49
|
any = true;
|
|
30
50
|
}
|
|
31
51
|
if (!any)
|
|
32
|
-
return metadata;
|
|
52
|
+
return withoutStoredContext(metadata);
|
|
33
53
|
return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
|
|
34
54
|
}
|
|
35
55
|
/**
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import type { Job } from "../job/job.type.js";
|
|
2
2
|
import type { DeadLetterStore } from "./deadLetter.type.js";
|
|
3
|
+
/**
|
|
4
|
+
* Dead-lettered jobs retained by the in-memory store when no cap is given.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
|
|
7
|
+
* keeps a full copy of every failed job — payload, metadata and error — so an
|
|
8
|
+
* unbounded one grows for the life of the process at whatever the failure
|
|
9
|
+
* rate happens to be.
|
|
10
|
+
*/
|
|
11
|
+
export declare const DEFAULT_DEAD_LETTER_JOBS = 1000;
|
|
12
|
+
/**
|
|
13
|
+
* Options for {@link createInMemoryDeadLetterStore}.
|
|
14
|
+
*/
|
|
15
|
+
export interface InMemoryDeadLetterStoreOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Maximum number of dead-lettered jobs retained before the oldest are
|
|
18
|
+
* evicted. Defaults to {@link DEFAULT_DEAD_LETTER_JOBS}. Pass
|
|
19
|
+
* `Number.POSITIVE_INFINITY` for the previous unbounded behaviour.
|
|
20
|
+
*/
|
|
21
|
+
readonly maxEntries?: number;
|
|
22
|
+
}
|
|
3
23
|
/**
|
|
4
24
|
* Creates an in-memory dead letter store.
|
|
25
|
+
*
|
|
26
|
+
* Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
|
|
27
|
+
* long-lived queue with a steady failure rate does not grow without limit.
|
|
28
|
+
*
|
|
29
|
+
* @param options - Retention options.
|
|
5
30
|
*/
|
|
6
|
-
export declare function createInMemoryDeadLetterStore<TData = unknown>(): DeadLetterStore<TData>;
|
|
31
|
+
export declare function createInMemoryDeadLetterStore<TData = unknown>(options?: InMemoryDeadLetterStoreOptions): DeadLetterStore<TData>;
|
|
7
32
|
/**
|
|
8
33
|
* Moves a failed job to the dead letter store.
|
|
9
34
|
*/
|
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
import { toQueueError } from "@zudojs/errors";
|
|
2
|
+
/**
|
|
3
|
+
* Dead-lettered jobs retained by the in-memory store when no cap is given.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors the queue's own `DEFAULT_RETAINED_JOBS`: the dead letter store
|
|
6
|
+
* keeps a full copy of every failed job — payload, metadata and error — so an
|
|
7
|
+
* unbounded one grows for the life of the process at whatever the failure
|
|
8
|
+
* rate happens to be.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_DEAD_LETTER_JOBS = 1_000;
|
|
2
11
|
/**
|
|
3
12
|
* Creates an in-memory dead letter store.
|
|
13
|
+
*
|
|
14
|
+
* Retention is bounded: past `maxEntries` the oldest entry is evicted, so a
|
|
15
|
+
* long-lived queue with a steady failure rate does not grow without limit.
|
|
16
|
+
*
|
|
17
|
+
* @param options - Retention options.
|
|
4
18
|
*/
|
|
5
|
-
export function createInMemoryDeadLetterStore() {
|
|
19
|
+
export function createInMemoryDeadLetterStore(options) {
|
|
6
20
|
const store = new Map();
|
|
21
|
+
const configured = options?.maxEntries ?? DEFAULT_DEAD_LETTER_JOBS;
|
|
22
|
+
const maxEntries = configured > 0 ? configured : DEFAULT_DEAD_LETTER_JOBS;
|
|
7
23
|
return {
|
|
8
24
|
async add(deadLetterJob) {
|
|
25
|
+
// Re-inserting an existing id must not count as a new entry, so
|
|
26
|
+
// delete first: a Map keeps its original insertion order otherwise,
|
|
27
|
+
// which would also evict the wrong entry.
|
|
28
|
+
store.delete(deadLetterJob.job.id);
|
|
9
29
|
store.set(deadLetterJob.job.id, deadLetterJob);
|
|
30
|
+
while (store.size > maxEntries) {
|
|
31
|
+
const oldest = store.keys().next().value;
|
|
32
|
+
if (oldest === undefined)
|
|
33
|
+
break;
|
|
34
|
+
store.delete(oldest);
|
|
35
|
+
}
|
|
10
36
|
},
|
|
11
37
|
async get(jobId) {
|
|
12
38
|
return store.get(jobId) ?? null;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Provides storage and management for jobs that have
|
|
5
5
|
* exceeded their retry attempts.
|
|
6
6
|
*/
|
|
7
|
-
export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
|
|
7
|
+
export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
|
|
8
|
+
export type { InMemoryDeadLetterStoreOptions } from "./deadLetter.core.js";
|
|
8
9
|
export type { DeadLetterJob, DeadLetterStore } from "./deadLetter.type.js";
|
|
9
10
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/deadLetter/index.js
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* Provides storage and management for jobs that have
|
|
5
5
|
* exceeded their retry attempts.
|
|
6
6
|
*/
|
|
7
|
-
export { createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
|
|
7
|
+
export { DEFAULT_DEAD_LETTER_JOBS, createInMemoryDeadLetterStore, moveToDeadLetter, } from "./deadLetter.core.js";
|
|
8
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -4,6 +4,7 @@ import type { Queue, QueueOptions, QueueStats } from "../queue/queue.type.js";
|
|
|
4
4
|
import type { Processor } from "../processor/processor.type.js";
|
|
5
5
|
import type { JobOptions } from "../jobOptions/jobOptions.type.js";
|
|
6
6
|
import type { QueueMiddleware } from "../middleware/middleware.type.js";
|
|
7
|
+
import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
|
|
7
8
|
import type { DeadLetterJob } from "../deadLetter/deadLetter.type.js";
|
|
8
9
|
/**
|
|
9
10
|
* In-memory queue implementation.
|
|
@@ -32,6 +33,11 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
|
|
|
32
33
|
private readonly stalledCounts;
|
|
33
34
|
private readonly deduplicationIndex;
|
|
34
35
|
private readonly deadLetterStore;
|
|
36
|
+
/**
|
|
37
|
+
* Whether this queue created its own dead letter store. A store handed in
|
|
38
|
+
* by the caller outlives the queue and is theirs to clear.
|
|
39
|
+
*/
|
|
40
|
+
private readonly ownsDeadLetterStore;
|
|
35
41
|
private readonly emitter;
|
|
36
42
|
private readonly counters;
|
|
37
43
|
private emptySince;
|
|
@@ -46,6 +52,13 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
|
|
|
46
52
|
* concurrency apply to every job.
|
|
47
53
|
*/
|
|
48
54
|
setAutoProcess(enabled: boolean): void;
|
|
55
|
+
/**
|
|
56
|
+
* The emitter this queue publishes lifecycle events on.
|
|
57
|
+
*
|
|
58
|
+
* A no-op emitter when the queue was created without one, so a worker can
|
|
59
|
+
* report its lifecycle unconditionally.
|
|
60
|
+
*/
|
|
61
|
+
get events(): QueueEventEmitter;
|
|
49
62
|
add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
|
|
50
63
|
process(name: string, processor: Processor<TData>): void;
|
|
51
64
|
getJob(jobId: JobId): Promise<Job<TData> | null>;
|
|
@@ -4,8 +4,8 @@ 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, createNoopQueueEventEmitter, } 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";
|
|
@@ -47,6 +47,11 @@ export class InMemoryQueue {
|
|
|
47
47
|
stalledCounts = new Map();
|
|
48
48
|
deduplicationIndex = new Map();
|
|
49
49
|
deadLetterStore;
|
|
50
|
+
/**
|
|
51
|
+
* Whether this queue created its own dead letter store. A store handed in
|
|
52
|
+
* by the caller outlives the queue and is theirs to clear.
|
|
53
|
+
*/
|
|
54
|
+
ownsDeadLetterStore;
|
|
50
55
|
emitter;
|
|
51
56
|
counters = {
|
|
52
57
|
processedCount: 0,
|
|
@@ -63,8 +68,19 @@ export class InMemoryQueue {
|
|
|
63
68
|
this.serializer = this.options.serializer ?? JsonSerializer;
|
|
64
69
|
this.middleware = this.options.middleware ?? [];
|
|
65
70
|
this.emitter = options?.eventEmitter ?? createNoopQueueEventEmitter();
|
|
71
|
+
// A supplied emitter is built before the queue exists, so it cannot have
|
|
72
|
+
// been given the queue's logger. Hand it over, so a throwing listener is
|
|
73
|
+
// reported through structured logging rather than `process.emitWarning`.
|
|
74
|
+
if (this.options.logger &&
|
|
75
|
+
this.emitter instanceof InMemoryQueueEventEmitter) {
|
|
76
|
+
this.emitter.setLogger(this.options.logger);
|
|
77
|
+
}
|
|
78
|
+
this.ownsDeadLetterStore = this.options.deadLetterStore === undefined;
|
|
66
79
|
this.deadLetterStore =
|
|
67
|
-
this.options.deadLetterStore ??
|
|
80
|
+
this.options.deadLetterStore ??
|
|
81
|
+
createInMemoryDeadLetterStore({
|
|
82
|
+
maxEntries: DEFAULT_DEAD_LETTER_JOBS,
|
|
83
|
+
});
|
|
68
84
|
this.autoProcess = this.options.autoProcess ?? true;
|
|
69
85
|
}
|
|
70
86
|
/**
|
|
@@ -78,6 +94,15 @@ export class InMemoryQueue {
|
|
|
78
94
|
setAutoProcess(enabled) {
|
|
79
95
|
this.autoProcess = enabled;
|
|
80
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* The emitter this queue publishes lifecycle events on.
|
|
99
|
+
*
|
|
100
|
+
* A no-op emitter when the queue was created without one, so a worker can
|
|
101
|
+
* report its lifecycle unconditionally.
|
|
102
|
+
*/
|
|
103
|
+
get events() {
|
|
104
|
+
return this.emitter;
|
|
105
|
+
}
|
|
81
106
|
async add(jobName, data, options) {
|
|
82
107
|
if (this.disposed)
|
|
83
108
|
throw new QueueDisposedError(this.name);
|
|
@@ -277,6 +302,11 @@ export class InMemoryQueue {
|
|
|
277
302
|
this.settledOrder.length = 0;
|
|
278
303
|
this.stalledCounts.clear();
|
|
279
304
|
this.inFlight.clear();
|
|
305
|
+
// A dead letter store the queue created dies with it; one handed in by
|
|
306
|
+
// the caller is theirs and is left alone.
|
|
307
|
+
if (this.ownsDeadLetterStore) {
|
|
308
|
+
await this.deadLetterStore.clear();
|
|
309
|
+
}
|
|
280
310
|
this.activeCount = 0;
|
|
281
311
|
this.paused = false;
|
|
282
312
|
this.emptySince = 0;
|
|
@@ -400,17 +430,23 @@ export class InMemoryQueue {
|
|
|
400
430
|
}
|
|
401
431
|
const abortController = new AbortController();
|
|
402
432
|
// A consumer's own signal (a worker draining, say) must reach the
|
|
403
|
-
// job it dispatched.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
433
|
+
// job it dispatched. The forwarder is held so it can be removed once
|
|
434
|
+
// the job settles: a worker uses one long-lived signal for every job
|
|
435
|
+
// it dispatches, so a listener left behind accumulates for the life
|
|
436
|
+
// of the worker and pins that job's controller with it.
|
|
437
|
+
const consumerSignal = options?.signal;
|
|
438
|
+
let forwardAbort;
|
|
439
|
+
if (consumerSignal) {
|
|
440
|
+
if (consumerSignal.aborted) {
|
|
441
|
+
abortController.abort(consumerSignal.reason);
|
|
407
442
|
}
|
|
408
443
|
else {
|
|
409
|
-
|
|
444
|
+
forwardAbort = () => {
|
|
410
445
|
if (!abortController.signal.aborted) {
|
|
411
|
-
abortController.abort(
|
|
446
|
+
abortController.abort(consumerSignal.reason);
|
|
412
447
|
}
|
|
413
|
-
}
|
|
448
|
+
};
|
|
449
|
+
consumerSignal.addEventListener("abort", forwardAbort, { once: true });
|
|
414
450
|
}
|
|
415
451
|
}
|
|
416
452
|
this.inFlight.set(job.id, abortController);
|
|
@@ -453,6 +489,9 @@ export class InMemoryQueue {
|
|
|
453
489
|
finally {
|
|
454
490
|
this.activeCount--;
|
|
455
491
|
this.inFlight.delete(job.id);
|
|
492
|
+
if (forwardAbort && consumerSignal) {
|
|
493
|
+
consumerSignal.removeEventListener("abort", forwardAbort);
|
|
494
|
+
}
|
|
456
495
|
}
|
|
457
496
|
}
|
|
458
497
|
async processTick() {
|
|
@@ -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 {
|
|
@@ -138,6 +138,14 @@ export interface QueueStats {
|
|
|
138
138
|
export interface Queue<TData = unknown> {
|
|
139
139
|
/** Queue name. */
|
|
140
140
|
readonly name: QueueName;
|
|
141
|
+
/**
|
|
142
|
+
* The emitter this queue publishes lifecycle events on, when it has one.
|
|
143
|
+
*
|
|
144
|
+
* Exposed so a `Worker` can report its own lifecycle
|
|
145
|
+
* (`worker:started`, `worker:stopped`, `worker:error`) on the same
|
|
146
|
+
* emitter as the jobs it runs.
|
|
147
|
+
*/
|
|
148
|
+
readonly events?: QueueEventEmitter;
|
|
141
149
|
/** Add a job to the queue. */
|
|
142
150
|
add(name: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
|
|
143
151
|
/** Process jobs with a processor. */
|
|
@@ -228,6 +236,7 @@ export type QueueEventMap = {
|
|
|
228
236
|
job: Job;
|
|
229
237
|
attempt: number;
|
|
230
238
|
};
|
|
239
|
+
/** A running job was aborted from outside — a drain, a close, a cancel. */
|
|
231
240
|
"job:cancelled": {
|
|
232
241
|
job: Job;
|
|
233
242
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QueueEventEmitter } from "./queueEmitter.type.js";
|
|
2
|
-
import type { QueueEventMap } from "../queue/queue.type.js";
|
|
2
|
+
import type { QueueEventMap, QueueLogger } from "../queue/queue.type.js";
|
|
3
3
|
type EventName = keyof QueueEventMap;
|
|
4
4
|
type Handler<T extends EventName> = (data: QueueEventMap[T]) => void;
|
|
5
5
|
/**
|
|
@@ -12,6 +12,12 @@ export interface QueueEventEmitterOptions {
|
|
|
12
12
|
* the emitting code path.
|
|
13
13
|
*/
|
|
14
14
|
readonly onHandlerError?: (error: unknown, event: EventName) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Receives a throwing listener's error when no `onHandlerError` is given.
|
|
17
|
+
* Without one the failure goes to `process.emitWarning`, bypassing
|
|
18
|
+
* structured logging and redaction.
|
|
19
|
+
*/
|
|
20
|
+
readonly logger?: QueueLogger;
|
|
15
21
|
}
|
|
16
22
|
/**
|
|
17
23
|
* In-memory queue event emitter.
|
|
@@ -24,7 +30,20 @@ export interface QueueEventEmitterOptions {
|
|
|
24
30
|
export declare class InMemoryQueueEventEmitter implements QueueEventEmitter {
|
|
25
31
|
private readonly handlers;
|
|
26
32
|
private readonly onHandlerError;
|
|
33
|
+
/** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
|
|
34
|
+
private readonly hasCustomHandlerError;
|
|
35
|
+
private logger;
|
|
27
36
|
constructor(options?: QueueEventEmitterOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Adopts a logger for the default handler-error report.
|
|
39
|
+
*
|
|
40
|
+
* Called by a queue that was configured with a logger, since the emitter is
|
|
41
|
+
* built before the queue exists and cannot have been given it. A logger or
|
|
42
|
+
* an `onHandlerError` supplied at construction always wins.
|
|
43
|
+
*
|
|
44
|
+
* @param logger - Destination for a throwing listener's error.
|
|
45
|
+
*/
|
|
46
|
+
setLogger(logger: QueueLogger): void;
|
|
28
47
|
emit<K extends EventName>(event: K, data: QueueEventMap[K]): void;
|
|
29
48
|
on<K extends EventName>(event: K, handler: Handler<K>): () => void;
|
|
30
49
|
/**
|
|
@@ -10,15 +10,34 @@ import { reportQueueError } from "../queue/queue.report.js";
|
|
|
10
10
|
export class InMemoryQueueEventEmitter {
|
|
11
11
|
handlers = new Map();
|
|
12
12
|
onHandlerError;
|
|
13
|
+
/** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
|
|
14
|
+
hasCustomHandlerError;
|
|
15
|
+
logger;
|
|
13
16
|
constructor(options = {}) {
|
|
17
|
+
this.hasCustomHandlerError = options.onHandlerError !== undefined;
|
|
18
|
+
this.logger = options.logger;
|
|
14
19
|
this.onHandlerError =
|
|
15
20
|
options.onHandlerError ??
|
|
16
21
|
((error, event) => {
|
|
17
22
|
queueMicrotask(() => {
|
|
18
|
-
reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error);
|
|
23
|
+
reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error, this.logger);
|
|
19
24
|
});
|
|
20
25
|
});
|
|
21
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Adopts a logger for the default handler-error report.
|
|
29
|
+
*
|
|
30
|
+
* Called by a queue that was configured with a logger, since the emitter is
|
|
31
|
+
* built before the queue exists and cannot have been given it. A logger or
|
|
32
|
+
* an `onHandlerError` supplied at construction always wins.
|
|
33
|
+
*
|
|
34
|
+
* @param logger - Destination for a throwing listener's error.
|
|
35
|
+
*/
|
|
36
|
+
setLogger(logger) {
|
|
37
|
+
if (this.hasCustomHandlerError || this.logger)
|
|
38
|
+
return;
|
|
39
|
+
this.logger = logger;
|
|
40
|
+
}
|
|
22
41
|
emit(event, data) {
|
|
23
42
|
const handlers = this.handlers.get(event);
|
|
24
43
|
if (!handlers) {
|
|
@@ -26,12 +26,29 @@ export function createWorker(id, queue, options) {
|
|
|
26
26
|
let polling = false;
|
|
27
27
|
let abortController = null;
|
|
28
28
|
queue.setAutoProcess?.(false);
|
|
29
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Reports the worker's own lifecycle on the queue's emitter.
|
|
31
|
+
*
|
|
32
|
+
* `worker:started`, `worker:stopped` and `worker:error` were part of
|
|
33
|
+
* `QueueEventMap` with nothing emitting them, so a consumer subscribing for
|
|
34
|
+
* readiness never heard from the worker.
|
|
35
|
+
*/
|
|
36
|
+
const emitLifecycle = (event) => {
|
|
37
|
+
queue.events?.emit(event, { workerId: id });
|
|
38
|
+
};
|
|
39
|
+
const reportWorkerError = options?.onError ??
|
|
30
40
|
((error) => {
|
|
31
41
|
queueMicrotask(() => {
|
|
32
42
|
reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
|
|
33
43
|
});
|
|
34
44
|
});
|
|
45
|
+
const onError = (error) => {
|
|
46
|
+
queue.events?.emit("worker:error", {
|
|
47
|
+
workerId: id,
|
|
48
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
49
|
+
});
|
|
50
|
+
reportWorkerError(error);
|
|
51
|
+
};
|
|
35
52
|
/**
|
|
36
53
|
* Arms the next poll. At most one timer is ever armed: a delayed poll
|
|
37
54
|
* already pending is left alone, while an immediate poll (capacity just
|
|
@@ -157,6 +174,7 @@ export function createWorker(id, queue, options) {
|
|
|
157
174
|
abortController = new AbortController();
|
|
158
175
|
try {
|
|
159
176
|
state = WorkerState.RUNNING;
|
|
177
|
+
emitLifecycle("worker:started");
|
|
160
178
|
scheduleNextPoll(0);
|
|
161
179
|
}
|
|
162
180
|
catch (error) {
|
|
@@ -193,11 +211,17 @@ export function createWorker(id, queue, options) {
|
|
|
193
211
|
}
|
|
194
212
|
clearPollTimer();
|
|
195
213
|
state = WorkerState.STOPPED;
|
|
214
|
+
emitLifecycle("worker:stopped");
|
|
196
215
|
},
|
|
197
216
|
async forceStop() {
|
|
217
|
+
const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
|
|
198
218
|
abortController?.abort();
|
|
199
219
|
clearPollTimer();
|
|
200
220
|
state = WorkerState.STOPPED;
|
|
221
|
+
// A worker that never started never stopped: reporting it would give a
|
|
222
|
+
// readiness listener a transition that did not happen.
|
|
223
|
+
if (wasLive)
|
|
224
|
+
emitLifecycle("worker:stopped");
|
|
201
225
|
},
|
|
202
226
|
isRunning() {
|
|
203
227
|
return state === WorkerState.RUNNING;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/queue",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"!dist/.tsbuildinfo"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@zudojs/errors": "1.
|
|
29
|
-
"@zudojs/constants": "1.1.
|
|
30
|
-
"@zudojs/serialization": "1.1.
|
|
28
|
+
"@zudojs/errors": "1.2.0",
|
|
29
|
+
"@zudojs/constants": "1.1.1",
|
|
30
|
+
"@zudojs/serialization": "1.1.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"typescript": "7.0.2",
|