@zudojs/queue 1.1.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 +64 -5
- package/dist/contextCarrier/contextCarrier.core.d.ts +36 -0
- package/dist/contextCarrier/contextCarrier.core.js +80 -0
- package/dist/contextCarrier/contextCarrier.type.d.ts +41 -0
- package/dist/contextCarrier/contextCarrier.type.js +7 -0
- package/dist/contextCarrier/index.d.ts +11 -0
- package/dist/contextCarrier/index.js +10 -0
- 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 +24 -0
- package/dist/inMemoryQueue/inMemoryQueue.core.js +77 -13
- package/dist/inMemoryQueue/inMemoryQueue.processing.d.ts +7 -0
- package/dist/inMemoryQueue/inMemoryQueue.processing.js +19 -3
- package/dist/inMemoryQueue/inMemoryQueue.settle.d.ts +24 -0
- package/dist/inMemoryQueue/inMemoryQueue.settle.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/queue/queue.report.d.ts +21 -0
- package/dist/queue/queue.report.js +26 -0
- package/dist/queue/queue.type.d.ts +39 -0
- package/dist/queueEmitter/queueEmitter.core.d.ts +20 -1
- package/dist/queueEmitter/queueEmitter.core.js +21 -1
- package/dist/worker/worker.core.d.ts +5 -0
- package/dist/worker/worker.core.js +33 -2
- package/dist/worker/worker.type.d.ts +6 -3
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Background job and asynchronous task infrastructure with in-memory and adapter-based queue implementations.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-queue](https://zudojs.oyinlola.site/docs/packages-queue) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-queue.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -43,9 +49,6 @@ await queue.add(
|
|
|
43
49
|
// The queue polls in the background; give it a tick before reading counts.
|
|
44
50
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
45
51
|
|
|
46
|
-
// The queue polls in the background; give it a tick before reading counts.
|
|
47
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
48
|
-
|
|
49
52
|
// Jobs that exhaust their attempts land here rather than vanishing.
|
|
50
53
|
console.log((await queue.getDeadLetterJobs()).length);
|
|
51
54
|
console.log(await queue.getStats());
|
|
@@ -66,8 +69,64 @@ await queue.close();
|
|
|
66
69
|
- Draining shutdown with a bounded `closeTimeout`, then cooperative abort
|
|
67
70
|
- Stalled-job reclaim (`stalledAfter`, `maxStalledCount`) for consumers that die
|
|
68
71
|
mid-job
|
|
69
|
-
- Bounded retention of settled jobs, so a long-lived
|
|
70
|
-
limit
|
|
72
|
+
- Bounded retention of settled jobs and of dead-lettered jobs, so a long-lived
|
|
73
|
+
queue does not grow without limit
|
|
74
|
+
|
|
75
|
+
## Workers, timeouts and context
|
|
76
|
+
|
|
77
|
+
**One consumer at a time.** `queue.process()` registers a processor and, by
|
|
78
|
+
default, the queue's own poller runs jobs. Creating a `Worker` for the queue
|
|
79
|
+
turns that poller off as a consumer (`queue.setAutoProcess(false)`), so the
|
|
80
|
+
worker's `middleware`, `timeoutMs` and `concurrency` apply to every job, and
|
|
81
|
+
`worker.stop()` really stops consumption. Pass `autoProcess: false` to keep the
|
|
82
|
+
queue from consuming before any worker exists.
|
|
83
|
+
|
|
84
|
+
**Timeouts are cooperative.** A timeout aborts `context.signal`; honour it. The
|
|
85
|
+
job's concurrency slot and its retry wait up to `timeoutGraceMs` (5000 ms by
|
|
86
|
+
default) for the processor to settle, so a retry never runs beside the attempt
|
|
87
|
+
it replaces. A processor still running after the grace period is abandoned.
|
|
88
|
+
|
|
89
|
+
**Carrying context across the queue.** AsyncLocalStorage does not follow a job
|
|
90
|
+
into the poller or worker that runs it. A `QueueContextCarrier` captures a value
|
|
91
|
+
at `add()` into job metadata and restores it around the middleware and the
|
|
92
|
+
processor:
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
96
|
+
import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
|
|
97
|
+
import type { QueueContextCarrier } from "@zudojs/queue";
|
|
98
|
+
|
|
99
|
+
const tenants = new AsyncLocalStorage<{ tenantId: string }>();
|
|
100
|
+
|
|
101
|
+
const tenantCarrier: QueueContextCarrier<string> = {
|
|
102
|
+
key: "tenantId",
|
|
103
|
+
capture: () => tenants.getStore()?.tenantId,
|
|
104
|
+
restore: (tenantId, run) => tenants.run({ tenantId }, run),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const jobs = createInMemoryQueue<{ id: number }>(createQueueName("reports"), {
|
|
108
|
+
contextCarriers: [tenantCarrier],
|
|
109
|
+
});
|
|
110
|
+
jobs.process("build", async () => {
|
|
111
|
+
console.log("building for", tenants.getStore()?.tenantId); // "acme"
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
await tenants.run({ tenantId: "acme" }, () => jobs.add("build", { id: 1 }));
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
116
|
+
await jobs.close();
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Keep captured values small and serializable (ids, not live objects). With
|
|
120
|
+
`@zudojs/tenancy`, capture the tenant id and restore it with the tenant context
|
|
121
|
+
storage's `run`.
|
|
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
|
+
|
|
127
|
+
Errors with no caller to receive them (a worker's failing poll, a throwing event
|
|
128
|
+
listener) go to `logger.error` when a logger is configured, and otherwise to
|
|
129
|
+
`process.emitWarning`, never to `console`.
|
|
71
130
|
|
|
72
131
|
## Use Cases
|
|
73
132
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context capture at `add()` and restoration around processing.
|
|
3
|
+
*
|
|
4
|
+
* @module contextCarrier/contextCarrier.core
|
|
5
|
+
*/
|
|
6
|
+
import type { Job } from "../job/job.type.js";
|
|
7
|
+
import type { QueueContextCarrier } from "./contextCarrier.type.js";
|
|
8
|
+
/**
|
|
9
|
+
* Job metadata key under which captured context values are stored, as a
|
|
10
|
+
* `{ [carrier.key]: value }` record.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CONTEXT_METADATA_KEY = "zudo:context";
|
|
13
|
+
/**
|
|
14
|
+
* Captures every carrier's value into a metadata record.
|
|
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
|
+
*
|
|
21
|
+
* @param carriers - The queue's context carriers.
|
|
22
|
+
* @param metadata - The job's own metadata, if any.
|
|
23
|
+
* @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
|
|
24
|
+
* without one when no carrier captured anything.
|
|
25
|
+
*/
|
|
26
|
+
export declare function captureContext(carriers: readonly QueueContextCarrier[] | undefined, metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Runs `fn` inside every context the job carries, first carrier outermost.
|
|
29
|
+
*
|
|
30
|
+
* @param carriers - The queue's context carriers.
|
|
31
|
+
* @param job - The job about to run.
|
|
32
|
+
* @param fn - The work to run inside the restored context.
|
|
33
|
+
* @returns Whatever `fn` resolves to.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runWithContext<T>(carriers: readonly QueueContextCarrier[] | undefined, job: Job<unknown>, fn: () => Promise<T>): Promise<T>;
|
|
36
|
+
//# sourceMappingURL=contextCarrier.core.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context capture at `add()` and restoration around processing.
|
|
3
|
+
*
|
|
4
|
+
* @module contextCarrier/contextCarrier.core
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Job metadata key under which captured context values are stored, as a
|
|
8
|
+
* `{ [carrier.key]: value }` record.
|
|
9
|
+
*/
|
|
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
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Captures every carrier's value into a metadata record.
|
|
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
|
+
*
|
|
34
|
+
* @param carriers - The queue's context carriers.
|
|
35
|
+
* @param metadata - The job's own metadata, if any.
|
|
36
|
+
* @returns The metadata with a {@link CONTEXT_METADATA_KEY} entry added, or
|
|
37
|
+
* without one when no carrier captured anything.
|
|
38
|
+
*/
|
|
39
|
+
export function captureContext(carriers, metadata) {
|
|
40
|
+
if (!carriers || carriers.length === 0)
|
|
41
|
+
return withoutStoredContext(metadata);
|
|
42
|
+
const captured = {};
|
|
43
|
+
let any = false;
|
|
44
|
+
for (const carrier of carriers) {
|
|
45
|
+
const value = carrier.capture();
|
|
46
|
+
if (value === undefined)
|
|
47
|
+
continue;
|
|
48
|
+
captured[carrier.key] = value;
|
|
49
|
+
any = true;
|
|
50
|
+
}
|
|
51
|
+
if (!any)
|
|
52
|
+
return withoutStoredContext(metadata);
|
|
53
|
+
return { ...metadata, [CONTEXT_METADATA_KEY]: Object.freeze(captured) };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Runs `fn` inside every context the job carries, first carrier outermost.
|
|
57
|
+
*
|
|
58
|
+
* @param carriers - The queue's context carriers.
|
|
59
|
+
* @param job - The job about to run.
|
|
60
|
+
* @param fn - The work to run inside the restored context.
|
|
61
|
+
* @returns Whatever `fn` resolves to.
|
|
62
|
+
*/
|
|
63
|
+
export function runWithContext(carriers, job, fn) {
|
|
64
|
+
const raw = job.metadata?.[CONTEXT_METADATA_KEY];
|
|
65
|
+
if (!carriers || carriers.length === 0)
|
|
66
|
+
return fn();
|
|
67
|
+
if (typeof raw !== "object" || raw === null)
|
|
68
|
+
return fn();
|
|
69
|
+
const stored = raw;
|
|
70
|
+
let run = fn;
|
|
71
|
+
for (let i = carriers.length - 1; i >= 0; i--) {
|
|
72
|
+
const carrier = carriers[i];
|
|
73
|
+
if (!Object.hasOwn(stored, carrier.key))
|
|
74
|
+
continue;
|
|
75
|
+
const inner = run;
|
|
76
|
+
run = () => carrier.restore(stored[carrier.key], inner);
|
|
77
|
+
}
|
|
78
|
+
return run();
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=contextCarrier.core.js.map
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context carrier contract.
|
|
3
|
+
*
|
|
4
|
+
* @module contextCarrier/contextCarrier.type
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Captures one piece of ambient context when a job is added and restores it
|
|
8
|
+
* while the job runs.
|
|
9
|
+
*
|
|
10
|
+
* AsyncLocalStorage does not follow a job from the request that enqueued it
|
|
11
|
+
* into the poller or worker that runs it, so without a carrier every job runs
|
|
12
|
+
* with no tenant, no correlation id and no trace. Keep the captured value
|
|
13
|
+
* small and serializable (an id, not a live object): a broker-backed queue
|
|
14
|
+
* has to store it with the job.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* ```typescript
|
|
18
|
+
* const tenantCarrier: QueueContextCarrier<string> = {
|
|
19
|
+
* key: "tenantId",
|
|
20
|
+
* capture: () => tenantStorage.get()?.tenant?.id,
|
|
21
|
+
* restore: (tenantId, run) =>
|
|
22
|
+
* tenantStorage.run(contextFor(tenantId), run),
|
|
23
|
+
* };
|
|
24
|
+
* createInMemoryQueue("emails", { contextCarriers: [tenantCarrier] });
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export interface QueueContextCarrier<TValue = unknown> {
|
|
28
|
+
/** Unique name under which the value is stored with the job. */
|
|
29
|
+
readonly key: string;
|
|
30
|
+
/**
|
|
31
|
+
* Reads the value from the caller's context at `add()`. Returning
|
|
32
|
+
* `undefined` stores nothing.
|
|
33
|
+
*/
|
|
34
|
+
capture(): TValue | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Runs `run` inside the restored context. Called only for jobs that
|
|
37
|
+
* carry a value for this carrier's `key`.
|
|
38
|
+
*/
|
|
39
|
+
restore<T>(value: TValue, run: () => Promise<T>): Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=contextCarrier.type.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/queue/contextCarrier
|
|
3
|
+
*
|
|
4
|
+
* Carries ambient execution context (tenant, correlation id, trace ids)
|
|
5
|
+
* across the queue boundary: captured into job metadata at `add()` and
|
|
6
|
+
* restored around the processor, so a background job runs in the context of
|
|
7
|
+
* the request that enqueued it.
|
|
8
|
+
*/
|
|
9
|
+
export type { QueueContextCarrier } from "./contextCarrier.type.js";
|
|
10
|
+
export { CONTEXT_METADATA_KEY, captureContext, runWithContext, } from "./contextCarrier.core.js";
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/queue/contextCarrier
|
|
3
|
+
*
|
|
4
|
+
* Carries ambient execution context (tenant, correlation id, trace ids)
|
|
5
|
+
* across the queue boundary: captured into job metadata at `add()` and
|
|
6
|
+
* restored around the processor, so a background job runs in the context of
|
|
7
|
+
* the request that enqueued it.
|
|
8
|
+
*/
|
|
9
|
+
export { CONTEXT_METADATA_KEY, captureContext, runWithContext, } from "./contextCarrier.core.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -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.
|
|
@@ -20,6 +21,8 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
|
|
|
20
21
|
private readonly middleware;
|
|
21
22
|
private paused;
|
|
22
23
|
private disposed;
|
|
24
|
+
/** Whether the internal poller claims jobs; see `setAutoProcess`. */
|
|
25
|
+
private autoProcess;
|
|
23
26
|
private activeCount;
|
|
24
27
|
private pollTimer;
|
|
25
28
|
private readonly scheduledTimers;
|
|
@@ -30,11 +33,32 @@ export declare class InMemoryQueue<TData = unknown> implements Queue<TData> {
|
|
|
30
33
|
private readonly stalledCounts;
|
|
31
34
|
private readonly deduplicationIndex;
|
|
32
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;
|
|
33
41
|
private readonly emitter;
|
|
34
42
|
private readonly counters;
|
|
35
43
|
private emptySince;
|
|
36
44
|
private backoffMs;
|
|
37
45
|
constructor(name: QueueName, options?: QueueOptions);
|
|
46
|
+
/**
|
|
47
|
+
* Turns the internal poller on or off as a consumer.
|
|
48
|
+
*
|
|
49
|
+
* While off, the poller still promotes scheduled jobs and reclaims stalled
|
|
50
|
+
* ones, but claims nothing: an external `Worker` is the only consumer, so
|
|
51
|
+
* stopping it really stops consumption and its middleware, timeout and
|
|
52
|
+
* concurrency apply to every job.
|
|
53
|
+
*/
|
|
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;
|
|
38
62
|
add(jobName: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
|
|
39
63
|
process(name: string, processor: Processor<TData>): void;
|
|
40
64
|
getJob(jobId: JobId): Promise<Job<TData> | null>;
|
|
@@ -4,9 +4,10 @@ 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
|
+
import { captureContext } from "../contextCarrier/contextCarrier.core.js";
|
|
10
11
|
import { scheduleJob, promoteDueScheduledJobs, } from "./inMemoryQueue.scheduling.js";
|
|
11
12
|
/** Terminal states a job never leaves. */
|
|
12
13
|
const TERMINAL_STATES = new Set([
|
|
@@ -34,6 +35,8 @@ export class InMemoryQueue {
|
|
|
34
35
|
middleware;
|
|
35
36
|
paused = false;
|
|
36
37
|
disposed = false;
|
|
38
|
+
/** Whether the internal poller claims jobs; see `setAutoProcess`. */
|
|
39
|
+
autoProcess;
|
|
37
40
|
activeCount = 0;
|
|
38
41
|
pollTimer = null;
|
|
39
42
|
scheduledTimers = new Map();
|
|
@@ -44,6 +47,11 @@ export class InMemoryQueue {
|
|
|
44
47
|
stalledCounts = new Map();
|
|
45
48
|
deduplicationIndex = new Map();
|
|
46
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;
|
|
47
55
|
emitter;
|
|
48
56
|
counters = {
|
|
49
57
|
processedCount: 0,
|
|
@@ -60,8 +68,40 @@ export class InMemoryQueue {
|
|
|
60
68
|
this.serializer = this.options.serializer ?? JsonSerializer;
|
|
61
69
|
this.middleware = this.options.middleware ?? [];
|
|
62
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;
|
|
63
79
|
this.deadLetterStore =
|
|
64
|
-
this.options.deadLetterStore ??
|
|
80
|
+
this.options.deadLetterStore ??
|
|
81
|
+
createInMemoryDeadLetterStore({
|
|
82
|
+
maxEntries: DEFAULT_DEAD_LETTER_JOBS,
|
|
83
|
+
});
|
|
84
|
+
this.autoProcess = this.options.autoProcess ?? true;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Turns the internal poller on or off as a consumer.
|
|
88
|
+
*
|
|
89
|
+
* While off, the poller still promotes scheduled jobs and reclaims stalled
|
|
90
|
+
* ones, but claims nothing: an external `Worker` is the only consumer, so
|
|
91
|
+
* stopping it really stops consumption and its middleware, timeout and
|
|
92
|
+
* concurrency apply to every job.
|
|
93
|
+
*/
|
|
94
|
+
setAutoProcess(enabled) {
|
|
95
|
+
this.autoProcess = enabled;
|
|
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;
|
|
65
105
|
}
|
|
66
106
|
async add(jobName, data, options) {
|
|
67
107
|
if (this.disposed)
|
|
@@ -74,7 +114,9 @@ export class InMemoryQueue {
|
|
|
74
114
|
queueName: this.name,
|
|
75
115
|
});
|
|
76
116
|
}
|
|
77
|
-
const
|
|
117
|
+
const merged = { ...this.options.defaultJobOptions, ...options };
|
|
118
|
+
const metadata = captureContext(this.options.contextCarriers, merged.metadata);
|
|
119
|
+
const mergedOptions = metadata === merged.metadata ? merged : { ...merged, metadata };
|
|
78
120
|
if (mergedOptions.deduplicationKey) {
|
|
79
121
|
const existing = this.deduplicationIndex.get(mergedOptions.deduplicationKey);
|
|
80
122
|
if (existing && this.jobs.has(existing)) {
|
|
@@ -260,6 +302,11 @@ export class InMemoryQueue {
|
|
|
260
302
|
this.settledOrder.length = 0;
|
|
261
303
|
this.stalledCounts.clear();
|
|
262
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
|
+
}
|
|
263
310
|
this.activeCount = 0;
|
|
264
311
|
this.paused = false;
|
|
265
312
|
this.emptySince = 0;
|
|
@@ -383,17 +430,23 @@ export class InMemoryQueue {
|
|
|
383
430
|
}
|
|
384
431
|
const abortController = new AbortController();
|
|
385
432
|
// A consumer's own signal (a worker draining, say) must reach the
|
|
386
|
-
// job it dispatched.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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);
|
|
390
442
|
}
|
|
391
443
|
else {
|
|
392
|
-
|
|
444
|
+
forwardAbort = () => {
|
|
393
445
|
if (!abortController.signal.aborted) {
|
|
394
|
-
abortController.abort(
|
|
446
|
+
abortController.abort(consumerSignal.reason);
|
|
395
447
|
}
|
|
396
|
-
}
|
|
448
|
+
};
|
|
449
|
+
consumerSignal.addEventListener("abort", forwardAbort, { once: true });
|
|
397
450
|
}
|
|
398
451
|
}
|
|
399
452
|
this.inFlight.set(job.id, abortController);
|
|
@@ -402,6 +455,12 @@ export class InMemoryQueue {
|
|
|
402
455
|
await processJob(job, processor, {
|
|
403
456
|
timeoutMs: job.timeoutMs ?? options?.timeoutMs,
|
|
404
457
|
abortController,
|
|
458
|
+
...(this.options.timeoutGraceMs !== undefined
|
|
459
|
+
? { timeoutGraceMs: this.options.timeoutGraceMs }
|
|
460
|
+
: {}),
|
|
461
|
+
...(this.options.contextCarriers
|
|
462
|
+
? { contextCarriers: this.options.contextCarriers }
|
|
463
|
+
: {}),
|
|
405
464
|
}, {
|
|
406
465
|
jobs: this.jobs,
|
|
407
466
|
emitter: this.emitter,
|
|
@@ -413,6 +472,9 @@ export class InMemoryQueue {
|
|
|
413
472
|
registerRetryTimer: (jobId, timer) => {
|
|
414
473
|
this.retryTimers.set(jobId, timer);
|
|
415
474
|
},
|
|
475
|
+
deregisterRetryTimer: (jobId) => {
|
|
476
|
+
this.retryTimers.delete(jobId);
|
|
477
|
+
},
|
|
416
478
|
onSettled: (settled) => this.recordSettled(settled),
|
|
417
479
|
isDisposed: () => this.disposed,
|
|
418
480
|
...(this.options.logger ? { logger: this.options.logger } : {}),
|
|
@@ -427,7 +489,9 @@ export class InMemoryQueue {
|
|
|
427
489
|
finally {
|
|
428
490
|
this.activeCount--;
|
|
429
491
|
this.inFlight.delete(job.id);
|
|
430
|
-
|
|
492
|
+
if (forwardAbort && consumerSignal) {
|
|
493
|
+
consumerSignal.removeEventListener("abort", forwardAbort);
|
|
494
|
+
}
|
|
431
495
|
}
|
|
432
496
|
}
|
|
433
497
|
async processTick() {
|
|
@@ -435,7 +499,7 @@ export class InMemoryQueue {
|
|
|
435
499
|
return;
|
|
436
500
|
const concurrency = Math.max(1, this.options.concurrency ?? 1);
|
|
437
501
|
let processed = 0;
|
|
438
|
-
while (this.activeCount < concurrency) {
|
|
502
|
+
while (this.autoProcess && this.activeCount < concurrency) {
|
|
439
503
|
// `claimNextJob` only returns jobs that have a registered
|
|
440
504
|
// processor and moves them out of `waiting`, so this loop always
|
|
441
505
|
// terminates. Returning an unrunnable job here is what previously
|
|
@@ -5,6 +5,7 @@ import type { QueueMiddleware } from "../middleware/middleware.type.js";
|
|
|
5
5
|
import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
|
|
6
6
|
import type { DeadLetterStore } from "../deadLetter/deadLetter.type.js";
|
|
7
7
|
import type { QueueLogger } from "../queue/queue.type.js";
|
|
8
|
+
import type { QueueContextCarrier } from "../contextCarrier/contextCarrier.type.js";
|
|
8
9
|
/**
|
|
9
10
|
* Mutable throughput counters.
|
|
10
11
|
*
|
|
@@ -32,6 +33,8 @@ export interface ProcessJobDependencies<TData> {
|
|
|
32
33
|
* queue can clear it on close instead of leaking it.
|
|
33
34
|
*/
|
|
34
35
|
readonly registerRetryTimer: (jobId: JobId, timer: ReturnType<typeof setTimeout>) => void;
|
|
36
|
+
/** Forgets a retry timer once it has fired. */
|
|
37
|
+
readonly deregisterRetryTimer?: (jobId: JobId) => void;
|
|
35
38
|
/** Invoked whenever a job reaches a terminal state. */
|
|
36
39
|
readonly onSettled?: (job: Job<TData>) => void;
|
|
37
40
|
/** Whether the owning queue has been disposed. */
|
|
@@ -52,6 +55,10 @@ export interface ProcessJobDependencies<TData> {
|
|
|
52
55
|
export declare function processJob<TData>(job: Job<TData>, processor: Processor<TData>, options: {
|
|
53
56
|
timeoutMs?: number;
|
|
54
57
|
abortController?: AbortController;
|
|
58
|
+
/** See `QueueOptions.timeoutGraceMs`. */
|
|
59
|
+
timeoutGraceMs?: number;
|
|
60
|
+
/** See `QueueOptions.contextCarriers`. */
|
|
61
|
+
contextCarriers?: readonly QueueContextCarrier[];
|
|
55
62
|
}, deps: ProcessJobDependencies<TData>): Promise<void>;
|
|
56
63
|
/**
|
|
57
64
|
* Handle job failure with retry logic.
|
|
@@ -5,6 +5,8 @@ import { createJobContext } from "../jobContext/jobContext.core.js";
|
|
|
5
5
|
import { createMiddlewareChain, createTimeoutMiddleware, } from "../middleware/middleware.core.js";
|
|
6
6
|
import { calculateRetryDelay, resolveBackoff, shouldRetry, } from "../retryPolicy/retryPolicy.core.js";
|
|
7
7
|
import { moveToDeadLetter } from "../deadLetter/deadLetter.core.js";
|
|
8
|
+
import { runWithContext } from "../contextCarrier/contextCarrier.core.js";
|
|
9
|
+
import { DEFAULT_TIMEOUT_GRACE_MS, settleWithin, } from "./inMemoryQueue.settle.js";
|
|
8
10
|
import { JobMaxAttemptsError } from "@zudojs/errors";
|
|
9
11
|
/**
|
|
10
12
|
* Narrows a processor's return value to a `JobResult`.
|
|
@@ -46,10 +48,15 @@ export async function processJob(job, processor, options, deps) {
|
|
|
46
48
|
},
|
|
47
49
|
});
|
|
48
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;
|
|
49
55
|
const timeoutMiddleware = createTimeoutMiddleware(timeoutMs, () => {
|
|
50
56
|
// Let a cooperative processor observe the timeout and stop working
|
|
51
57
|
// instead of running on with its result discarded.
|
|
52
58
|
if (!abortController.signal.aborted) {
|
|
59
|
+
timedOut = true;
|
|
53
60
|
abortController.abort(new Error(`Job "${updatedJob.id}" timed out after ${timeoutMs}ms.`));
|
|
54
61
|
}
|
|
55
62
|
});
|
|
@@ -57,14 +64,18 @@ export async function processJob(job, processor, options, deps) {
|
|
|
57
64
|
timeoutMiddleware,
|
|
58
65
|
...deps.middleware,
|
|
59
66
|
]);
|
|
67
|
+
// The processor promise itself, so a failure (a timeout above all) can
|
|
68
|
+
// wait for it to stop before the slot and the retry are released.
|
|
69
|
+
let running;
|
|
60
70
|
try {
|
|
61
|
-
const result = await middlewareChain({
|
|
71
|
+
const result = await runWithContext(options.contextCarriers, updatedJob, () => middlewareChain({
|
|
62
72
|
job: updatedJob,
|
|
63
73
|
context,
|
|
64
74
|
next: async () => {
|
|
65
|
-
|
|
75
|
+
running = Promise.resolve(processor(updatedJob, context));
|
|
76
|
+
return running;
|
|
66
77
|
},
|
|
67
|
-
});
|
|
78
|
+
}));
|
|
68
79
|
if (isJobResult(result) && !result.success) {
|
|
69
80
|
await handleJobFailure(updatedJob, result.error ?? "Job failed", deps);
|
|
70
81
|
}
|
|
@@ -85,6 +96,10 @@ export async function processJob(job, processor, options, deps) {
|
|
|
85
96
|
}
|
|
86
97
|
catch (error) {
|
|
87
98
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
99
|
+
await settleWithin(running, options.timeoutGraceMs ?? DEFAULT_TIMEOUT_GRACE_MS);
|
|
100
|
+
if (abortController.signal.aborted && !timedOut) {
|
|
101
|
+
emitter.emit("job:cancelled", { job: updatedJob });
|
|
102
|
+
}
|
|
88
103
|
await handleJobFailure(updatedJob, errorMessage, deps);
|
|
89
104
|
}
|
|
90
105
|
finally {
|
|
@@ -122,6 +137,7 @@ export async function handleJobFailure(job, errorMessage, deps) {
|
|
|
122
137
|
const backoff = resolveBackoff(incrementedJob.backoff);
|
|
123
138
|
const delay = calculateRetryDelay(incrementedJob.attempt, backoff);
|
|
124
139
|
const timer = setTimeout(() => {
|
|
140
|
+
deps.deregisterRetryTimer?.(job.id);
|
|
125
141
|
if (deps.isDisposed()) {
|
|
126
142
|
return;
|
|
127
143
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting for a timed-out processor to actually stop.
|
|
3
|
+
*
|
|
4
|
+
* The timeout middleware rejects as soon as the timer fires, but the
|
|
5
|
+
* processor promise it raced keeps running until it notices the aborted
|
|
6
|
+
* signal. Freeing the concurrency slot and scheduling the retry at that
|
|
7
|
+
* moment let the retry run beside the original attempt.
|
|
8
|
+
*
|
|
9
|
+
* @module inMemoryQueue/inMemoryQueue.settle
|
|
10
|
+
*/
|
|
11
|
+
/** Default grace for a timed-out processor to settle, in milliseconds. */
|
|
12
|
+
export declare const DEFAULT_TIMEOUT_GRACE_MS = 5000;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves once `pending` settles or `graceMs` elapses, whichever is first.
|
|
15
|
+
*
|
|
16
|
+
* Never rejects: the processor's own outcome has already been superseded by
|
|
17
|
+
* the failure being handled.
|
|
18
|
+
*
|
|
19
|
+
* @param pending - The processor promise, if the processor was started.
|
|
20
|
+
* @param graceMs - The longest to wait.
|
|
21
|
+
* @returns True when the processor settled within the grace period.
|
|
22
|
+
*/
|
|
23
|
+
export declare function settleWithin(pending: Promise<unknown> | undefined, graceMs: number): Promise<boolean>;
|
|
24
|
+
//# sourceMappingURL=inMemoryQueue.settle.d.ts.map
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting for a timed-out processor to actually stop.
|
|
3
|
+
*
|
|
4
|
+
* The timeout middleware rejects as soon as the timer fires, but the
|
|
5
|
+
* processor promise it raced keeps running until it notices the aborted
|
|
6
|
+
* signal. Freeing the concurrency slot and scheduling the retry at that
|
|
7
|
+
* moment let the retry run beside the original attempt.
|
|
8
|
+
*
|
|
9
|
+
* @module inMemoryQueue/inMemoryQueue.settle
|
|
10
|
+
*/
|
|
11
|
+
/** Default grace for a timed-out processor to settle, in milliseconds. */
|
|
12
|
+
export const DEFAULT_TIMEOUT_GRACE_MS = 5_000;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves once `pending` settles or `graceMs` elapses, whichever is first.
|
|
15
|
+
*
|
|
16
|
+
* Never rejects: the processor's own outcome has already been superseded by
|
|
17
|
+
* the failure being handled.
|
|
18
|
+
*
|
|
19
|
+
* @param pending - The processor promise, if the processor was started.
|
|
20
|
+
* @param graceMs - The longest to wait.
|
|
21
|
+
* @returns True when the processor settled within the grace period.
|
|
22
|
+
*/
|
|
23
|
+
export async function settleWithin(pending, graceMs) {
|
|
24
|
+
if (pending === undefined)
|
|
25
|
+
return true;
|
|
26
|
+
let timer;
|
|
27
|
+
const settled = pending.then(() => true, () => true);
|
|
28
|
+
const expired = new Promise((resolve) => {
|
|
29
|
+
timer = setTimeout(() => resolve(false), Math.max(0, graceMs));
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
return await Promise.race([settled, expired]);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (timer !== undefined)
|
|
36
|
+
clearTimeout(timer);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=inMemoryQueue.settle.js.map
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -39,4 +39,6 @@ export * from "./worker/index.js";
|
|
|
39
39
|
export * from "./inMemoryQueue/index.js";
|
|
40
40
|
// Dead letter
|
|
41
41
|
export * from "./deadLetter/index.js";
|
|
42
|
+
// Context propagation across the queue boundary
|
|
43
|
+
export * from "./contextCarrier/index.js";
|
|
42
44
|
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort reporting for failures nobody else can receive.
|
|
3
|
+
*
|
|
4
|
+
* A worker's poll failure or a throwing event listener has no caller to
|
|
5
|
+
* reject. They used to go to `console.error`, bypassing structured logging
|
|
6
|
+
* and redaction. They now go to the configured logger's `error`, and without
|
|
7
|
+
* one to `process.emitWarning`, which Node prints once and applications can
|
|
8
|
+
* intercept with `process.on("warning")`.
|
|
9
|
+
*
|
|
10
|
+
* @module queue/queue.report
|
|
11
|
+
*/
|
|
12
|
+
import type { QueueLogger } from "./queue.type.js";
|
|
13
|
+
/**
|
|
14
|
+
* Report an error that has no caller to receive it.
|
|
15
|
+
*
|
|
16
|
+
* @param message - What failed.
|
|
17
|
+
* @param error - The failure.
|
|
18
|
+
* @param logger - The configured logger, if any.
|
|
19
|
+
*/
|
|
20
|
+
export declare function reportQueueError(message: string, error: unknown, logger?: QueueLogger): void;
|
|
21
|
+
//# sourceMappingURL=queue.report.d.ts.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-resort reporting for failures nobody else can receive.
|
|
3
|
+
*
|
|
4
|
+
* A worker's poll failure or a throwing event listener has no caller to
|
|
5
|
+
* reject. They used to go to `console.error`, bypassing structured logging
|
|
6
|
+
* and redaction. They now go to the configured logger's `error`, and without
|
|
7
|
+
* one to `process.emitWarning`, which Node prints once and applications can
|
|
8
|
+
* intercept with `process.on("warning")`.
|
|
9
|
+
*
|
|
10
|
+
* @module queue/queue.report
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Report an error that has no caller to receive it.
|
|
14
|
+
*
|
|
15
|
+
* @param message - What failed.
|
|
16
|
+
* @param error - The failure.
|
|
17
|
+
* @param logger - The configured logger, if any.
|
|
18
|
+
*/
|
|
19
|
+
export function reportQueueError(message, error, logger) {
|
|
20
|
+
if (logger?.error) {
|
|
21
|
+
logger.error(message, { error });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
process.emitWarning(error instanceof Error ? error : new Error(String(error)), { type: "ZudoQueueWarning", detail: message });
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=queue.report.js.map
|
|
@@ -5,6 +5,7 @@ import type { Processor } from "../processor/processor.type.js";
|
|
|
5
5
|
import type { Serializer } from "../serializer/serializer.type.js";
|
|
6
6
|
import type { QueueMiddleware } from "../middleware/middleware.type.js";
|
|
7
7
|
import type { QueueEventEmitter } from "../queueEmitter/queueEmitter.type.js";
|
|
8
|
+
import type { QueueContextCarrier } from "../contextCarrier/contextCarrier.type.js";
|
|
8
9
|
import type { DeadLetterJob, DeadLetterStore } from "../deadLetter/deadLetter.type.js";
|
|
9
10
|
/**
|
|
10
11
|
* Somewhere for a job to write a log line.
|
|
@@ -13,6 +14,8 @@ import type { DeadLetterJob, DeadLetterStore } from "../deadLetter/deadLetter.ty
|
|
|
13
14
|
*/
|
|
14
15
|
export interface QueueLogger {
|
|
15
16
|
info(message: string, data?: Record<string, unknown>): void;
|
|
17
|
+
/** Receives failures that have no caller to reject (poll errors). */
|
|
18
|
+
error?(message: string, data?: Record<string, unknown>): void;
|
|
16
19
|
}
|
|
17
20
|
/**
|
|
18
21
|
* Options for creating a queue.
|
|
@@ -79,6 +82,28 @@ export interface QueueOptions {
|
|
|
79
82
|
* reclaimed again. Defaults to 3.
|
|
80
83
|
*/
|
|
81
84
|
readonly maxStalledCount?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Whether the queue's own poller claims and runs jobs. Defaults to
|
|
87
|
+
* `true`. Creating a `Worker` for the queue turns it off, so the worker —
|
|
88
|
+
* with its middleware, timeout and concurrency — is the only consumer.
|
|
89
|
+
*/
|
|
90
|
+
readonly autoProcess?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* After a job times out, how long its concurrency slot and its retry wait
|
|
93
|
+
* for the processor to actually settle, in milliseconds. Defaults to 5000.
|
|
94
|
+
*
|
|
95
|
+
* A timeout aborts `context.signal`; a processor that ignores the signal
|
|
96
|
+
* keeps running. Without the wait, the retry started beside it and one
|
|
97
|
+
* job ran several times at once. Past this grace the processor is
|
|
98
|
+
* abandoned and the job fails anyway, so processors must honour the
|
|
99
|
+
* signal.
|
|
100
|
+
*/
|
|
101
|
+
readonly timeoutGraceMs?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Context carried from `add()` into the processor (tenant, correlation
|
|
104
|
+
* id, trace ids). See {@link QueueContextCarrier}.
|
|
105
|
+
*/
|
|
106
|
+
readonly contextCarriers?: readonly QueueContextCarrier[];
|
|
82
107
|
}
|
|
83
108
|
/**
|
|
84
109
|
* Statistics for a queue.
|
|
@@ -113,6 +138,14 @@ export interface QueueStats {
|
|
|
113
138
|
export interface Queue<TData = unknown> {
|
|
114
139
|
/** Queue name. */
|
|
115
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;
|
|
116
149
|
/** Add a job to the queue. */
|
|
117
150
|
add(name: string, data: TData, options?: JobOptions): Promise<Job<TData>>;
|
|
118
151
|
/** Process jobs with a processor. */
|
|
@@ -171,6 +204,11 @@ export interface Queue<TData = unknown> {
|
|
|
171
204
|
getDeadLetterJobs(): Promise<readonly DeadLetterJob<TData>[]>;
|
|
172
205
|
/** Close the queue, draining in-flight jobs first. */
|
|
173
206
|
close(): Promise<void>;
|
|
207
|
+
/**
|
|
208
|
+
* Turns the queue's own poller on or off as a consumer. `createWorker`
|
|
209
|
+
* turns it off so a worker is never racing the queue for jobs.
|
|
210
|
+
*/
|
|
211
|
+
setAutoProcess?(enabled: boolean): void;
|
|
174
212
|
}
|
|
175
213
|
/**
|
|
176
214
|
* Event types emitted by a queue.
|
|
@@ -198,6 +236,7 @@ export type QueueEventMap = {
|
|
|
198
236
|
job: Job;
|
|
199
237
|
attempt: number;
|
|
200
238
|
};
|
|
239
|
+
/** A running job was aborted from outside — a drain, a close, a cancel. */
|
|
201
240
|
"job:cancelled": {
|
|
202
241
|
job: Job;
|
|
203
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
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { reportQueueError } from "../queue/queue.report.js";
|
|
1
2
|
/**
|
|
2
3
|
* In-memory queue event emitter.
|
|
3
4
|
*
|
|
@@ -9,15 +10,34 @@
|
|
|
9
10
|
export class InMemoryQueueEventEmitter {
|
|
10
11
|
handlers = new Map();
|
|
11
12
|
onHandlerError;
|
|
13
|
+
/** Whether `onHandlerError` was supplied, so `setLogger` leaves it alone. */
|
|
14
|
+
hasCustomHandlerError;
|
|
15
|
+
logger;
|
|
12
16
|
constructor(options = {}) {
|
|
17
|
+
this.hasCustomHandlerError = options.onHandlerError !== undefined;
|
|
18
|
+
this.logger = options.logger;
|
|
13
19
|
this.onHandlerError =
|
|
14
20
|
options.onHandlerError ??
|
|
15
21
|
((error, event) => {
|
|
16
22
|
queueMicrotask(() => {
|
|
17
|
-
|
|
23
|
+
reportQueueError(`[@zudojs/queue] Listener for "${event}" threw.`, error, this.logger);
|
|
18
24
|
});
|
|
19
25
|
});
|
|
20
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
|
+
}
|
|
21
41
|
emit(event, data) {
|
|
22
42
|
const handlers = this.handlers.get(event);
|
|
23
43
|
if (!handlers) {
|
|
@@ -6,6 +6,11 @@ import type { Worker, WorkerOptions } from "./worker.type.js";
|
|
|
6
6
|
* The worker claims each job before running it, so a job is never picked
|
|
7
7
|
* up twice — by this worker on its next poll, or by another worker on the
|
|
8
8
|
* same queue.
|
|
9
|
+
*
|
|
10
|
+
* Creating a worker turns the queue's own poller off as a consumer
|
|
11
|
+
* (`queue.setAutoProcess(false)`), so the worker — with its middleware,
|
|
12
|
+
* timeout and concurrency — is the only thing running jobs, and `stop()`
|
|
13
|
+
* really stops consumption.
|
|
9
14
|
*/
|
|
10
15
|
export declare function createWorker<TData>(id: string, queue: Queue<TData>, options?: WorkerOptions): Worker<TData>;
|
|
11
16
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { JobState as JobStateEnum, WorkerState, } from "../jobTypes/jobTypes.type.js";
|
|
2
2
|
import { WorkerLifecycleError } from "@zudojs/errors";
|
|
3
|
+
import { reportQueueError } from "../queue/queue.report.js";
|
|
3
4
|
/** How long `stop()` waits for in-flight jobs before forcing a stop. */
|
|
4
5
|
const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
|
|
5
6
|
/**
|
|
@@ -8,6 +9,11 @@ const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
|
|
|
8
9
|
* The worker claims each job before running it, so a job is never picked
|
|
9
10
|
* up twice — by this worker on its next poll, or by another worker on the
|
|
10
11
|
* same queue.
|
|
12
|
+
*
|
|
13
|
+
* Creating a worker turns the queue's own poller off as a consumer
|
|
14
|
+
* (`queue.setAutoProcess(false)`), so the worker — with its middleware,
|
|
15
|
+
* timeout and concurrency — is the only thing running jobs, and `stop()`
|
|
16
|
+
* really stops consumption.
|
|
11
17
|
*/
|
|
12
18
|
export function createWorker(id, queue, options) {
|
|
13
19
|
let state = WorkerState.CREATED;
|
|
@@ -19,12 +25,30 @@ export function createWorker(id, queue, options) {
|
|
|
19
25
|
let activeJobs = 0;
|
|
20
26
|
let polling = false;
|
|
21
27
|
let abortController = null;
|
|
22
|
-
|
|
28
|
+
queue.setAutoProcess?.(false);
|
|
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 ??
|
|
23
40
|
((error) => {
|
|
24
41
|
queueMicrotask(() => {
|
|
25
|
-
|
|
42
|
+
reportQueueError(`[@zudojs/queue] Worker "${id}" poll failed.`, error, options?.logger);
|
|
26
43
|
});
|
|
27
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
|
+
};
|
|
28
52
|
/**
|
|
29
53
|
* Arms the next poll. At most one timer is ever armed: a delayed poll
|
|
30
54
|
* already pending is left alone, while an immediate poll (capacity just
|
|
@@ -150,6 +174,7 @@ export function createWorker(id, queue, options) {
|
|
|
150
174
|
abortController = new AbortController();
|
|
151
175
|
try {
|
|
152
176
|
state = WorkerState.RUNNING;
|
|
177
|
+
emitLifecycle("worker:started");
|
|
153
178
|
scheduleNextPoll(0);
|
|
154
179
|
}
|
|
155
180
|
catch (error) {
|
|
@@ -186,11 +211,17 @@ export function createWorker(id, queue, options) {
|
|
|
186
211
|
}
|
|
187
212
|
clearPollTimer();
|
|
188
213
|
state = WorkerState.STOPPED;
|
|
214
|
+
emitLifecycle("worker:stopped");
|
|
189
215
|
},
|
|
190
216
|
async forceStop() {
|
|
217
|
+
const wasLive = state !== WorkerState.CREATED && state !== WorkerState.STOPPED;
|
|
191
218
|
abortController?.abort();
|
|
192
219
|
clearPollTimer();
|
|
193
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");
|
|
194
225
|
},
|
|
195
226
|
isRunning() {
|
|
196
227
|
return state === WorkerState.RUNNING;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Queue } from "../queue/queue.type.js";
|
|
1
|
+
import type { Queue, QueueLogger } from "../queue/queue.type.js";
|
|
2
2
|
import type { QueueMiddleware } from "../middleware/middleware.type.js";
|
|
3
3
|
import type { WorkerState } from "../jobTypes/jobTypes.type.js";
|
|
4
4
|
/**
|
|
@@ -25,10 +25,13 @@ export interface WorkerOptions {
|
|
|
25
25
|
readonly drainTimeout?: number;
|
|
26
26
|
/**
|
|
27
27
|
* Invoked for errors raised outside a job — a failing poll, a job that
|
|
28
|
-
* threw, or a drain that timed out. Defaults to
|
|
29
|
-
*
|
|
28
|
+
* threw, or a drain that timed out. Defaults to `logger.error`, or to
|
|
29
|
+
* `process.emitWarning` without a logger. Poll errors are never left as
|
|
30
|
+
* unhandled rejections.
|
|
30
31
|
*/
|
|
31
32
|
readonly onError?: (error: unknown) => void;
|
|
33
|
+
/** Receives errors when no `onError` is given. */
|
|
34
|
+
readonly logger?: QueueLogger;
|
|
32
35
|
}
|
|
33
36
|
/**
|
|
34
37
|
* Worker lifecycle states.
|
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.0
|
|
29
|
-
"@zudojs/constants": "1.
|
|
30
|
-
"@zudojs/serialization": "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",
|