@vercube/queue 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/LICENSE +21 -0
- package/README.md +60 -0
- package/dist/QueueStrategy-DwLPpfFM.mjs +237 -0
- package/dist/QueueStrategy-saWSBeU6.d.mts +715 -0
- package/dist/Strategies/BullMQStrategy.d.mts +161 -0
- package/dist/Strategies/BullMQStrategy.mjs +328 -0
- package/dist/Strategies/KafkaStrategy.d.mts +152 -0
- package/dist/Strategies/KafkaStrategy.mjs +280 -0
- package/dist/Strategies/MemoryStrategy.d.mts +139 -0
- package/dist/Strategies/MemoryStrategy.mjs +305 -0
- package/dist/Strategies/RabbitMQStrategy.d.mts +205 -0
- package/dist/Strategies/RabbitMQStrategy.mjs +388 -0
- package/dist/decorate-C0p0FnUM.mjs +25 -0
- package/dist/index.d.mts +953 -0
- package/dist/index.mjs +1719 -0
- package/package.json +54 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
import { _ as resolveBackoff, a as KEY_HEADER, c as PRIORITY_HEADER, d as delay, f as encodePayload, g as readNumericHeader, h as prune, i as JOB_HEADER, l as WILDCARD_JOB, m as normalizeHeaders, n as ATTEMPTS_HEADER, o as MAX_ATTEMPTS, p as generateJobId, r as ATTEMPT_HEADER, s as MAX_BACKOFF_MS, t as QueueStrategy, u as decodePayload, v as QueueError } from "./QueueStrategy-DwLPpfFM.mjs";
|
|
2
|
+
import { n as toQueueError, t as __decorate } from "./decorate-C0p0FnUM.mjs";
|
|
3
|
+
import { BaseDecorator, Container, Destroy, Init, Inject, InjectOptional, createDecorator } from "@vercube/di";
|
|
4
|
+
import { Logger } from "@vercube/logger";
|
|
5
|
+
import { BasePlugin, ValidationProvider } from "@vercube/core";
|
|
6
|
+
import { SpanKind, ValueType, createInstrument } from "@vercube/telemetry/instrument";
|
|
7
|
+
//#region src/Common/Instrument.ts
|
|
8
|
+
/**
|
|
9
|
+
* Traces and counts queue activity.
|
|
10
|
+
*
|
|
11
|
+
* The toolkit comes from `@vercube/telemetry/instrument`, which is the only
|
|
12
|
+
* place in the framework that speaks to OpenTelemetry directly, and it creates
|
|
13
|
+
* no instrument until one is actually used.
|
|
14
|
+
*/
|
|
15
|
+
const instrument = createInstrument("@vercube/queue");
|
|
16
|
+
/** Attribute naming the transport a job travelled through. */
|
|
17
|
+
const QUEUE_STRATEGY = "vercube.queue.strategy";
|
|
18
|
+
/** Attribute naming the queue. */
|
|
19
|
+
const QUEUE_NAME = "vercube.queue.name";
|
|
20
|
+
/** Attribute naming the job. */
|
|
21
|
+
const QUEUE_JOB = "vercube.queue.job";
|
|
22
|
+
/** Attribute carrying the attempt number. */
|
|
23
|
+
const QUEUE_ATTEMPT = "vercube.queue.attempt";
|
|
24
|
+
/** Attribute carrying how an attempt ended. */
|
|
25
|
+
const QUEUE_OUTCOME = "vercube.queue.outcome";
|
|
26
|
+
/**
|
|
27
|
+
* Writes the active trace context into a job's headers.
|
|
28
|
+
*
|
|
29
|
+
* This is what makes a background job part of the trace of the request that
|
|
30
|
+
* queued it: without it, the consumer starts a trace of its own and the two
|
|
31
|
+
* halves of the same operation can never be put back together.
|
|
32
|
+
*
|
|
33
|
+
* Uses the globally registered propagator, so it does nothing until an
|
|
34
|
+
* application installs one.
|
|
35
|
+
*
|
|
36
|
+
* @param headers - Headers the job will carry
|
|
37
|
+
*/
|
|
38
|
+
function injectTraceContext(headers) {
|
|
39
|
+
instrument.inject(headers);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reads the trace context a job was published with.
|
|
43
|
+
*
|
|
44
|
+
* @param headers - Headers the job arrived with
|
|
45
|
+
* @returns A context carrying the publishing span as parent
|
|
46
|
+
*/
|
|
47
|
+
function extractTraceContext(headers) {
|
|
48
|
+
return instrument.extract(headers);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Traces publishing one or more jobs.
|
|
52
|
+
*
|
|
53
|
+
* @param target - Strategy, queue and job the publish is for
|
|
54
|
+
* @param count - How many jobs are being published
|
|
55
|
+
* @param fn - The publish
|
|
56
|
+
* @returns Whatever the publish returned
|
|
57
|
+
*/
|
|
58
|
+
function tracePublish(target, count, fn) {
|
|
59
|
+
const published = instrument.counter("vercube.queue.published", {
|
|
60
|
+
description: "Jobs handed to a transport.",
|
|
61
|
+
unit: "{job}",
|
|
62
|
+
valueType: ValueType.INT
|
|
63
|
+
});
|
|
64
|
+
return instrument.span(`queue.publish ${target.queue}`, {
|
|
65
|
+
kind: SpanKind.PRODUCER,
|
|
66
|
+
attributes: {
|
|
67
|
+
[QUEUE_STRATEGY]: target.strategy,
|
|
68
|
+
[QUEUE_NAME]: target.queue,
|
|
69
|
+
[QUEUE_JOB]: target.job,
|
|
70
|
+
"vercube.queue.batch": count
|
|
71
|
+
}
|
|
72
|
+
}, fn).then((value) => {
|
|
73
|
+
published.add(count, {
|
|
74
|
+
[QUEUE_NAME]: target.queue,
|
|
75
|
+
[QUEUE_JOB]: target.job
|
|
76
|
+
});
|
|
77
|
+
return value;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Traces one attempt at a job.
|
|
82
|
+
*
|
|
83
|
+
* The span is parented on the publishing span when the job carries trace
|
|
84
|
+
* context, so the whole operation reads as one trace even though the two halves
|
|
85
|
+
* ran in different processes.
|
|
86
|
+
*
|
|
87
|
+
* @param target - Strategy, queue, job and attempt being processed
|
|
88
|
+
* @param headers - Headers the job arrived with
|
|
89
|
+
* @param fn - The attempt
|
|
90
|
+
* @returns Whatever the attempt returned
|
|
91
|
+
*/
|
|
92
|
+
function traceProcess(target, headers, fn) {
|
|
93
|
+
return instrument.spanFrom(`queue.process ${target.queue}.${target.job}`, {
|
|
94
|
+
kind: SpanKind.CONSUMER,
|
|
95
|
+
attributes: {
|
|
96
|
+
[QUEUE_STRATEGY]: target.strategy,
|
|
97
|
+
[QUEUE_NAME]: target.queue,
|
|
98
|
+
[QUEUE_JOB]: target.job,
|
|
99
|
+
[QUEUE_ATTEMPT]: target.attempt
|
|
100
|
+
}
|
|
101
|
+
}, extractTraceContext(headers), fn);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Counts the outcome of one attempt.
|
|
105
|
+
*
|
|
106
|
+
* @param target - Queue and job the attempt was for
|
|
107
|
+
* @param outcome - How the attempt ended
|
|
108
|
+
*/
|
|
109
|
+
function countOutcome(target, outcome) {
|
|
110
|
+
instrument.counter("vercube.queue.processed", {
|
|
111
|
+
description: "Job attempts by outcome.",
|
|
112
|
+
unit: "{attempt}",
|
|
113
|
+
valueType: ValueType.INT
|
|
114
|
+
}).add(1, {
|
|
115
|
+
[QUEUE_NAME]: target.queue,
|
|
116
|
+
[QUEUE_JOB]: target.job,
|
|
117
|
+
[QUEUE_OUTCOME]: outcome
|
|
118
|
+
});
|
|
119
|
+
instrument.activeSpan()?.setAttribute(QUEUE_OUTCOME, outcome);
|
|
120
|
+
}
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/Utils/Redact.ts
|
|
123
|
+
/**
|
|
124
|
+
* Words whose values are never kept in the inspection buffer. Matched against
|
|
125
|
+
* the words of a key, so `keys` stays visible while `accessKeys` does not.
|
|
126
|
+
*
|
|
127
|
+
* Mirrors the list `@vercube/devtools` redacts config and storage with, on
|
|
128
|
+
* purpose: a payload should never be readable in one panel and hidden in another.
|
|
129
|
+
*/
|
|
130
|
+
const SECRET_WORDS = /* @__PURE__ */ new Set([
|
|
131
|
+
"token",
|
|
132
|
+
"secret",
|
|
133
|
+
"password",
|
|
134
|
+
"passwd",
|
|
135
|
+
"pwd",
|
|
136
|
+
"credential",
|
|
137
|
+
"privatekey",
|
|
138
|
+
"apikey",
|
|
139
|
+
"accesskey",
|
|
140
|
+
"secretkey",
|
|
141
|
+
"authorization",
|
|
142
|
+
"cookie",
|
|
143
|
+
"dsn",
|
|
144
|
+
"connectionstring"
|
|
145
|
+
]);
|
|
146
|
+
/** What replaces the value of a key that names a credential. */
|
|
147
|
+
const REDACTED = "<redacted>";
|
|
148
|
+
/**
|
|
149
|
+
* Splits a key into lowercase words on separators, digits and camelCase boundaries.
|
|
150
|
+
*
|
|
151
|
+
* @param key - The key to split.
|
|
152
|
+
* @returns The words the key is built from.
|
|
153
|
+
*/
|
|
154
|
+
function words(key) {
|
|
155
|
+
return key.replaceAll(/([a-z\d])([A-Z])/g, "$1 $2").replaceAll(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).filter(Boolean);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* @param word - A single lowercase word, or two adjacent words joined.
|
|
159
|
+
* @returns Whether the word names a credential.
|
|
160
|
+
*/
|
|
161
|
+
function isSecretWord(word) {
|
|
162
|
+
return SECRET_WORDS.has(word) || word.endsWith("s") && SECRET_WORDS.has(word.slice(0, -1));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Tells whether a value stored under this key should be withheld.
|
|
166
|
+
*
|
|
167
|
+
* @param key - The key to judge.
|
|
168
|
+
* @returns True when the key names a credential.
|
|
169
|
+
*/
|
|
170
|
+
function isSecretKey(key) {
|
|
171
|
+
const parts = words(key);
|
|
172
|
+
return parts.some((word, index) => isSecretWord(word) || index > 0 && isSecretWord(parts[index - 1] + word));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Copies headers with credential-looking values withheld.
|
|
176
|
+
*
|
|
177
|
+
* @param headers - Headers as received from the transport.
|
|
178
|
+
* @returns Headers safe to keep for inspection.
|
|
179
|
+
*/
|
|
180
|
+
function redactHeaders(headers) {
|
|
181
|
+
const safe = {};
|
|
182
|
+
for (const [key, value] of Object.entries(headers)) safe[key] = isSecretKey(key) ? REDACTED : value;
|
|
183
|
+
return safe;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Renders a payload for inspection: JSON, with credential-looking fields
|
|
187
|
+
* withheld and the result capped.
|
|
188
|
+
*
|
|
189
|
+
* @param payload - The payload to render.
|
|
190
|
+
* @param maxBytes - Largest preview to keep, in bytes.
|
|
191
|
+
* @returns The preview, or a note when the payload cannot be rendered.
|
|
192
|
+
*/
|
|
193
|
+
function previewPayload(payload, maxBytes) {
|
|
194
|
+
let text;
|
|
195
|
+
try {
|
|
196
|
+
text = JSON.stringify(payload, (key, value) => key && isSecretKey(key) ? REDACTED : value, 2) ?? String(payload);
|
|
197
|
+
} catch {
|
|
198
|
+
return "<unserializable>";
|
|
199
|
+
}
|
|
200
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
201
|
+
if (size <= maxBytes) return text;
|
|
202
|
+
return `${text.slice(0, maxBytes)}\n… truncated, ${size} bytes in total`;
|
|
203
|
+
}
|
|
204
|
+
//#endregion
|
|
205
|
+
//#region src/Services/QueueManager.ts
|
|
206
|
+
/** Name a strategy is mounted under when none is given. */
|
|
207
|
+
const DEFAULT_STRATEGY = "default";
|
|
208
|
+
/** Manager-wide settings before anything is configured. */
|
|
209
|
+
const DEFAULTS = {
|
|
210
|
+
autoStart: true,
|
|
211
|
+
concurrency: 1,
|
|
212
|
+
onUnhandled: "ignore",
|
|
213
|
+
maxEvents: 50,
|
|
214
|
+
capturePayloads: false,
|
|
215
|
+
maxPayloadBytes: 4096
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Central entry point of the queue module.
|
|
219
|
+
*
|
|
220
|
+
* The manager owns the mounted strategies, the handlers registered by the
|
|
221
|
+
* decorators and everything that has to behave the same across transports:
|
|
222
|
+
* routing a job to its handler, validating payloads, retries, timeouts,
|
|
223
|
+
* lifecycle hooks and the counters the devtools read.
|
|
224
|
+
*
|
|
225
|
+
* @example
|
|
226
|
+
* ```ts
|
|
227
|
+
* container.bind(QueueManager);
|
|
228
|
+
*
|
|
229
|
+
* const queue = container.get(QueueManager);
|
|
230
|
+
* await queue.mount({ strategy: MemoryStrategy });
|
|
231
|
+
*
|
|
232
|
+
* await queue.add({ queue: 'emails', job: 'welcome', payload: { userId: '1' } });
|
|
233
|
+
* ```
|
|
234
|
+
*/
|
|
235
|
+
var QueueManager = class {
|
|
236
|
+
/** Container instance */
|
|
237
|
+
gContainer;
|
|
238
|
+
/** Logger instance */
|
|
239
|
+
gLogger;
|
|
240
|
+
/** Validation provider, needed only by handlers declaring a schema */
|
|
241
|
+
gValidation;
|
|
242
|
+
/** Mounted strategies, indexed by mount name */
|
|
243
|
+
fStrategies = /* @__PURE__ */ new Map();
|
|
244
|
+
/** Registered handlers, indexed by strategy, queue and job name */
|
|
245
|
+
fRegistrations = /* @__PURE__ */ new Map();
|
|
246
|
+
/** Registered lifecycle hooks */
|
|
247
|
+
fHooks = {
|
|
248
|
+
completed: [],
|
|
249
|
+
failed: []
|
|
250
|
+
};
|
|
251
|
+
/** Running consumers, indexed by strategy and queue */
|
|
252
|
+
fConsumers = /* @__PURE__ */ new Map();
|
|
253
|
+
/** Per-queue counters, indexed by strategy and queue */
|
|
254
|
+
fMetrics = /* @__PURE__ */ new Map();
|
|
255
|
+
/** Recently processed jobs, newest first */
|
|
256
|
+
fEvents = [];
|
|
257
|
+
/** Manager-wide settings */
|
|
258
|
+
fDefaults = { ...DEFAULTS };
|
|
259
|
+
/** Whether consumers should be running */
|
|
260
|
+
fStarted = false;
|
|
261
|
+
/** Serializes start, stop and mount work so consumers are never started twice */
|
|
262
|
+
fTail = Promise.resolve();
|
|
263
|
+
/** Retries waiting for their backoff to elapse */
|
|
264
|
+
fPendingRetries = /* @__PURE__ */ new Set();
|
|
265
|
+
/** Listeners following the jobs this manager processes */
|
|
266
|
+
fListeners = /* @__PURE__ */ new Set();
|
|
267
|
+
/**
|
|
268
|
+
* Whether consumers have been started.
|
|
269
|
+
*
|
|
270
|
+
* @returns {boolean} True once {@link QueueManager.start} ran
|
|
271
|
+
*/
|
|
272
|
+
get started() {
|
|
273
|
+
return this.fStarted;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Currently configured settings.
|
|
277
|
+
*
|
|
278
|
+
* @returns {Required<QueueTypes.Defaults>} A copy of the active settings
|
|
279
|
+
*/
|
|
280
|
+
get defaults() {
|
|
281
|
+
return { ...this.fDefaults };
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Sets manager-wide settings. Calling it repeatedly merges into the existing
|
|
285
|
+
* ones, and settings passed per job or per handler always win.
|
|
286
|
+
*
|
|
287
|
+
* @param {QueueTypes.Defaults} defaults - Settings to apply
|
|
288
|
+
* @returns {void}
|
|
289
|
+
*/
|
|
290
|
+
configure(defaults) {
|
|
291
|
+
for (const [key, value] of Object.entries(defaults)) if (value !== void 0) this.fDefaults[key] = value;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Mounts a strategy under a name. Every other call refers to it by that name,
|
|
295
|
+
* so a single application can talk to several brokers at once.
|
|
296
|
+
*
|
|
297
|
+
* The strategy is resolved through the container, connects on first use, and
|
|
298
|
+
* starts consuming right away when the manager is already running.
|
|
299
|
+
*
|
|
300
|
+
* @template T - Strategy being mounted
|
|
301
|
+
* @param {QueueTypes.Mount<T>} params - Mount name, strategy class and its init options
|
|
302
|
+
* @returns {Promise<void>} Resolves once the strategy is mounted
|
|
303
|
+
*/
|
|
304
|
+
async mount({ name, strategy, initOptions }) {
|
|
305
|
+
const mountName = name ?? DEFAULT_STRATEGY;
|
|
306
|
+
this.fStrategies.set(mountName, {
|
|
307
|
+
name: mountName,
|
|
308
|
+
strategy: this.gContainer.resolve(strategy),
|
|
309
|
+
initOptions
|
|
310
|
+
});
|
|
311
|
+
if (this.fStarted) await this.enqueue(() => this.startMount(mountName));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Stops and closes a mounted strategy, and forgets it.
|
|
315
|
+
* Handlers registered for it stay registered, so mounting it again resumes them.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} [name] - Mount name, defaults to `default`
|
|
318
|
+
* @returns {Promise<void>} Resolves once the strategy is closed
|
|
319
|
+
*/
|
|
320
|
+
async unmount(name = DEFAULT_STRATEGY) {
|
|
321
|
+
const mount = this.fStrategies.get(name);
|
|
322
|
+
if (!mount) return;
|
|
323
|
+
this.fStrategies.delete(name);
|
|
324
|
+
await this.enqueue(async () => {
|
|
325
|
+
await this.stopConsumers(name);
|
|
326
|
+
await this.closeStrategy(mount);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Returns a mounted strategy, for the rare case a transport-specific API is needed.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} [name] - Mount name, defaults to `default`
|
|
333
|
+
* @returns {QueueStrategy<unknown> | undefined} The strategy, or undefined when nothing is mounted under that name
|
|
334
|
+
*/
|
|
335
|
+
getStrategy(name = DEFAULT_STRATEGY) {
|
|
336
|
+
return this.fStrategies.get(name)?.strategy;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Adds a single job to a queue.
|
|
340
|
+
*
|
|
341
|
+
* @template TQueue - Queue the job is added to
|
|
342
|
+
* @template TJob - Name of the job
|
|
343
|
+
* @param {QueueTypes.AddRequest<TQueue, TJob>} request - Queue, job name, payload and per-job options
|
|
344
|
+
* @returns {Promise<QueueTypes.JobRef>} Reference to the published job
|
|
345
|
+
* @throws {QueueError} When no strategy is mounted under the requested name, or the job cannot be published
|
|
346
|
+
*/
|
|
347
|
+
async add(request) {
|
|
348
|
+
const mount = await this.resolveMount(request.strategy, "add");
|
|
349
|
+
const queue = request.queue;
|
|
350
|
+
const job = request.job;
|
|
351
|
+
return tracePublish({
|
|
352
|
+
strategy: mount.name,
|
|
353
|
+
queue,
|
|
354
|
+
job
|
|
355
|
+
}, 1, async () => {
|
|
356
|
+
try {
|
|
357
|
+
const ref = await mount.strategy.publish(this.createPublishRequest(queue, job, request.payload, request.options));
|
|
358
|
+
this.metricsFor(mount.name, queue).published++;
|
|
359
|
+
return ref;
|
|
360
|
+
} catch (error) {
|
|
361
|
+
throw toQueueError(error, "Failed to publish job", "add", {
|
|
362
|
+
strategy: mount.name,
|
|
363
|
+
queue,
|
|
364
|
+
job
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Adds many jobs of the same kind to a queue, using the transport's batch API
|
|
371
|
+
* when it has one.
|
|
372
|
+
*
|
|
373
|
+
* @template TQueue - Queue the jobs are added to
|
|
374
|
+
* @template TJob - Name of the jobs
|
|
375
|
+
* @param {QueueTypes.AddManyRequest<TQueue, TJob>} request - Queue, job name, payloads and per-job options
|
|
376
|
+
* @returns {Promise<QueueTypes.JobRef[]>} References to the published jobs, in the same order
|
|
377
|
+
* @throws {QueueError} When no strategy is mounted under the requested name, or the jobs cannot be published
|
|
378
|
+
*/
|
|
379
|
+
async addMany(request) {
|
|
380
|
+
if (request.payloads.length === 0) return [];
|
|
381
|
+
const mount = await this.resolveMount(request.strategy, "addMany");
|
|
382
|
+
const queue = request.queue;
|
|
383
|
+
const job = request.job;
|
|
384
|
+
return tracePublish({
|
|
385
|
+
strategy: mount.name,
|
|
386
|
+
queue,
|
|
387
|
+
job
|
|
388
|
+
}, request.payloads.length, async () => {
|
|
389
|
+
try {
|
|
390
|
+
const refs = await mount.strategy.publishMany(request.payloads.map((payload) => this.createPublishRequest(queue, job, payload, request.options)));
|
|
391
|
+
this.metricsFor(mount.name, queue).published += refs.length;
|
|
392
|
+
return refs;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
throw toQueueError(error, "Failed to publish jobs", "addMany", {
|
|
395
|
+
strategy: mount.name,
|
|
396
|
+
queue,
|
|
397
|
+
job
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Registers a handler for a single job, or for every job the queue has no
|
|
404
|
+
* other handler for when registered under `*`. The decorators call this, and so
|
|
405
|
+
* can application code building its consumers dynamically.
|
|
406
|
+
*
|
|
407
|
+
* When the manager is already running, the queue starts being consumed right away.
|
|
408
|
+
*
|
|
409
|
+
* @param {QueueTypes.Registration} registration - Queue, job name, handler and its options
|
|
410
|
+
* @returns {void}
|
|
411
|
+
* @throws {QueueError} When a handler for the same job is already registered
|
|
412
|
+
*/
|
|
413
|
+
registerConsumer(registration) {
|
|
414
|
+
const key = this.consumerKey(registration.strategy, registration.queue, registration.job);
|
|
415
|
+
const existing = this.fRegistrations.get(key);
|
|
416
|
+
if (existing) throw new QueueError(`Job "${registration.job}" of queue "${registration.queue}" already has a handler`, "register", void 0, {
|
|
417
|
+
registered: existing.source,
|
|
418
|
+
duplicate: registration.source
|
|
419
|
+
}, false);
|
|
420
|
+
this.fRegistrations.set(key, registration);
|
|
421
|
+
this.metricsFor(registration.strategy, registration.queue);
|
|
422
|
+
if (this.fStarted) this.enqueue(() => this.startQueue(registration.strategy, registration.queue));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Registers a lifecycle hook for a queue.
|
|
426
|
+
*
|
|
427
|
+
* @param {'completed' | 'failed'} event - Event to listen for
|
|
428
|
+
* @param {QueueTypes.HookRegistration} registration - Queue, optional job filter and the hook itself
|
|
429
|
+
* @returns {void}
|
|
430
|
+
*/
|
|
431
|
+
registerHook(event, registration) {
|
|
432
|
+
this.fHooks[event].push(registration);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Removes a registered handler. The queue stops being consumed once its last
|
|
436
|
+
* handler is gone.
|
|
437
|
+
*
|
|
438
|
+
* @param {Pick<QueueTypes.Registration, 'strategy' | 'queue' | 'job'>} registration - Handler to remove
|
|
439
|
+
* @returns {void}
|
|
440
|
+
*/
|
|
441
|
+
unregisterConsumer(registration) {
|
|
442
|
+
if (!this.fRegistrations.delete(this.consumerKey(registration.strategy, registration.queue, registration.job))) return;
|
|
443
|
+
if (![...this.fRegistrations.values()].some((entry) => entry.strategy === registration.strategy && entry.queue === registration.queue)) this.enqueue(() => this.stopQueue(registration.strategy, registration.queue));
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Removes a registered lifecycle hook.
|
|
447
|
+
*
|
|
448
|
+
* @param {'completed' | 'failed'} event - Event the hook listens for
|
|
449
|
+
* @param {QueueTypes.HookRegistration} registration - The very registration that was registered
|
|
450
|
+
* @returns {void}
|
|
451
|
+
*/
|
|
452
|
+
unregisterHook(event, registration) {
|
|
453
|
+
this.fHooks[event] = this.fHooks[event].filter((entry) => entry !== registration);
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Connects every mounted strategy and starts consuming every queue that has
|
|
457
|
+
* a handler. Safe to call more than once, queues already running are left alone.
|
|
458
|
+
*
|
|
459
|
+
* @returns {Promise<void>} Resolves once every consumer is running
|
|
460
|
+
*/
|
|
461
|
+
async start() {
|
|
462
|
+
this.fStarted = true;
|
|
463
|
+
return this.enqueue(async () => {
|
|
464
|
+
const mounted = [...this.fStrategies.keys()];
|
|
465
|
+
for (const name of mounted) await this.startMount(name);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Stops every consumer while keeping the connections open, so the application
|
|
470
|
+
* can still publish. In-flight jobs are awaited.
|
|
471
|
+
*
|
|
472
|
+
* @returns {Promise<void>} Resolves once every consumer is stopped
|
|
473
|
+
*/
|
|
474
|
+
async stop() {
|
|
475
|
+
this.fStarted = false;
|
|
476
|
+
return this.enqueue(async () => {
|
|
477
|
+
const mounted = [...this.fStrategies.keys()];
|
|
478
|
+
for (const name of mounted) await this.stopConsumers(name);
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Stops every consumer and closes every connection.
|
|
483
|
+
*
|
|
484
|
+
* @returns {Promise<void>} Resolves once everything is closed
|
|
485
|
+
*/
|
|
486
|
+
async close() {
|
|
487
|
+
this.fStarted = false;
|
|
488
|
+
return this.enqueue(async () => {
|
|
489
|
+
for (const [name, mount] of this.fStrategies) {
|
|
490
|
+
await this.stopConsumers(name);
|
|
491
|
+
await this.closeStrategy(mount);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Waits for the work the manager scheduled in the background: starting or
|
|
497
|
+
* stopping consumers, and retries waiting for their backoff to elapse.
|
|
498
|
+
*
|
|
499
|
+
* @returns {Promise<void>} Resolves once nothing is pending
|
|
500
|
+
*/
|
|
501
|
+
async drain() {
|
|
502
|
+
await this.fTail;
|
|
503
|
+
while (this.fPendingRetries.size > 0) await Promise.all(this.fPendingRetries);
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Reads live counters of a queue straight from the transport.
|
|
507
|
+
*
|
|
508
|
+
* @param {object} params - Queue to read and the strategy to read it from
|
|
509
|
+
* @param {string} params.queue - Queue to read
|
|
510
|
+
* @param {string} [params.strategy] - Mount name, defaults to `default`
|
|
511
|
+
* @returns {Promise<QueueTypes.QueueStats>} The counters the transport reports, empty when it reports none
|
|
512
|
+
*/
|
|
513
|
+
async stats({ queue, strategy }) {
|
|
514
|
+
const mount = this.fStrategies.get(strategy ?? DEFAULT_STRATEGY);
|
|
515
|
+
if (!mount?.strategy.stats) return {};
|
|
516
|
+
try {
|
|
517
|
+
return await mount.strategy.stats(queue);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
this.gLogger?.warn("Vercube/QueueManager::stats", error);
|
|
520
|
+
return {};
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Follows every job this manager finishes, as it finishes it.
|
|
525
|
+
*
|
|
526
|
+
* The listener is called with the same event that goes into the inspection
|
|
527
|
+
* buffer, right after the job settled, so a listener sees a queue live instead
|
|
528
|
+
* of polling it. A listener that throws is reported and kept.
|
|
529
|
+
*
|
|
530
|
+
* @param {QueueTypes.JobListener} listener - Called once per processed job
|
|
531
|
+
* @returns {() => void} Removes the listener again
|
|
532
|
+
*/
|
|
533
|
+
subscribe(listener) {
|
|
534
|
+
this.fListeners.add(listener);
|
|
535
|
+
return () => {
|
|
536
|
+
this.fListeners.delete(listener);
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Shows what a queue is holding, without consuming any of it.
|
|
541
|
+
*
|
|
542
|
+
* Only transports that can be read without side effects support this, which
|
|
543
|
+
* they report as the `peek` capability. Everything else returns nothing rather
|
|
544
|
+
* than perturbing the queue: taking delivery of a message to look at it would
|
|
545
|
+
* change delivery counts and compete with the running consumer.
|
|
546
|
+
*
|
|
547
|
+
* @param {object} params - Queue to look at and how much of it to read
|
|
548
|
+
* @param {string} params.queue - Queue to look at
|
|
549
|
+
* @param {string} [params.strategy] - Mount name, defaults to `default`
|
|
550
|
+
* @param {number} [params.limit] - How many messages to read, defaults to 20
|
|
551
|
+
* @param {QueueTypes.PeekState[]} [params.states] - States to read, defaults to waiting, delayed and failed
|
|
552
|
+
* @returns {Promise<QueueTypes.PeekedJob[]>} The messages found, with their payloads rendered
|
|
553
|
+
* @throws {QueueError} When the transport fails to answer
|
|
554
|
+
*/
|
|
555
|
+
async peek({ queue, strategy, limit = 20, states = [
|
|
556
|
+
"waiting",
|
|
557
|
+
"delayed",
|
|
558
|
+
"failed"
|
|
559
|
+
] }) {
|
|
560
|
+
const mount = this.fStrategies.get(strategy ?? DEFAULT_STRATEGY);
|
|
561
|
+
if (!mount?.strategy.peek) return [];
|
|
562
|
+
return (await mount.strategy.peek({
|
|
563
|
+
queue,
|
|
564
|
+
limit: Math.max(1, limit),
|
|
565
|
+
states
|
|
566
|
+
})).map(({ payload, headers, ...message }) => ({
|
|
567
|
+
...message,
|
|
568
|
+
payload: previewPayload(payload, this.fDefaults.maxPayloadBytes),
|
|
569
|
+
headers: redactHeaders(headers)
|
|
570
|
+
}));
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Describes what the module currently holds: mounted strategies, registered
|
|
574
|
+
* handlers, per-queue counters and the last processed jobs. Used by the devtools.
|
|
575
|
+
*
|
|
576
|
+
* @returns {QueueTypes.Snapshot} The current state of the queue module
|
|
577
|
+
*/
|
|
578
|
+
inspect() {
|
|
579
|
+
const strategies = [...this.fStrategies.values()].map((mount) => ({
|
|
580
|
+
name: mount.name,
|
|
581
|
+
transport: mount.strategy.transport,
|
|
582
|
+
driver: mount.strategy.constructor?.name ?? "unknown",
|
|
583
|
+
status: this.statusOf(mount),
|
|
584
|
+
capabilities: mount.strategy.capabilities,
|
|
585
|
+
error: mount.error?.message
|
|
586
|
+
}));
|
|
587
|
+
const consumers = [...this.fRegistrations.values()].map((registration) => ({
|
|
588
|
+
strategy: registration.strategy,
|
|
589
|
+
queue: registration.queue,
|
|
590
|
+
job: registration.job,
|
|
591
|
+
source: registration.source,
|
|
592
|
+
attempts: registration.options.attempts ?? 1,
|
|
593
|
+
timeout: registration.options.timeout,
|
|
594
|
+
validated: Boolean(registration.options.schema),
|
|
595
|
+
running: this.fConsumers.has(this.queueKey(registration.strategy, registration.queue))
|
|
596
|
+
}));
|
|
597
|
+
return {
|
|
598
|
+
started: this.fStarted,
|
|
599
|
+
strategies,
|
|
600
|
+
consumers,
|
|
601
|
+
metrics: [...this.fMetrics.values()].map((metrics) => ({ ...metrics })),
|
|
602
|
+
events: this.fEvents.map((event) => ({ ...event }))
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Processes a single job: routes it to its handler, validates the payload,
|
|
607
|
+
* enforces the timeout, runs the lifecycle hooks and applies the retry policy.
|
|
608
|
+
*
|
|
609
|
+
* The handler registered for the job name wins, and a handler registered under
|
|
610
|
+
* `*` picks up whatever is left.
|
|
611
|
+
*
|
|
612
|
+
* Rejecting tells the strategy the job failed for good, so it can dead-letter it.
|
|
613
|
+
*
|
|
614
|
+
* @param {string} strategy - Mount name the job came from
|
|
615
|
+
* @param {string} queue - Queue the job came from
|
|
616
|
+
* @param {QueueTypes.IncomingJob} incoming - The job as received from the transport
|
|
617
|
+
* @returns {Promise<void>} Resolves when the job may be acknowledged
|
|
618
|
+
* @throws {Error} When the job failed and the transport has to deal with it
|
|
619
|
+
*/
|
|
620
|
+
process(strategy, queue, incoming) {
|
|
621
|
+
return traceProcess({
|
|
622
|
+
strategy,
|
|
623
|
+
queue,
|
|
624
|
+
job: incoming.job,
|
|
625
|
+
attempt: incoming.attempt
|
|
626
|
+
}, incoming.headers, () => this.processJob(strategy, queue, incoming));
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Does the work {@link QueueManager.process} traces.
|
|
630
|
+
*
|
|
631
|
+
* @param {string} strategy - Mount name the job came from
|
|
632
|
+
* @param {string} queue - Queue the job came from
|
|
633
|
+
* @param {QueueTypes.IncomingJob} incoming - The job as received from the transport
|
|
634
|
+
* @returns {Promise<void>} Resolves when the job may be acknowledged
|
|
635
|
+
* @throws {Error} When the job failed and the transport has to deal with it
|
|
636
|
+
*/
|
|
637
|
+
async processJob(strategy, queue, incoming) {
|
|
638
|
+
const metrics = this.metricsFor(strategy, queue);
|
|
639
|
+
const registration = this.fRegistrations.get(this.consumerKey(strategy, queue, incoming.job)) ?? this.fRegistrations.get(this.consumerKey(strategy, queue, "*"));
|
|
640
|
+
if (!registration) {
|
|
641
|
+
metrics.unhandled++;
|
|
642
|
+
this.record({
|
|
643
|
+
strategy,
|
|
644
|
+
queue,
|
|
645
|
+
job: incoming.job,
|
|
646
|
+
id: incoming.id,
|
|
647
|
+
attempt: incoming.attempt,
|
|
648
|
+
status: "unhandled",
|
|
649
|
+
duration: 0,
|
|
650
|
+
error: {
|
|
651
|
+
name: "QueueError",
|
|
652
|
+
message: `No handler is registered for job "${incoming.job}"`,
|
|
653
|
+
operation: "process",
|
|
654
|
+
retryable: false
|
|
655
|
+
}
|
|
656
|
+
}, incoming.payload, incoming.headers);
|
|
657
|
+
this.gLogger?.warn(`Vercube/QueueManager::No handler for job "${incoming.job}" on queue "${queue}"`);
|
|
658
|
+
countOutcome({
|
|
659
|
+
queue,
|
|
660
|
+
job: incoming.job
|
|
661
|
+
}, "unhandled");
|
|
662
|
+
if (this.fDefaults.onUnhandled === "fail") throw new QueueError(`No handler registered for job "${incoming.job}"`, "process", void 0, {
|
|
663
|
+
strategy,
|
|
664
|
+
queue
|
|
665
|
+
}, false);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const attempts = Math.min(incoming.attempts ?? readNumericHeader(incoming.headers["x-attempts"], registration.options.attempts ?? 1, 50), 50);
|
|
669
|
+
const context = this.createContext(strategy, queue, incoming, attempts);
|
|
670
|
+
const started = performance.now();
|
|
671
|
+
metrics.active++;
|
|
672
|
+
try {
|
|
673
|
+
context.payload = await this.validate(registration, incoming.payload, context.job);
|
|
674
|
+
await this.runHandler(registration, context);
|
|
675
|
+
metrics.processed++;
|
|
676
|
+
countOutcome({
|
|
677
|
+
queue,
|
|
678
|
+
job: context.job
|
|
679
|
+
}, "completed");
|
|
680
|
+
this.record({
|
|
681
|
+
strategy,
|
|
682
|
+
queue,
|
|
683
|
+
job: context.job,
|
|
684
|
+
id: context.id,
|
|
685
|
+
attempt: context.attempt,
|
|
686
|
+
status: "completed",
|
|
687
|
+
duration: performance.now() - started,
|
|
688
|
+
source: registration.source
|
|
689
|
+
});
|
|
690
|
+
await this.runHooks("completed", registration, context);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
await this.handleFailure(error, registration, context, performance.now() - started);
|
|
693
|
+
} finally {
|
|
694
|
+
metrics.active--;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Applies the failure policy of a job: hooks first, then either a retry the
|
|
699
|
+
* manager schedules itself, or a rejection handing the job back to the transport.
|
|
700
|
+
*
|
|
701
|
+
* @param {Error} error - Error the attempt failed with
|
|
702
|
+
* @param {QueueTypes.Registration} registration - Handler that failed
|
|
703
|
+
* @param {QueueTypes.JobContext} context - Context of the failed attempt
|
|
704
|
+
* @param {number} duration - How long the attempt took, in milliseconds
|
|
705
|
+
* @returns {Promise<void>} Resolves once a retry has been scheduled
|
|
706
|
+
* @throws {Error} The original error, when the transport has to deal with the failure
|
|
707
|
+
*/
|
|
708
|
+
async handleFailure(error, registration, context, duration) {
|
|
709
|
+
const metrics = this.metricsFor(registration.strategy, registration.queue);
|
|
710
|
+
metrics.failed++;
|
|
711
|
+
metrics.lastError = error.message;
|
|
712
|
+
await this.runHooks("failed", registration, context, error);
|
|
713
|
+
const mount = this.fStrategies.get(registration.strategy);
|
|
714
|
+
const canRetry = (!(error instanceof QueueError) || error.retryable) && context.attempt < context.attempts;
|
|
715
|
+
if (mount?.strategy.capabilities.retries || !canRetry) {
|
|
716
|
+
countOutcome({
|
|
717
|
+
queue: registration.queue,
|
|
718
|
+
job: context.job
|
|
719
|
+
}, "failed");
|
|
720
|
+
this.record({
|
|
721
|
+
strategy: registration.strategy,
|
|
722
|
+
queue: registration.queue,
|
|
723
|
+
job: context.job,
|
|
724
|
+
id: context.id,
|
|
725
|
+
attempt: context.attempt,
|
|
726
|
+
status: "failed",
|
|
727
|
+
duration,
|
|
728
|
+
error: this.describeFailure(error),
|
|
729
|
+
source: registration.source
|
|
730
|
+
}, context.payload, context.headers);
|
|
731
|
+
throw error;
|
|
732
|
+
}
|
|
733
|
+
metrics.retried++;
|
|
734
|
+
countOutcome({
|
|
735
|
+
queue: registration.queue,
|
|
736
|
+
job: context.job
|
|
737
|
+
}, "retried");
|
|
738
|
+
this.record({
|
|
739
|
+
strategy: registration.strategy,
|
|
740
|
+
queue: registration.queue,
|
|
741
|
+
job: context.job,
|
|
742
|
+
id: context.id,
|
|
743
|
+
attempt: context.attempt,
|
|
744
|
+
status: "retried",
|
|
745
|
+
duration,
|
|
746
|
+
error: this.describeFailure(error),
|
|
747
|
+
source: registration.source
|
|
748
|
+
}, context.payload, context.headers);
|
|
749
|
+
await this.scheduleRetry(registration, context, error);
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Publishes the job again for its next attempt, waiting for the backoff first.
|
|
753
|
+
*
|
|
754
|
+
* The wait is handed to the transport when it can delay jobs, and kept on a
|
|
755
|
+
* timer otherwise, so the handler slot is released immediately either way.
|
|
756
|
+
*
|
|
757
|
+
* @param {QueueTypes.Registration} registration - Handler that failed
|
|
758
|
+
* @param {QueueTypes.JobContext} context - Context of the failed attempt
|
|
759
|
+
* @param {Error} error - Error the attempt failed with, reported when the retry cannot be published
|
|
760
|
+
* @returns {Promise<void>} Resolves once the retry exists, or once it has been scheduled on a timer
|
|
761
|
+
* @throws {Error} When the retry could be published right away and the transport refused it
|
|
762
|
+
*/
|
|
763
|
+
scheduleRetry(registration, context, error) {
|
|
764
|
+
const wait = resolveBackoff(registration.options.backoff, context.attempt);
|
|
765
|
+
const native = this.fStrategies.get(registration.strategy)?.strategy.capabilities.delay ?? false;
|
|
766
|
+
const headers = {
|
|
767
|
+
...context.headers,
|
|
768
|
+
[ATTEMPT_HEADER]: String(context.attempt + 1),
|
|
769
|
+
[ATTEMPTS_HEADER]: String(context.attempts)
|
|
770
|
+
};
|
|
771
|
+
const priority = readNumericHeader(headers[PRIORITY_HEADER], 0);
|
|
772
|
+
const options = {
|
|
773
|
+
...native ? { delay: wait } : {},
|
|
774
|
+
...headers["x-key"] === void 0 ? {} : { key: headers[KEY_HEADER] },
|
|
775
|
+
...priority === 0 ? {} : { priority }
|
|
776
|
+
};
|
|
777
|
+
const publish = () => {
|
|
778
|
+
const mount = this.fStrategies.get(registration.strategy);
|
|
779
|
+
return mount ? mount.strategy.publish({
|
|
780
|
+
queue: registration.queue,
|
|
781
|
+
job: context.job,
|
|
782
|
+
payload: context.payload,
|
|
783
|
+
headers,
|
|
784
|
+
options
|
|
785
|
+
}) : Promise.reject(new QueueError(`Strategy "${registration.strategy}" is no longer mounted`, "publish", void 0, {
|
|
786
|
+
queue: registration.queue,
|
|
787
|
+
job: context.job
|
|
788
|
+
}, false));
|
|
789
|
+
};
|
|
790
|
+
if (native || wait === 0) return Promise.resolve(publish()).then(() => void 0);
|
|
791
|
+
const pending = delay(wait).then(publish).then(() => void 0, (publishError) => {
|
|
792
|
+
this.gLogger?.error("Vercube/QueueManager::Failed to schedule retry", publishError, error);
|
|
793
|
+
this.record({
|
|
794
|
+
strategy: registration.strategy,
|
|
795
|
+
queue: registration.queue,
|
|
796
|
+
job: context.job,
|
|
797
|
+
id: context.id,
|
|
798
|
+
attempt: context.attempt,
|
|
799
|
+
status: "failed",
|
|
800
|
+
duration: 0,
|
|
801
|
+
error: this.describeFailure(publishError),
|
|
802
|
+
source: registration.source
|
|
803
|
+
}, context.payload, context.headers);
|
|
804
|
+
countOutcome({
|
|
805
|
+
queue: registration.queue,
|
|
806
|
+
job: context.job
|
|
807
|
+
}, "lost");
|
|
808
|
+
});
|
|
809
|
+
this.fPendingRetries.add(pending);
|
|
810
|
+
pending.finally(() => this.fPendingRetries.delete(pending));
|
|
811
|
+
return Promise.resolve();
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Runs a handler, failing the attempt when it outlives its timeout.
|
|
815
|
+
* A timed out handler is not interrupted, it is only stopped being waited for.
|
|
816
|
+
*
|
|
817
|
+
* @param {QueueTypes.Registration} registration - Handler to run
|
|
818
|
+
* @param {QueueTypes.JobContext} context - Context of the attempt
|
|
819
|
+
* @returns {Promise<void>} Resolves once the handler returned
|
|
820
|
+
* @throws {QueueError} When the handler outlives its timeout
|
|
821
|
+
*/
|
|
822
|
+
async runHandler(registration, context) {
|
|
823
|
+
const run = Promise.resolve(registration.handler(context.payload, context));
|
|
824
|
+
const timeout = registration.options.timeout;
|
|
825
|
+
if (!timeout || timeout <= 0) return run;
|
|
826
|
+
let timer;
|
|
827
|
+
run.catch(() => void 0);
|
|
828
|
+
try {
|
|
829
|
+
await Promise.race([run, new Promise((_, reject) => {
|
|
830
|
+
timer = setTimeout(() => {
|
|
831
|
+
reject(new QueueError(`Job "${registration.job}" timed out after ${timeout}ms`, "timeout", void 0, {
|
|
832
|
+
queue: registration.queue,
|
|
833
|
+
id: context.id
|
|
834
|
+
}));
|
|
835
|
+
}, timeout);
|
|
836
|
+
})]);
|
|
837
|
+
} finally {
|
|
838
|
+
clearTimeout(timer);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Validates a payload against the handler's schema, when it declares one.
|
|
843
|
+
*
|
|
844
|
+
* @param {QueueTypes.Registration} registration - Handler the payload is meant for
|
|
845
|
+
* @param {unknown} payload - Payload as received from the transport
|
|
846
|
+
* @param {string} job - Name of the job being processed, which a wildcard handler does not know upfront
|
|
847
|
+
* @returns {Promise<unknown>} The validated payload, as returned by the schema
|
|
848
|
+
* @throws {QueueError} When the payload does not match the schema, or no validation provider is bound
|
|
849
|
+
*/
|
|
850
|
+
async validate(registration, payload, job) {
|
|
851
|
+
const schema = registration.options.schema;
|
|
852
|
+
if (!schema) return payload;
|
|
853
|
+
if (!this.gValidation) throw new QueueError("A job declares a schema but no ValidationProvider is bound in the container", "validate", void 0, {
|
|
854
|
+
queue: registration.queue,
|
|
855
|
+
job
|
|
856
|
+
}, false);
|
|
857
|
+
const result = await this.gValidation.validate(schema, payload);
|
|
858
|
+
if (result.issues) throw new QueueError(`Payload of job "${job}" failed validation`, "validate", void 0, {
|
|
859
|
+
queue: registration.queue,
|
|
860
|
+
issues: result.issues
|
|
861
|
+
}, false);
|
|
862
|
+
return result.value;
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Runs the hooks registered for a queue. A hook filtered by job name runs for
|
|
866
|
+
* that job only, unless the filter is `*`. A throwing hook is logged and never
|
|
867
|
+
* changes the outcome of the job.
|
|
868
|
+
*
|
|
869
|
+
* @param {'completed' | 'failed'} event - Event being reported
|
|
870
|
+
* @param {QueueTypes.Registration} registration - Handler the event belongs to
|
|
871
|
+
* @param {QueueTypes.JobContext} context - Context of the attempt
|
|
872
|
+
* @param {Error} [error] - Error of the attempt, for the `failed` event
|
|
873
|
+
* @returns {Promise<void>} Resolves once every hook settled
|
|
874
|
+
*/
|
|
875
|
+
async runHooks(event, registration, context, error) {
|
|
876
|
+
for (const hook of this.fHooks[event]) {
|
|
877
|
+
if (hook.strategy !== registration.strategy || hook.queue !== registration.queue) continue;
|
|
878
|
+
if (hook.job && hook.job !== "*" && hook.job !== context.job) continue;
|
|
879
|
+
try {
|
|
880
|
+
await (event === "failed" ? hook.hook(error, context) : hook.hook(context));
|
|
881
|
+
} catch (hookError) {
|
|
882
|
+
this.gLogger?.error(`Vercube/QueueManager::Hook ${hook.source} threw`, hookError);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Connects a mounted strategy and starts consuming every queue it has handlers for.
|
|
888
|
+
* Failures are logged and leave the other mounts untouched.
|
|
889
|
+
*
|
|
890
|
+
* @param {string} name - Mount name
|
|
891
|
+
* @returns {Promise<void>} Resolves once the mount is running
|
|
892
|
+
*/
|
|
893
|
+
async startMount(name) {
|
|
894
|
+
const mount = this.fStrategies.get(name);
|
|
895
|
+
if (!mount) return;
|
|
896
|
+
try {
|
|
897
|
+
await this.ensureReady(mount);
|
|
898
|
+
} catch {
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
const queues = new Set([...this.fRegistrations.values()].filter((entry) => entry.strategy === name).map((entry) => entry.queue));
|
|
902
|
+
for (const queue of queues) await this.startQueue(name, queue);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Starts consuming a single queue, unless it is already being consumed.
|
|
906
|
+
*
|
|
907
|
+
* @param {string} strategy - Mount name
|
|
908
|
+
* @param {string} queue - Queue to consume
|
|
909
|
+
* @returns {Promise<void>} Resolves once the consumer is running
|
|
910
|
+
*/
|
|
911
|
+
async startQueue(strategy, queue) {
|
|
912
|
+
const key = this.queueKey(strategy, queue);
|
|
913
|
+
const mount = this.fStrategies.get(strategy);
|
|
914
|
+
if (!mount || !this.fStarted) return;
|
|
915
|
+
const registrations = [...this.fRegistrations.values()].filter((entry) => entry.strategy === strategy && entry.queue === queue);
|
|
916
|
+
if (registrations.length === 0) return;
|
|
917
|
+
const concurrency = Math.max(this.fDefaults.concurrency, ...registrations.map((entry) => entry.concurrency ?? 0));
|
|
918
|
+
const running = this.fConsumers.get(key);
|
|
919
|
+
if (running) {
|
|
920
|
+
if (running.concurrency >= concurrency) return;
|
|
921
|
+
await this.stopQueue(strategy, queue);
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
await this.consumeQueue(mount, queue, concurrency);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
this.gLogger?.error(`Vercube/QueueManager::Failed to consume queue "${queue}"`, error);
|
|
927
|
+
if (!running) return;
|
|
928
|
+
try {
|
|
929
|
+
await this.consumeQueue(mount, queue, running.concurrency);
|
|
930
|
+
} catch (restoreError) {
|
|
931
|
+
this.gLogger?.error(`Vercube/QueueManager::Failed to restore the consumer of "${queue}"`, restoreError);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Starts consuming a queue at a given concurrency and records the handle.
|
|
937
|
+
*
|
|
938
|
+
* @param {QueueTypes.MountedStrategy} mount - Mount the queue lives on
|
|
939
|
+
* @param {string} queue - Queue to consume
|
|
940
|
+
* @param {number} concurrency - How many jobs the transport may hand over at once
|
|
941
|
+
* @returns {Promise<void>} Resolves once the transport is delivering
|
|
942
|
+
* @throws {Error} When the strategy cannot start consuming
|
|
943
|
+
*/
|
|
944
|
+
async consumeQueue(mount, queue, concurrency) {
|
|
945
|
+
await this.ensureReady(mount);
|
|
946
|
+
const handle = await mount.strategy.consume({
|
|
947
|
+
queue,
|
|
948
|
+
concurrency,
|
|
949
|
+
dispatch: (job) => this.process(mount.name, queue, job)
|
|
950
|
+
});
|
|
951
|
+
this.fConsumers.set(this.queueKey(mount.name, queue), {
|
|
952
|
+
handle,
|
|
953
|
+
concurrency
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Stops every consumer of a mount.
|
|
958
|
+
*
|
|
959
|
+
* @param {string} strategy - Mount name
|
|
960
|
+
* @returns {Promise<void>} Resolves once every consumer is stopped
|
|
961
|
+
*/
|
|
962
|
+
async stopConsumers(strategy) {
|
|
963
|
+
const running = [...this.fConsumers];
|
|
964
|
+
for (const [key, consumer] of running) if (key === this.queueKey(strategy, consumer.handle.queue)) await this.stopQueue(strategy, consumer.handle.queue);
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Stops the consumer of a single queue, waiting for its in-flight jobs.
|
|
968
|
+
*
|
|
969
|
+
* @param {string} strategy - Mount name
|
|
970
|
+
* @param {string} queue - Queue whose consumer is stopped
|
|
971
|
+
* @returns {Promise<void>} Resolves once the consumer is stopped
|
|
972
|
+
*/
|
|
973
|
+
async stopQueue(strategy, queue) {
|
|
974
|
+
const key = this.queueKey(strategy, queue);
|
|
975
|
+
const consumer = this.fConsumers.get(key);
|
|
976
|
+
if (!consumer) return;
|
|
977
|
+
try {
|
|
978
|
+
await consumer.handle.stop();
|
|
979
|
+
} catch (error) {
|
|
980
|
+
this.gLogger?.error(`Vercube/QueueManager::Failed to stop consumer of "${queue}"`, error);
|
|
981
|
+
}
|
|
982
|
+
this.fConsumers.delete(key);
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Closes a strategy, keeping a failure from breaking a shutdown sequence.
|
|
986
|
+
*
|
|
987
|
+
* @param {QueueTypes.MountedStrategy} mount - Mount to close
|
|
988
|
+
* @returns {Promise<void>} Resolves once the strategy is closed
|
|
989
|
+
*/
|
|
990
|
+
async closeStrategy(mount) {
|
|
991
|
+
if (!mount.ready) return;
|
|
992
|
+
try {
|
|
993
|
+
await mount.strategy.close();
|
|
994
|
+
} catch (error) {
|
|
995
|
+
this.gLogger?.error(`Vercube/QueueManager::Failed to close strategy "${mount.name}"`, error);
|
|
996
|
+
}
|
|
997
|
+
mount.ready = void 0;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Initializes a strategy once, reusing the same promise for every later call.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {QueueTypes.MountedStrategy} mount - Mount to initialize
|
|
1003
|
+
* @returns {Promise<void>} Resolves once the strategy is ready
|
|
1004
|
+
* @throws {QueueError} When the strategy cannot be initialized
|
|
1005
|
+
*/
|
|
1006
|
+
async ensureReady(mount) {
|
|
1007
|
+
mount.ready ??= (async () => mount.strategy.initialize(mount.initOptions))().then(() => {
|
|
1008
|
+
mount.error = void 0;
|
|
1009
|
+
}, (error) => {
|
|
1010
|
+
mount.ready = void 0;
|
|
1011
|
+
mount.error = error;
|
|
1012
|
+
this.gLogger?.error(`Vercube/QueueManager::Failed to initialize strategy "${mount.name}"`, error);
|
|
1013
|
+
throw toQueueError(error, `Failed to initialize strategy "${mount.name}"`, "initialize", { strategy: mount.name });
|
|
1014
|
+
});
|
|
1015
|
+
return mount.ready;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Resolves a mount by name and makes sure it is connected.
|
|
1019
|
+
*
|
|
1020
|
+
* @param {string} [name] - Mount name, defaults to `default`
|
|
1021
|
+
* @param {string} operation - Operation asking for the mount, reported in the error
|
|
1022
|
+
* @returns {Promise<QueueTypes.MountedStrategy>} The ready mount
|
|
1023
|
+
* @throws {QueueError} When nothing is mounted under that name, or it cannot connect
|
|
1024
|
+
*/
|
|
1025
|
+
async resolveMount(name, operation) {
|
|
1026
|
+
const mountName = name ?? DEFAULT_STRATEGY;
|
|
1027
|
+
const mount = this.fStrategies.get(mountName);
|
|
1028
|
+
if (!mount) throw new QueueError(`No queue strategy is mounted as "${mountName}"`, operation, void 0, { mounted: [...this.fStrategies.keys()] });
|
|
1029
|
+
await this.ensureReady(mount);
|
|
1030
|
+
return mount;
|
|
1031
|
+
}
|
|
1032
|
+
/**
|
|
1033
|
+
* Builds the transport-facing shape of a job, adding the headers the module
|
|
1034
|
+
* needs to route and retry it.
|
|
1035
|
+
*
|
|
1036
|
+
* @param {string} queue - Queue the job goes to
|
|
1037
|
+
* @param {string} job - Name of the job
|
|
1038
|
+
* @param {unknown} payload - Payload of the job
|
|
1039
|
+
* @param {QueueTypes.JobOptions} [options] - Per-job options
|
|
1040
|
+
* @returns {QueueTypes.PublishRequest} The job as a strategy expects it
|
|
1041
|
+
*/
|
|
1042
|
+
createPublishRequest(queue, job, payload, options = {}) {
|
|
1043
|
+
const headers = {
|
|
1044
|
+
...options.headers,
|
|
1045
|
+
[JOB_HEADER]: job,
|
|
1046
|
+
[ATTEMPT_HEADER]: "1"
|
|
1047
|
+
};
|
|
1048
|
+
if (options.attempts && options.attempts > 1) headers[ATTEMPTS_HEADER] = String(options.attempts);
|
|
1049
|
+
if (options.key !== void 0) headers[KEY_HEADER] = options.key;
|
|
1050
|
+
if (options.priority !== void 0) headers[PRIORITY_HEADER] = String(options.priority);
|
|
1051
|
+
injectTraceContext(headers);
|
|
1052
|
+
return {
|
|
1053
|
+
queue,
|
|
1054
|
+
job,
|
|
1055
|
+
payload,
|
|
1056
|
+
headers,
|
|
1057
|
+
options
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Builds the context a handler and its hooks receive.
|
|
1062
|
+
*
|
|
1063
|
+
* @param {string} strategy - Mount name the job came from
|
|
1064
|
+
* @param {string} queue - Queue the job came from
|
|
1065
|
+
* @param {QueueTypes.IncomingJob} incoming - The job as received from the transport
|
|
1066
|
+
* @param {number} attempts - Total attempts this job may take
|
|
1067
|
+
* @returns {QueueTypes.JobContext} The context of this attempt
|
|
1068
|
+
*/
|
|
1069
|
+
createContext(strategy, queue, incoming, attempts) {
|
|
1070
|
+
const logger = this.gLogger;
|
|
1071
|
+
return {
|
|
1072
|
+
id: incoming.id,
|
|
1073
|
+
job: incoming.job,
|
|
1074
|
+
queue,
|
|
1075
|
+
strategy,
|
|
1076
|
+
attempt: incoming.attempt,
|
|
1077
|
+
attempts,
|
|
1078
|
+
payload: incoming.payload,
|
|
1079
|
+
headers: incoming.headers,
|
|
1080
|
+
raw: incoming.raw,
|
|
1081
|
+
logger: typeof logger?.child === "function" ? logger.child({
|
|
1082
|
+
queue,
|
|
1083
|
+
job: incoming.job,
|
|
1084
|
+
jobId: incoming.id,
|
|
1085
|
+
attempt: incoming.attempt
|
|
1086
|
+
}) : logger,
|
|
1087
|
+
updateProgress: async (progress) => {
|
|
1088
|
+
await incoming.updateProgress?.(progress);
|
|
1089
|
+
}
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Returns the counters of a queue, creating them on first use.
|
|
1094
|
+
*
|
|
1095
|
+
* @param {string} strategy - Mount name
|
|
1096
|
+
* @param {string} queue - Queue name
|
|
1097
|
+
* @returns {QueueTypes.QueueMetrics} The mutable counters of that queue
|
|
1098
|
+
*/
|
|
1099
|
+
metricsFor(strategy, queue) {
|
|
1100
|
+
const key = this.queueKey(strategy, queue);
|
|
1101
|
+
let metrics = this.fMetrics.get(key);
|
|
1102
|
+
if (!metrics) {
|
|
1103
|
+
metrics = {
|
|
1104
|
+
strategy,
|
|
1105
|
+
queue,
|
|
1106
|
+
published: 0,
|
|
1107
|
+
processed: 0,
|
|
1108
|
+
failed: 0,
|
|
1109
|
+
retried: 0,
|
|
1110
|
+
unhandled: 0,
|
|
1111
|
+
active: 0
|
|
1112
|
+
};
|
|
1113
|
+
this.fMetrics.set(key, metrics);
|
|
1114
|
+
}
|
|
1115
|
+
return metrics;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Appends a processed job to the inspection buffer, dropping the oldest entry
|
|
1119
|
+
* once the buffer is full.
|
|
1120
|
+
*
|
|
1121
|
+
* The payload and headers of a failure are kept only while `capturePayloads`
|
|
1122
|
+
* is on, and never for a job that completed: that is where the volume is, and
|
|
1123
|
+
* a job that worked has nothing to diagnose.
|
|
1124
|
+
*
|
|
1125
|
+
* @param {Omit<QueueTypes.JobEvent, 'at'>} event - The processed job
|
|
1126
|
+
* @param {unknown} [payload] - Payload of the attempt, kept when capturing is on
|
|
1127
|
+
* @param {Record<string, string>} [headers] - Headers of the attempt, kept when capturing is on
|
|
1128
|
+
* @returns {void}
|
|
1129
|
+
*/
|
|
1130
|
+
record(event, payload, headers) {
|
|
1131
|
+
const captured = this.fDefaults.capturePayloads && event.status !== "completed" ? {
|
|
1132
|
+
payload: previewPayload(payload, this.fDefaults.maxPayloadBytes),
|
|
1133
|
+
headers: headers ? redactHeaders(headers) : void 0
|
|
1134
|
+
} : {};
|
|
1135
|
+
const recorded = {
|
|
1136
|
+
...event,
|
|
1137
|
+
...captured,
|
|
1138
|
+
at: Date.now()
|
|
1139
|
+
};
|
|
1140
|
+
for (const listener of this.fListeners) try {
|
|
1141
|
+
listener(recorded);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
this.gLogger?.error("Vercube/QueueManager::Job listener threw", error);
|
|
1144
|
+
}
|
|
1145
|
+
if (this.fDefaults.maxEvents <= 0) return;
|
|
1146
|
+
this.fEvents.unshift(recorded);
|
|
1147
|
+
if (this.fEvents.length > this.fDefaults.maxEvents) this.fEvents.length = this.fDefaults.maxEvents;
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Describes an error for the inspection buffer: its name, message and a capped
|
|
1151
|
+
* stack, plus what this module knows about it when it raised the error itself.
|
|
1152
|
+
*
|
|
1153
|
+
* @param {Error} error - Error the attempt failed with
|
|
1154
|
+
* @returns {QueueTypes.JobFailure} The failure, ready to be inspected
|
|
1155
|
+
*/
|
|
1156
|
+
describeFailure(error) {
|
|
1157
|
+
const failure = {
|
|
1158
|
+
name: error.name || "Error",
|
|
1159
|
+
message: error.message,
|
|
1160
|
+
stack: error.stack?.slice(0, this.fDefaults.maxPayloadBytes)
|
|
1161
|
+
};
|
|
1162
|
+
if (error instanceof QueueError) {
|
|
1163
|
+
failure.operation = error.operation;
|
|
1164
|
+
failure.retryable = error.retryable;
|
|
1165
|
+
}
|
|
1166
|
+
return failure;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Reports the state of a mount for the devtools.
|
|
1170
|
+
*
|
|
1171
|
+
* @param {QueueTypes.MountedStrategy} mount - Mount to describe
|
|
1172
|
+
* @returns {QueueTypes.StrategyStatus} What the mount is currently doing
|
|
1173
|
+
*/
|
|
1174
|
+
statusOf(mount) {
|
|
1175
|
+
if (mount.error) return "error";
|
|
1176
|
+
return mount.ready ? "ready" : "idle";
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Runs a piece of lifecycle work after everything scheduled before it, so
|
|
1180
|
+
* consumers are never started or stopped concurrently.
|
|
1181
|
+
*
|
|
1182
|
+
* @param {() => Promise<void>} task - Work to run
|
|
1183
|
+
* @returns {Promise<void>} Resolves once this task ran
|
|
1184
|
+
*/
|
|
1185
|
+
enqueue(task) {
|
|
1186
|
+
const tail = this.fTail.then(task, task);
|
|
1187
|
+
this.fTail = tail.catch(() => void 0);
|
|
1188
|
+
return tail;
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Key a handler is registered under.
|
|
1192
|
+
*
|
|
1193
|
+
* @param {string} strategy - Mount name
|
|
1194
|
+
* @param {string} queue - Queue name
|
|
1195
|
+
* @param {string} job - Job name
|
|
1196
|
+
* @returns {string} The registration key
|
|
1197
|
+
*/
|
|
1198
|
+
consumerKey(strategy, queue, job) {
|
|
1199
|
+
return `${strategy}::${queue}::${job}`;
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Key a queue is tracked under.
|
|
1203
|
+
*
|
|
1204
|
+
* @param {string} strategy - Mount name
|
|
1205
|
+
* @param {string} queue - Queue name
|
|
1206
|
+
* @returns {string} The queue key
|
|
1207
|
+
*/
|
|
1208
|
+
queueKey(strategy, queue) {
|
|
1209
|
+
return `${strategy}::${queue}`;
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Starts the consumers once the container is initialized, so every handler
|
|
1213
|
+
* registered by a decorator is known before the first job is received.
|
|
1214
|
+
*
|
|
1215
|
+
* @returns {void}
|
|
1216
|
+
*/
|
|
1217
|
+
init() {
|
|
1218
|
+
queueMicrotask(() => {
|
|
1219
|
+
if (!this.fDefaults.autoStart) return;
|
|
1220
|
+
this.start().catch((error) => this.gLogger?.error("Vercube/QueueManager::Failed to start", error));
|
|
1221
|
+
});
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Closes every connection when the container is torn down.
|
|
1225
|
+
*
|
|
1226
|
+
* @returns {void}
|
|
1227
|
+
*/
|
|
1228
|
+
destroy() {
|
|
1229
|
+
this.close().catch((error) => this.gLogger?.error("Vercube/QueueManager::Failed to close", error));
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
__decorate([Inject(Container)], QueueManager.prototype, "gContainer", void 0);
|
|
1233
|
+
__decorate([InjectOptional(Logger)], QueueManager.prototype, "gLogger", void 0);
|
|
1234
|
+
__decorate([InjectOptional(ValidationProvider)], QueueManager.prototype, "gValidation", void 0);
|
|
1235
|
+
__decorate([Init()], QueueManager.prototype, "init", null);
|
|
1236
|
+
__decorate([Destroy()], QueueManager.prototype, "destroy", null);
|
|
1237
|
+
//#endregion
|
|
1238
|
+
//#region src/Utils/Metadata.ts
|
|
1239
|
+
/**
|
|
1240
|
+
* Consumer options per decorated prototype.
|
|
1241
|
+
*
|
|
1242
|
+
* A weak map keeps the options off the class itself, so nothing leaks into the
|
|
1243
|
+
* instances the container hands out and subclasses inherit them naturally.
|
|
1244
|
+
*/
|
|
1245
|
+
const consumers = /* @__PURE__ */ new WeakMap();
|
|
1246
|
+
/**
|
|
1247
|
+
* Stores the options a `@Consumer()` class was declared with.
|
|
1248
|
+
*
|
|
1249
|
+
* @param prototype - Prototype of the decorated class.
|
|
1250
|
+
* @param options - Options the class was declared with.
|
|
1251
|
+
* @returns Nothing.
|
|
1252
|
+
*/
|
|
1253
|
+
function setConsumerOptions(prototype, options) {
|
|
1254
|
+
consumers.set(prototype, options);
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Reads the options of the closest `@Consumer()` class in the prototype chain,
|
|
1258
|
+
* so a handler declared on a base class still finds its queue.
|
|
1259
|
+
*
|
|
1260
|
+
* @param prototype - Prototype to start looking at.
|
|
1261
|
+
* @returns The options, or undefined when no class in the chain is a consumer.
|
|
1262
|
+
*/
|
|
1263
|
+
function getConsumerOptions(prototype) {
|
|
1264
|
+
let current = prototype;
|
|
1265
|
+
while (current) {
|
|
1266
|
+
const options = consumers.get(current);
|
|
1267
|
+
if (options) return options;
|
|
1268
|
+
current = Object.getPrototypeOf(current);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
//#endregion
|
|
1272
|
+
//#region src/Decorators/Job.ts
|
|
1273
|
+
/**
|
|
1274
|
+
* Registers the decorated method as the handler of a single job.
|
|
1275
|
+
* Runs when the container instantiates the consumer class.
|
|
1276
|
+
*/
|
|
1277
|
+
var JobDecorator = class extends BaseDecorator {
|
|
1278
|
+
/** Queue manager the handler is registered with */
|
|
1279
|
+
gQueueManager;
|
|
1280
|
+
/** Logger instance */
|
|
1281
|
+
gLogger;
|
|
1282
|
+
/** The registration handed to the manager, kept so it can be removed again */
|
|
1283
|
+
fRegistration = null;
|
|
1284
|
+
/**
|
|
1285
|
+
* Registers the handler with the queue manager.
|
|
1286
|
+
*
|
|
1287
|
+
* @returns {void}
|
|
1288
|
+
*/
|
|
1289
|
+
created() {
|
|
1290
|
+
if (!this.gQueueManager) {
|
|
1291
|
+
this.warn("QueueManager is not bound in the container, no job will be consumed");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const consumer = getConsumerOptions(this.prototype);
|
|
1295
|
+
if (!consumer) {
|
|
1296
|
+
this.warn(`Unable to find the queue of "${this.propertyName}". Did you use @Consumer()?`);
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const handler = this.instance[this.propertyName];
|
|
1300
|
+
if (typeof handler !== "function") {
|
|
1301
|
+
this.warn(`"${this.propertyName}" is not a method, @Job() can only decorate methods`);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
this.fRegistration = {
|
|
1305
|
+
strategy: consumer.strategy ?? "default",
|
|
1306
|
+
queue: consumer.queue,
|
|
1307
|
+
job: this.options.name,
|
|
1308
|
+
handler: handler.bind(this.instance),
|
|
1309
|
+
concurrency: consumer.concurrency,
|
|
1310
|
+
options: {
|
|
1311
|
+
attempts: this.options.options.attempts ?? consumer.attempts,
|
|
1312
|
+
backoff: this.options.options.backoff ?? consumer.backoff,
|
|
1313
|
+
timeout: this.options.options.timeout ?? consumer.timeout,
|
|
1314
|
+
schema: this.options.options.schema ?? consumer.schema
|
|
1315
|
+
},
|
|
1316
|
+
source: `${this.instance?.constructor?.name ?? "anonymous"}.${this.propertyName}`
|
|
1317
|
+
};
|
|
1318
|
+
this.gQueueManager.registerConsumer(this.fRegistration);
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Removes the handler again, so rebinding the consumer class does not collide
|
|
1322
|
+
* with the handler its previous instance registered.
|
|
1323
|
+
*
|
|
1324
|
+
* @returns {void}
|
|
1325
|
+
*/
|
|
1326
|
+
destroyed() {
|
|
1327
|
+
if (!this.fRegistration) return;
|
|
1328
|
+
this.gQueueManager?.unregisterConsumer(this.fRegistration);
|
|
1329
|
+
this.fRegistration = null;
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Reports a consumer that cannot be wired up.
|
|
1333
|
+
*
|
|
1334
|
+
* @param {string} message - What is wrong
|
|
1335
|
+
* @returns {void}
|
|
1336
|
+
*/
|
|
1337
|
+
warn(message) {
|
|
1338
|
+
const text = `Vercube/Queue::@Job() - ${message}`;
|
|
1339
|
+
if (this.gLogger) {
|
|
1340
|
+
this.gLogger.warn(text);
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
console.warn(text);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
__decorate([InjectOptional(QueueManager)], JobDecorator.prototype, "gQueueManager", void 0);
|
|
1347
|
+
__decorate([InjectOptional(Logger)], JobDecorator.prototype, "gLogger", void 0);
|
|
1348
|
+
/**
|
|
1349
|
+
* Declares the decorated method as the handler of a job.
|
|
1350
|
+
*
|
|
1351
|
+
* The method receives the job payload as its first argument and a
|
|
1352
|
+
* {@link QueueTypes.JobContext} as its second. Returning marks the job as done,
|
|
1353
|
+
* throwing marks the attempt as failed and hands it to the retry policy.
|
|
1354
|
+
*
|
|
1355
|
+
* Options given here override the defaults of the `@Consumer()` class. To handle
|
|
1356
|
+
* everything the queue carries beyond the named jobs, see `@AnyJob()`.
|
|
1357
|
+
*
|
|
1358
|
+
* @param {string} name - Name of the job, as used when adding it to the queue
|
|
1359
|
+
* @param {QueueTypes.HandlerOptions} [options] - Retries, timeout and payload schema of this handler
|
|
1360
|
+
* @returns {Function} The method decorator
|
|
1361
|
+
*
|
|
1362
|
+
* @example
|
|
1363
|
+
* ```ts
|
|
1364
|
+
* @Consumer({ queue: 'emails' })
|
|
1365
|
+
* export class EmailConsumer {
|
|
1366
|
+
* @Job('welcome')
|
|
1367
|
+
* public async welcome(payload: { userId: string }): Promise<void> {
|
|
1368
|
+
* await this.mailer.sendWelcome(payload.userId);
|
|
1369
|
+
* }
|
|
1370
|
+
* }
|
|
1371
|
+
* ```
|
|
1372
|
+
*
|
|
1373
|
+
* @example
|
|
1374
|
+
* ```ts
|
|
1375
|
+
* // three attempts with a growing delay, a validated payload and a hard time limit
|
|
1376
|
+
* @Job('digest', {
|
|
1377
|
+
* attempts: 3,
|
|
1378
|
+
* backoff: { type: 'exponential', delay: 1000 },
|
|
1379
|
+
* timeout: 30_000,
|
|
1380
|
+
* schema: DigestSchema,
|
|
1381
|
+
* })
|
|
1382
|
+
* public async digest(payload: Digest, context: QueueTypes.JobContext<Digest>): Promise<void> {
|
|
1383
|
+
* context.logger?.info(`attempt ${context.attempt} of ${context.attempts}`);
|
|
1384
|
+
* await context.updateProgress(50);
|
|
1385
|
+
* }
|
|
1386
|
+
* ```
|
|
1387
|
+
*/
|
|
1388
|
+
function Job(name, options = {}) {
|
|
1389
|
+
return createDecorator(JobDecorator, {
|
|
1390
|
+
name,
|
|
1391
|
+
options
|
|
1392
|
+
});
|
|
1393
|
+
}
|
|
1394
|
+
//#endregion
|
|
1395
|
+
//#region src/Decorators/AnyJob.ts
|
|
1396
|
+
/**
|
|
1397
|
+
* Declares the decorated method as the handler of every job of the queue that no
|
|
1398
|
+
* `@Job()` claims.
|
|
1399
|
+
*
|
|
1400
|
+
* A handler registered for a job name always wins, so `@AnyJob()` is the fallback
|
|
1401
|
+
* rather than a replacement. It is what makes a queue somebody else fills
|
|
1402
|
+
* consumable: messages produced outside this module carry no job name, and would
|
|
1403
|
+
* otherwise be reported as unhandled.
|
|
1404
|
+
*
|
|
1405
|
+
* The real job name is on the context, so the handler can still branch on it.
|
|
1406
|
+
* Only one `@AnyJob()` per queue is allowed, like any other handler.
|
|
1407
|
+
*
|
|
1408
|
+
* @param {QueueTypes.HandlerOptions} [options] - Retries, timeout and payload schema of this handler
|
|
1409
|
+
* @returns {Function} The method decorator
|
|
1410
|
+
*
|
|
1411
|
+
* @example
|
|
1412
|
+
* ```ts
|
|
1413
|
+
* // a queue filled by another application, whose messages carry no job name
|
|
1414
|
+
* @Consumer({ queue: 'legacy-events' })
|
|
1415
|
+
* export class LegacyConsumer {
|
|
1416
|
+
* @AnyJob({ attempts: 3 })
|
|
1417
|
+
* public async handle(payload: unknown, context: QueueTypes.JobContext): Promise<void> {
|
|
1418
|
+
* await this.gEvents.ingest(payload, context.job);
|
|
1419
|
+
* }
|
|
1420
|
+
* }
|
|
1421
|
+
* ```
|
|
1422
|
+
*
|
|
1423
|
+
* @example
|
|
1424
|
+
* ```ts
|
|
1425
|
+
* // known jobs handled on their own, everything else swept up
|
|
1426
|
+
* @Consumer({ queue: 'emails' })
|
|
1427
|
+
* export class EmailConsumer {
|
|
1428
|
+
* @Job('welcome')
|
|
1429
|
+
* public async welcome(payload: Welcome): Promise<void> {}
|
|
1430
|
+
*
|
|
1431
|
+
* @AnyJob()
|
|
1432
|
+
* public async rest(payload: unknown, context: QueueTypes.JobContext): Promise<void> {
|
|
1433
|
+
* context.logger?.warn(`unrecognised job ${context.job}`);
|
|
1434
|
+
* }
|
|
1435
|
+
* }
|
|
1436
|
+
* ```
|
|
1437
|
+
*/
|
|
1438
|
+
function AnyJob(options = {}) {
|
|
1439
|
+
return createDecorator(JobDecorator, {
|
|
1440
|
+
name: "*",
|
|
1441
|
+
options
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
//#endregion
|
|
1445
|
+
//#region src/Decorators/Consumer.ts
|
|
1446
|
+
/**
|
|
1447
|
+
* Declares a class as the consumer of a queue.
|
|
1448
|
+
*
|
|
1449
|
+
* The class itself does nothing until it is bound in the container: that is when
|
|
1450
|
+
* its `@Job()` methods register themselves and the queue starts being consumed.
|
|
1451
|
+
* Options declared here become the defaults of every handler in the class.
|
|
1452
|
+
*
|
|
1453
|
+
* @param {QueueTypes.ConsumerOptions} options - Queue to consume, its concurrency and the handler defaults
|
|
1454
|
+
* @returns {Function} The class decorator
|
|
1455
|
+
*
|
|
1456
|
+
* @example
|
|
1457
|
+
* ```ts
|
|
1458
|
+
* @Consumer({ queue: 'emails', concurrency: 5 })
|
|
1459
|
+
* export class EmailConsumer {
|
|
1460
|
+
* @Job('welcome')
|
|
1461
|
+
* public async welcome(payload: { userId: string }): Promise<void> {
|
|
1462
|
+
* await this.mailer.sendWelcome(payload.userId);
|
|
1463
|
+
* }
|
|
1464
|
+
* }
|
|
1465
|
+
*
|
|
1466
|
+
* // in the container setup
|
|
1467
|
+
* container.bind(EmailConsumer);
|
|
1468
|
+
* ```
|
|
1469
|
+
*
|
|
1470
|
+
* @example
|
|
1471
|
+
* ```ts
|
|
1472
|
+
* // defaults for every handler of the class, overridable per job
|
|
1473
|
+
* @Consumer({ queue: 'reports', strategy: 'kafka', attempts: 3, timeout: 30_000 })
|
|
1474
|
+
* export class ReportConsumer {}
|
|
1475
|
+
* ```
|
|
1476
|
+
*/
|
|
1477
|
+
function Consumer(options) {
|
|
1478
|
+
return function internalDecorator(target) {
|
|
1479
|
+
setConsumerOptions(target.prototype, options);
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
//#endregion
|
|
1483
|
+
//#region src/Decorators/JobHookDecorator.ts
|
|
1484
|
+
/**
|
|
1485
|
+
* Registers the decorated method as a lifecycle hook of the queue its
|
|
1486
|
+
* `@Consumer()` class reads from. Shared by `@OnJobCompleted()` and `@OnJobFailed()`.
|
|
1487
|
+
*/
|
|
1488
|
+
var JobHookDecorator = class extends BaseDecorator {
|
|
1489
|
+
/** Queue manager the hook is registered with */
|
|
1490
|
+
gQueueManager;
|
|
1491
|
+
/** Logger instance */
|
|
1492
|
+
gLogger;
|
|
1493
|
+
/** The registration handed to the manager, kept so it can be removed again */
|
|
1494
|
+
fRegistration = null;
|
|
1495
|
+
/**
|
|
1496
|
+
* Registers the hook with the queue manager.
|
|
1497
|
+
*
|
|
1498
|
+
* @returns {void}
|
|
1499
|
+
*/
|
|
1500
|
+
created() {
|
|
1501
|
+
const consumer = getConsumerOptions(this.prototype);
|
|
1502
|
+
const hook = this.instance[this.propertyName];
|
|
1503
|
+
if (!this.gQueueManager) {
|
|
1504
|
+
this.warn("QueueManager is not bound in the container, no job hook will run");
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
if (!consumer) {
|
|
1508
|
+
this.warn(`Unable to find the queue of "${this.propertyName}". Did you use @Consumer()?`);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (typeof hook !== "function") {
|
|
1512
|
+
this.warn(`"${this.propertyName}" is not a method, a job hook can only decorate methods`);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
this.fRegistration = {
|
|
1516
|
+
strategy: consumer.strategy ?? "default",
|
|
1517
|
+
queue: consumer.queue,
|
|
1518
|
+
job: this.options.job,
|
|
1519
|
+
hook: hook.bind(this.instance),
|
|
1520
|
+
source: `${this.instance?.constructor?.name ?? "anonymous"}.${this.propertyName}`
|
|
1521
|
+
};
|
|
1522
|
+
this.gQueueManager.registerHook(this.options.event, this.fRegistration);
|
|
1523
|
+
}
|
|
1524
|
+
/**
|
|
1525
|
+
* Removes the hook again when the container is torn down.
|
|
1526
|
+
*
|
|
1527
|
+
* @returns {void}
|
|
1528
|
+
*/
|
|
1529
|
+
destroyed() {
|
|
1530
|
+
if (!this.fRegistration) return;
|
|
1531
|
+
this.gQueueManager?.unregisterHook(this.options.event, this.fRegistration);
|
|
1532
|
+
this.fRegistration = null;
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Reports a hook that cannot be wired up.
|
|
1536
|
+
*
|
|
1537
|
+
* @param {string} message - What is wrong
|
|
1538
|
+
* @returns {void}
|
|
1539
|
+
*/
|
|
1540
|
+
warn(message) {
|
|
1541
|
+
const text = `Vercube/Queue::Job hook - ${message}`;
|
|
1542
|
+
if (this.gLogger) {
|
|
1543
|
+
this.gLogger.warn(text);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
console.warn(text);
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
__decorate([InjectOptional(QueueManager)], JobHookDecorator.prototype, "gQueueManager", void 0);
|
|
1550
|
+
__decorate([InjectOptional(Logger)], JobHookDecorator.prototype, "gLogger", void 0);
|
|
1551
|
+
//#endregion
|
|
1552
|
+
//#region src/Decorators/OnJobCompleted.ts
|
|
1553
|
+
/**
|
|
1554
|
+
* Runs the decorated method after a job of the consumer's queue completed.
|
|
1555
|
+
*
|
|
1556
|
+
* The hook receives the {@link QueueTypes.JobContext} of the finished job. It
|
|
1557
|
+
* never changes the outcome of that job: a throwing hook is logged and forgotten.
|
|
1558
|
+
*
|
|
1559
|
+
* @param {object} [options] - Narrows the hook down to a single job
|
|
1560
|
+
* @param {string} [options.job] - Name of the only job to listen for, every job of the queue by default
|
|
1561
|
+
* @returns {Function} The method decorator
|
|
1562
|
+
*
|
|
1563
|
+
* @example
|
|
1564
|
+
* ```ts
|
|
1565
|
+
* @Consumer({ queue: 'emails' })
|
|
1566
|
+
* export class EmailConsumer {
|
|
1567
|
+
* @Job('welcome')
|
|
1568
|
+
* public async welcome(payload: { userId: string }): Promise<void> {}
|
|
1569
|
+
*
|
|
1570
|
+
* @OnJobCompleted()
|
|
1571
|
+
* public async sent(context: QueueTypes.JobContext): Promise<void> {
|
|
1572
|
+
* this.metrics.increment(`emails.${context.job}.sent`);
|
|
1573
|
+
* }
|
|
1574
|
+
* }
|
|
1575
|
+
* ```
|
|
1576
|
+
*/
|
|
1577
|
+
function OnJobCompleted(options = {}) {
|
|
1578
|
+
const hook = {
|
|
1579
|
+
event: "completed",
|
|
1580
|
+
job: options.job
|
|
1581
|
+
};
|
|
1582
|
+
return createDecorator(JobHookDecorator, hook);
|
|
1583
|
+
}
|
|
1584
|
+
//#endregion
|
|
1585
|
+
//#region src/Decorators/OnJobFailed.ts
|
|
1586
|
+
/**
|
|
1587
|
+
* Runs the decorated method after an attempt of a job of the consumer's queue threw.
|
|
1588
|
+
*
|
|
1589
|
+
* The hook receives the error and the {@link QueueTypes.JobContext} of the failed
|
|
1590
|
+
* attempt, so it can tell a retry from a final failure by comparing
|
|
1591
|
+
* `context.attempt` with `context.attempts`. It never changes the outcome of the
|
|
1592
|
+
* job: a throwing hook is logged and forgotten.
|
|
1593
|
+
*
|
|
1594
|
+
* @param {object} [options] - Narrows the hook down to a single job
|
|
1595
|
+
* @param {string} [options.job] - Name of the only job to listen for, every job of the queue by default
|
|
1596
|
+
* @returns {Function} The method decorator
|
|
1597
|
+
*
|
|
1598
|
+
* @example
|
|
1599
|
+
* ```ts
|
|
1600
|
+
* @Consumer({ queue: 'emails' })
|
|
1601
|
+
* export class EmailConsumer {
|
|
1602
|
+
* @Job('welcome', { attempts: 3 })
|
|
1603
|
+
* public async welcome(payload: { userId: string }): Promise<void> {}
|
|
1604
|
+
*
|
|
1605
|
+
* @OnJobFailed()
|
|
1606
|
+
* public async failed(error: Error, context: QueueTypes.JobContext): Promise<void> {
|
|
1607
|
+
* if (context.attempt === context.attempts) {
|
|
1608
|
+
* await this.alerts.report(error, context.id);
|
|
1609
|
+
* }
|
|
1610
|
+
* }
|
|
1611
|
+
* }
|
|
1612
|
+
* ```
|
|
1613
|
+
*/
|
|
1614
|
+
function OnJobFailed(options = {}) {
|
|
1615
|
+
const hook = {
|
|
1616
|
+
event: "failed",
|
|
1617
|
+
job: options.job
|
|
1618
|
+
};
|
|
1619
|
+
return createDecorator(JobHookDecorator, hook);
|
|
1620
|
+
}
|
|
1621
|
+
//#endregion
|
|
1622
|
+
//#region src/Plugins/QueuePlugin.ts
|
|
1623
|
+
/**
|
|
1624
|
+
* Queue Plugin for Vercube framework
|
|
1625
|
+
*
|
|
1626
|
+
* Binds the {@link QueueManager}, mounts the strategies it is given and lets the
|
|
1627
|
+
* `@Consumer()` classes bound in the container start consuming once the
|
|
1628
|
+
* application is up. Consumer classes themselves stay in your hands: bind them
|
|
1629
|
+
* where you bind your controllers.
|
|
1630
|
+
*
|
|
1631
|
+
* @example
|
|
1632
|
+
* ```ts
|
|
1633
|
+
* import { defineConfig } from '@vercube/core';
|
|
1634
|
+
* import { QueuePlugin } from '@vercube/queue';
|
|
1635
|
+
* import { BullMQStrategy } from '@vercube/queue/strategies/BullMQStrategy';
|
|
1636
|
+
*
|
|
1637
|
+
* export default defineConfig({
|
|
1638
|
+
* plugins: [
|
|
1639
|
+
* [QueuePlugin, {
|
|
1640
|
+
* strategies: [{ strategy: BullMQStrategy, initOptions: { connection: { host: '127.0.0.1', port: 6379 } } }],
|
|
1641
|
+
* }],
|
|
1642
|
+
* ],
|
|
1643
|
+
* });
|
|
1644
|
+
* ```
|
|
1645
|
+
*
|
|
1646
|
+
* @example
|
|
1647
|
+
* ```ts
|
|
1648
|
+
* // a web process that only publishes jobs
|
|
1649
|
+
* app.addPlugin(QueuePlugin, {
|
|
1650
|
+
* autoStart: false,
|
|
1651
|
+
* strategies: [{ strategy: MemoryStrategy }],
|
|
1652
|
+
* });
|
|
1653
|
+
* ```
|
|
1654
|
+
*
|
|
1655
|
+
* @see {@link https://vercube.dev} for full documentation
|
|
1656
|
+
*/
|
|
1657
|
+
var QueuePlugin = class extends BasePlugin {
|
|
1658
|
+
/**
|
|
1659
|
+
* The name of the plugin.
|
|
1660
|
+
* @override
|
|
1661
|
+
*/
|
|
1662
|
+
name = "QueuePlugin";
|
|
1663
|
+
/**
|
|
1664
|
+
* Binds the queue manager and mounts every configured strategy.
|
|
1665
|
+
*
|
|
1666
|
+
* Registering the plugin more than once, which happens as soon as it is listed
|
|
1667
|
+
* both in the config and in `app.addPlugin()`, is harmless: the manager is
|
|
1668
|
+
* bound once, settings are merged, and a name that is already mounted is left
|
|
1669
|
+
* alone. Rebinding it would drop the settings and the strategies of whoever
|
|
1670
|
+
* registered first.
|
|
1671
|
+
*
|
|
1672
|
+
* @param {App} app - The application instance
|
|
1673
|
+
* @param {QueueTypes.PluginOptions} [options] - Strategies to mount and manager-wide settings
|
|
1674
|
+
* @returns {Promise<void>} Resolves once every strategy is mounted
|
|
1675
|
+
* @override
|
|
1676
|
+
*/
|
|
1677
|
+
async use(app, options) {
|
|
1678
|
+
if (!app.container.getOptional(QueueManager)) app.container.bind(QueueManager);
|
|
1679
|
+
const manager = app.container.get(QueueManager);
|
|
1680
|
+
const { strategies, ...defaults } = options ?? {};
|
|
1681
|
+
manager.configure(defaults);
|
|
1682
|
+
for (const mount of strategies ?? []) {
|
|
1683
|
+
if (manager.getStrategy(mount.name)) continue;
|
|
1684
|
+
await manager.mount(mount);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
//#endregion
|
|
1689
|
+
//#region src/Utils/Mount.ts
|
|
1690
|
+
/**
|
|
1691
|
+
* Type checks a single strategy mount and erases its options, so a list of
|
|
1692
|
+
* mounts of different strategies stays checked.
|
|
1693
|
+
*
|
|
1694
|
+
* `QueueManager.mount()` infers the strategy from its argument and checks
|
|
1695
|
+
* `initOptions` against it. A plain array in `QueuePlugin` options cannot do
|
|
1696
|
+
* that, since there is no inference site per entry - this helper is that site.
|
|
1697
|
+
*
|
|
1698
|
+
* @param {QueueTypes.Mount<T>} mount - Mount name, strategy class and its init options
|
|
1699
|
+
* @returns {QueueTypes.AnyMount} The very same mount, with its options type erased
|
|
1700
|
+
*
|
|
1701
|
+
* @example
|
|
1702
|
+
* ```ts
|
|
1703
|
+
* app.addPlugin(QueuePlugin, {
|
|
1704
|
+
* strategies: [
|
|
1705
|
+
* defineQueueStrategy({ strategy: RabbitMQStrategy, initOptions: { url: 'amqp://localhost' } }),
|
|
1706
|
+
* defineQueueStrategy({
|
|
1707
|
+
* name: 'events',
|
|
1708
|
+
* strategy: KafkaStrategy,
|
|
1709
|
+
* initOptions: { client: { brokers: ['localhost:9092'] }, groupId: 'workers' },
|
|
1710
|
+
* }),
|
|
1711
|
+
* ],
|
|
1712
|
+
* });
|
|
1713
|
+
* ```
|
|
1714
|
+
*/
|
|
1715
|
+
function defineQueueStrategy(mount) {
|
|
1716
|
+
return mount;
|
|
1717
|
+
}
|
|
1718
|
+
//#endregion
|
|
1719
|
+
export { ATTEMPTS_HEADER, ATTEMPT_HEADER, AnyJob, Consumer, JOB_HEADER, Job, JobDecorator, KEY_HEADER, MAX_ATTEMPTS, MAX_BACKOFF_MS, OnJobCompleted, OnJobFailed, PRIORITY_HEADER, QueueError, QueueManager, QueuePlugin, QueueStrategy, WILDCARD_JOB, decodePayload, defineQueueStrategy, delay, encodePayload, generateJobId, getConsumerOptions, normalizeHeaders, prune, readNumericHeader, resolveBackoff, setConsumerOptions, toQueueError };
|