@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.
@@ -0,0 +1,388 @@
1
+ import { f as encodePayload, g as readNumericHeader, m as normalizeHeaders, p as generateJobId, r as ATTEMPT_HEADER, 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 { InjectOptional } from "@vercube/di";
4
+ import { Logger } from "@vercube/logger";
5
+ import { connect } from "amqplib";
6
+ //#region src/Strategies/RabbitMQStrategy.ts
7
+ /**
8
+ * RabbitMQ backed queue implementation.
9
+ *
10
+ * Jobs are plain AMQP messages: the job name travels in the message `type`
11
+ * property, the module's bookkeeping in the headers, and the payload as JSON.
12
+ * RabbitMQ has no notion of attempts or delays, so the manager owns them - which
13
+ * also means a job that has run out of attempts is nacked without requeue, and
14
+ * ends up wherever the queue's dead letter exchange points.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * await queueManager.mount({
19
+ * strategy: RabbitMQStrategy,
20
+ * initOptions: { url: 'amqp://localhost' },
21
+ * });
22
+ * ```
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * // a queue that dead-letters what it cannot process
27
+ * await queueManager.mount({
28
+ * strategy: RabbitMQStrategy,
29
+ * initOptions: {
30
+ * url: 'amqp://localhost',
31
+ * queueOptions: { durable: true, deadLetterExchange: 'failed' },
32
+ * },
33
+ * });
34
+ * ```
35
+ */
36
+ var RabbitMQStrategy = class extends QueueStrategy {
37
+ /** Transport this strategy talks to. */
38
+ transport = "rabbitmq";
39
+ /** Logger instance */
40
+ gLogger;
41
+ /** Options the strategy was initialized with */
42
+ fOptions = null;
43
+ /** The connection, recovering on its own unless recovery was turned off */
44
+ fConnection = null;
45
+ /** Channel every publish goes through */
46
+ fPublishChannel = null;
47
+ /** Running consumers, indexed by queue name */
48
+ fConsumers = /* @__PURE__ */ new Map();
49
+ /**
50
+ * Queues the application asked to consume, indexed by queue name.
51
+ *
52
+ * Kept apart from {@link RabbitMQStrategy.fConsumers}, which holds the live
53
+ * channels: those belong to one connection and are thrown away when it is
54
+ * replaced, while what should be consumed does not change with a reconnect.
55
+ */
56
+ fRequests = /* @__PURE__ */ new Map();
57
+ /**
58
+ * Bumped on every connection. A consumer that finishes starting after its
59
+ * connection was replaced belongs to a dead one and is discarded.
60
+ */
61
+ fGeneration = 0;
62
+ /** Queues already asserted on the current connection */
63
+ fAsserted = /* @__PURE__ */ new Set();
64
+ /**
65
+ * RabbitMQ carries priorities and message counts, while attempts and delays
66
+ * are left to the manager.
67
+ *
68
+ * @returns {QueueTypes.Capabilities} What this strategy supports
69
+ */
70
+ get capabilities() {
71
+ return {
72
+ retries: false,
73
+ delay: false,
74
+ priority: true,
75
+ progress: false,
76
+ stats: true,
77
+ peek: false
78
+ };
79
+ }
80
+ /**
81
+ * Opens the connection to the broker.
82
+ *
83
+ * @param {RabbitMQStrategyOptions} options - Broker to connect to and the defaults to use
84
+ * @returns {Promise<void>} Resolves once the connection is open
85
+ * @throws {QueueError} When no broker is given, or the connection cannot be opened
86
+ */
87
+ async initialize(options) {
88
+ if (!options?.url) throw new QueueError("RabbitMQ needs a broker url", "initialize", void 0, void 0, false);
89
+ this.fOptions = options;
90
+ const recovery = options.recovery ?? true;
91
+ try {
92
+ this.fConnection = await (recovery ? connect(options.url, {
93
+ ...options.socketOptions,
94
+ recovery: recovery === true ? {} : recovery
95
+ }) : connect(options.url, options.socketOptions));
96
+ } catch (error) {
97
+ throw toQueueError(error, "Failed to connect to RabbitMQ", "initialize");
98
+ }
99
+ this.fConnection.on("error", (error) => {
100
+ this.gLogger?.error("Vercube/RabbitMQStrategy::Connection failed", error);
101
+ });
102
+ this.fConnection.on("connect", () => {
103
+ this.fAsserted.clear();
104
+ this.fPublishChannel = null;
105
+ this.fConsumers.clear();
106
+ this.fGeneration++;
107
+ for (const request of this.fRequests.values()) this.resume(request, this.fGeneration);
108
+ });
109
+ }
110
+ /**
111
+ * Sends a job to a queue.
112
+ *
113
+ * @param {QueueTypes.PublishRequest} request - Job to publish
114
+ * @returns {Promise<QueueTypes.JobRef>} Reference to the published job
115
+ * @throws {QueueError} When the job cannot be sent
116
+ */
117
+ async publish(request) {
118
+ const options = this.requireOptions("publish");
119
+ try {
120
+ const channel = await this.publishChannel();
121
+ await this.assertQueue(channel, request.queue);
122
+ const id = request.options.jobId ?? generateJobId();
123
+ if (!channel.sendToQueue(request.queue, encodePayload(request.payload), {
124
+ persistent: true,
125
+ ...options.publishOptions,
126
+ headers: request.headers,
127
+ type: request.job,
128
+ messageId: id,
129
+ priority: request.options.priority,
130
+ correlationId: request.options.key
131
+ })) await new Promise((resolve, reject) => {
132
+ const drained = () => settle();
133
+ const failed = (channelError) => settle(channelError);
134
+ const closed = () => settle(/* @__PURE__ */ new Error("The channel closed before the job was flushed"));
135
+ const settle = (error) => {
136
+ channel.removeListener("drain", drained);
137
+ channel.removeListener("error", failed);
138
+ channel.removeListener("close", closed);
139
+ if (error) reject(error);
140
+ else resolve();
141
+ };
142
+ channel.once("drain", drained);
143
+ channel.once("error", failed);
144
+ channel.once("close", closed);
145
+ });
146
+ return {
147
+ id,
148
+ queue: request.queue,
149
+ job: request.job,
150
+ strategy: this.transport
151
+ };
152
+ } catch (error) {
153
+ throw toQueueError(error, "Failed to send job to RabbitMQ", "publish", {
154
+ queue: request.queue,
155
+ job: request.job
156
+ });
157
+ }
158
+ }
159
+ /**
160
+ * Starts consuming a queue on its own channel, so its prefetch is its own.
161
+ *
162
+ * A job whose dispatch rejects is nacked without requeue: the manager has
163
+ * already decided it is not worth another attempt.
164
+ *
165
+ * @param {QueueTypes.ConsumeRequest} request - Queue to consume, its concurrency and the dispatch callback
166
+ * @returns {Promise<QueueTypes.ConsumerHandle>} Handle used to stop the consumer again
167
+ * @throws {QueueError} When the consumer cannot be started
168
+ */
169
+ async consume(request) {
170
+ const generation = this.fGeneration;
171
+ this.fRequests.set(request.queue, request);
172
+ try {
173
+ await this.startConsumer(request, generation);
174
+ } catch (error) {
175
+ if (generation !== this.fGeneration) return {
176
+ queue: request.queue,
177
+ stop: () => this.stopConsumer(request.queue)
178
+ };
179
+ this.fRequests.delete(request.queue);
180
+ throw toQueueError(error, "Failed to consume RabbitMQ queue", "consume", { queue: request.queue });
181
+ }
182
+ return {
183
+ queue: request.queue,
184
+ stop: () => this.stopConsumer(request.queue)
185
+ };
186
+ }
187
+ /**
188
+ * Opens a channel for a queue and starts delivering its messages.
189
+ *
190
+ * @param {QueueTypes.ConsumeRequest} request - Queue to consume, its concurrency and the dispatch callback
191
+ * @param {number} generation - Connection this consumer is being started for
192
+ * @returns {Promise<boolean>} Whether the consumer was installed, false when it was no longer wanted
193
+ * @throws {Error} When the channel or the consumer cannot be created
194
+ */
195
+ async startConsumer(request, generation) {
196
+ const options = this.requireOptions("consume");
197
+ const channel = await this.requireConnection("consume").createChannel();
198
+ try {
199
+ await this.assertQueue(channel, request.queue);
200
+ await channel.prefetch(options.prefetch ?? Math.max(1, request.concurrency));
201
+ const reply = await channel.consume(request.queue, (message) => {
202
+ if (!message) {
203
+ this.gLogger?.warn(`Vercube/RabbitMQStrategy::Consumer of "${request.queue}" was cancelled by the broker`);
204
+ return;
205
+ }
206
+ this.handle(request, message);
207
+ });
208
+ if (generation !== this.fGeneration || !this.fRequests.has(request.queue)) {
209
+ await channel.close().catch(() => void 0);
210
+ return false;
211
+ }
212
+ this.fConsumers.set(request.queue, {
213
+ channel,
214
+ tag: reply.consumerTag,
215
+ active: 0,
216
+ stopping: false
217
+ });
218
+ return true;
219
+ } catch (error) {
220
+ await channel.close().catch(() => void 0);
221
+ throw error;
222
+ }
223
+ }
224
+ /**
225
+ * Starts a consumer again on a recovered connection.
226
+ *
227
+ * Failing here is reported rather than thrown: nothing is waiting on a
228
+ * recovery, and a queue that cannot be consumed again has to be visible.
229
+ *
230
+ * @param {QueueTypes.ConsumeRequest} request - What the consumer was started with
231
+ * @param {number} generation - Connection being recovered onto
232
+ * @returns {Promise<void>} Resolves once the consumer is running again, or once the failure was reported
233
+ */
234
+ async resume(request, generation) {
235
+ try {
236
+ await this.startConsumer(request, generation);
237
+ } catch (error) {
238
+ this.gLogger?.error(`Vercube/RabbitMQStrategy::Failed to consume "${request.queue}" again after a recovery`, error);
239
+ }
240
+ }
241
+ /**
242
+ * Reads how many messages are waiting on a queue.
243
+ *
244
+ * @param {string} queue - Queue to read
245
+ * @returns {Promise<QueueTypes.QueueStats>} Counters of that queue
246
+ * @throws {QueueError} When the queue cannot be inspected
247
+ */
248
+ async stats(queue) {
249
+ const connection = this.requireConnection("stats");
250
+ let channel = null;
251
+ try {
252
+ channel = await connection.createChannel();
253
+ const info = await channel.checkQueue(queue);
254
+ const consumer = this.fConsumers.get(queue);
255
+ return {
256
+ waiting: info.messageCount,
257
+ active: consumer?.active ?? 0
258
+ };
259
+ } catch (error) {
260
+ throw toQueueError(error, "Failed to inspect RabbitMQ queue", "stats", { queue });
261
+ } finally {
262
+ await channel?.close().catch(() => void 0);
263
+ }
264
+ }
265
+ /**
266
+ * Stops every consumer and closes the connection.
267
+ *
268
+ * @returns {Promise<void>} Resolves once the connection is closed
269
+ */
270
+ async close() {
271
+ const consuming = /* @__PURE__ */ new Set([...this.fConsumers.keys(), ...this.fRequests.keys()]);
272
+ for (const queue of consuming) await this.stopConsumer(queue);
273
+ const channel = this.fPublishChannel;
274
+ const connection = this.fConnection;
275
+ this.fPublishChannel = null;
276
+ this.fConnection = null;
277
+ this.fAsserted.clear();
278
+ try {
279
+ await channel?.close();
280
+ await connection?.close();
281
+ } catch (error) {
282
+ this.gLogger?.warn("Vercube/RabbitMQStrategy::Failed to close the connection", error);
283
+ }
284
+ }
285
+ /**
286
+ * Runs a single delivery and acknowledges it according to the outcome.
287
+ *
288
+ * @param {QueueTypes.ConsumeRequest} request - The consumer the message belongs to
289
+ * @param {ConsumeMessage} message - The delivery
290
+ * @returns {Promise<void>} Resolves once the message has been settled
291
+ */
292
+ async handle(request, message) {
293
+ const consumer = this.fConsumers.get(request.queue);
294
+ const headers = normalizeHeaders(message.properties.headers);
295
+ if (consumer) consumer.active++;
296
+ try {
297
+ await request.dispatch({
298
+ id: message.properties.messageId ?? generateJobId(),
299
+ job: message.properties.type ?? headers["x-job"] ?? "unknown",
300
+ payload: decodePayload(message.content),
301
+ headers,
302
+ attempt: readNumericHeader(headers[ATTEMPT_HEADER], 1),
303
+ raw: message
304
+ });
305
+ consumer?.channel.ack(message);
306
+ } catch {
307
+ consumer?.channel.nack(message, false, false);
308
+ } finally {
309
+ if (consumer) consumer.active--;
310
+ }
311
+ }
312
+ /**
313
+ * Cancels a consumer, waits for its in-flight messages and closes its channel.
314
+ *
315
+ * @param {string} queue - Queue whose consumer is stopped
316
+ * @returns {Promise<void>} Resolves once the channel is closed
317
+ */
318
+ async stopConsumer(queue) {
319
+ this.fRequests.delete(queue);
320
+ const consumer = this.fConsumers.get(queue);
321
+ if (!consumer || consumer.stopping) return;
322
+ consumer.stopping = true;
323
+ this.fConsumers.delete(queue);
324
+ try {
325
+ await consumer.channel.cancel(consumer.tag);
326
+ while (consumer.active > 0) await new Promise((resolve) => setTimeout(resolve, 10));
327
+ await consumer.channel.close();
328
+ } catch (error) {
329
+ this.gLogger?.warn(`Vercube/RabbitMQStrategy::Failed to stop the consumer of "${queue}"`, error);
330
+ }
331
+ }
332
+ /**
333
+ * Returns the channel every publish goes through, opening it on first use and
334
+ * after a reconnection.
335
+ *
336
+ * @returns {Promise<Channel>} The publish channel
337
+ * @throws {QueueError} When the strategy is not connected
338
+ */
339
+ async publishChannel() {
340
+ if (!this.fPublishChannel) {
341
+ this.fPublishChannel = await this.requireConnection("publish").createChannel();
342
+ this.fPublishChannel.on("error", (error) => {
343
+ this.gLogger?.error("Vercube/RabbitMQStrategy::Publish channel failed", error);
344
+ });
345
+ }
346
+ return this.fPublishChannel;
347
+ }
348
+ /**
349
+ * Declares a queue once per connection.
350
+ *
351
+ * @param {Channel} channel - Channel to declare it on
352
+ * @param {string} queue - Queue to declare
353
+ * @returns {Promise<void>} Resolves once the queue exists
354
+ */
355
+ async assertQueue(channel, queue) {
356
+ if (this.fAsserted.has(queue)) return;
357
+ await channel.assertQueue(queue, {
358
+ durable: true,
359
+ ...this.fOptions?.queueOptions
360
+ });
361
+ this.fAsserted.add(queue);
362
+ }
363
+ /**
364
+ * Returns the options the strategy was initialized with.
365
+ *
366
+ * @param {string} operation - Operation asking for them, reported in the error
367
+ * @returns {RabbitMQStrategyOptions} The options
368
+ * @throws {QueueError} When the strategy has not been initialized
369
+ */
370
+ requireOptions(operation) {
371
+ if (!this.fOptions) throw new QueueError("RabbitMQ strategy is not initialized", operation, void 0, void 0, false);
372
+ return this.fOptions;
373
+ }
374
+ /**
375
+ * Returns the open connection.
376
+ *
377
+ * @param {string} operation - Operation asking for it, reported in the error
378
+ * @returns {ChannelModel | RecoveringChannelModel} The connection
379
+ * @throws {QueueError} When the strategy is not connected
380
+ */
381
+ requireConnection(operation) {
382
+ if (!this.fConnection) throw new QueueError("RabbitMQ strategy is not connected", operation, void 0, void 0, false);
383
+ return this.fConnection;
384
+ }
385
+ };
386
+ __decorate([InjectOptional(Logger)], RabbitMQStrategy.prototype, "gLogger", void 0);
387
+ //#endregion
388
+ export { RabbitMQStrategy };
@@ -0,0 +1,25 @@
1
+ import { v as QueueError } from "./QueueStrategy-DwLPpfFM.mjs";
2
+ //#region src/Utils/Errors.ts
3
+ /**
4
+ * Turns any thrown value into a {@link QueueError}, leaving queue errors alone so
5
+ * their operation and `retryable` flag survive.
6
+ *
7
+ * @param error - The value that was thrown.
8
+ * @param message - Message of the resulting error.
9
+ * @param operation - Queue operation that failed.
10
+ * @param metadata - Additional non-sensitive context.
11
+ * @returns The error to throw.
12
+ */
13
+ function toQueueError(error, message, operation, metadata) {
14
+ return error instanceof QueueError ? error : new QueueError(message, operation, error, metadata);
15
+ }
16
+ //#endregion
17
+ //#region \0@oxc-project+runtime@0.150.0/helpers/esm/decorate.js
18
+ function __decorate(decorators, target, key, desc) {
19
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
20
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
21
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
22
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
23
+ }
24
+ //#endregion
25
+ export { toQueueError as n, __decorate as t };