@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/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 };