@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,280 @@
1
+ import { f as encodePayload, g as readNumericHeader, m as normalizeHeaders, 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 { Kafka } from "kafkajs";
6
+ //#region src/Strategies/KafkaStrategy.ts
7
+ /**
8
+ * Kafka backed queue implementation.
9
+ *
10
+ * A queue is a topic, the job name travels in the `x-job` header and the payload
11
+ * is JSON. Kafka is a log rather than a job broker: it has no attempts, delays,
12
+ * priorities or per-message acknowledgements, so those are handled by the manager
13
+ * and a job it gives up on only moves the offset forward.
14
+ *
15
+ * Ordering is per partition, so use the `key` job option to keep related jobs on
16
+ * the same partition.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * await queueManager.mount({
21
+ * strategy: KafkaStrategy,
22
+ * initOptions: {
23
+ * client: { clientId: 'orders', brokers: ['localhost:9092'] },
24
+ * groupId: 'orders-workers',
25
+ * },
26
+ * });
27
+ * ```
28
+ */
29
+ var KafkaStrategy = class extends QueueStrategy {
30
+ /** Transport this strategy talks to. */
31
+ transport = "kafka";
32
+ /** Logger instance */
33
+ gLogger;
34
+ /** Options the strategy was initialized with */
35
+ fOptions = null;
36
+ /** The Kafka client */
37
+ fKafka = null;
38
+ /** The connected producer */
39
+ fProducer = null;
40
+ /** Admin client, opened only when counters are read */
41
+ fAdmin = null;
42
+ /** Running consumers, indexed by topic */
43
+ fConsumers = /* @__PURE__ */ new Map();
44
+ /**
45
+ * Kafka is a log: only the offsets it keeps can be reported, everything else
46
+ * about the job model is left to the manager.
47
+ *
48
+ * @returns {QueueTypes.Capabilities} What this strategy supports
49
+ */
50
+ get capabilities() {
51
+ return {
52
+ retries: false,
53
+ delay: false,
54
+ priority: false,
55
+ progress: false,
56
+ stats: true,
57
+ peek: false
58
+ };
59
+ }
60
+ /**
61
+ * Creates the client and connects the producer, so a broken configuration is
62
+ * reported at boot instead of on the first job.
63
+ *
64
+ * @param {KafkaStrategyOptions} options - Client configuration and consumer group
65
+ * @returns {Promise<void>} Resolves once the producer is connected
66
+ * @throws {QueueError} When no brokers are given, or the producer cannot connect
67
+ */
68
+ async initialize(options) {
69
+ if (!options?.client?.brokers) throw new QueueError("Kafka needs at least one broker", "initialize", void 0, void 0, false);
70
+ this.fOptions = options;
71
+ this.fKafka = new Kafka(options.client);
72
+ try {
73
+ this.fProducer = this.fKafka.producer(options.producer);
74
+ await this.fProducer.connect();
75
+ } catch (error) {
76
+ this.fProducer = null;
77
+ throw toQueueError(error, "Failed to connect the Kafka producer", "initialize");
78
+ }
79
+ }
80
+ /**
81
+ * Produces a single record.
82
+ *
83
+ * @param {QueueTypes.PublishRequest} request - Job to publish
84
+ * @returns {Promise<QueueTypes.JobRef>} Reference to the produced record
85
+ * @throws {QueueError} When the record cannot be produced
86
+ */
87
+ async publish(request) {
88
+ const [ref] = await this.publishMany([request]);
89
+ return ref;
90
+ }
91
+ /**
92
+ * Produces many records of the same topic in a single request.
93
+ *
94
+ * @param {QueueTypes.PublishRequest[]} requests - Jobs to publish, all on the same topic
95
+ * @returns {Promise<QueueTypes.JobRef[]>} References to the produced records
96
+ * @throws {QueueError} When the records cannot be produced
97
+ */
98
+ async publishMany(requests) {
99
+ if (requests.length === 0) return [];
100
+ const producer = this.requireProducer("publish");
101
+ const topic = requests[0].queue;
102
+ try {
103
+ const metadata = await producer.send({
104
+ ...this.fOptions?.send,
105
+ topic,
106
+ messages: requests.map((request) => ({
107
+ key: request.options.key ?? request.options.jobId ?? null,
108
+ value: encodePayload(request.payload),
109
+ headers: request.headers
110
+ }))
111
+ });
112
+ const partition = metadata[0]?.partition ?? 0;
113
+ const baseOffset = Number(metadata[0]?.baseOffset ?? 0);
114
+ return requests.map((request, index) => ({
115
+ id: `${topic}-${partition}-${baseOffset + index}`,
116
+ queue: topic,
117
+ job: request.job,
118
+ strategy: this.transport
119
+ }));
120
+ } catch (error) {
121
+ throw toQueueError(error, "Failed to produce Kafka records", "publish", { queue: topic });
122
+ }
123
+ }
124
+ /**
125
+ * Subscribes a consumer of the configured group to a topic.
126
+ *
127
+ * @param {QueueTypes.ConsumeRequest} request - Topic to consume, its concurrency and the dispatch callback
128
+ * @returns {Promise<QueueTypes.ConsumerHandle>} Handle used to stop the consumer again
129
+ * @throws {QueueError} When no consumer group is configured, or the consumer cannot start
130
+ */
131
+ async consume(request) {
132
+ const options = this.requireOptions("consume");
133
+ const kafka = this.requireClient("consume");
134
+ if (!options.groupId) throw new QueueError("Kafka needs a groupId to consume a topic", "consume", void 0, { queue: request.queue }, false);
135
+ const previous = this.fConsumers.get(request.queue);
136
+ if (previous) {
137
+ this.fConsumers.delete(request.queue);
138
+ await previous.disconnect().catch(() => void 0);
139
+ }
140
+ const consumer = kafka.consumer({
141
+ ...options.consumer,
142
+ groupId: options.groupId
143
+ });
144
+ try {
145
+ await consumer.connect();
146
+ await consumer.subscribe({
147
+ topic: request.queue,
148
+ fromBeginning: options.fromBeginning ?? false
149
+ });
150
+ await consumer.run({
151
+ partitionsConsumedConcurrently: Math.max(1, request.concurrency),
152
+ eachMessage: async ({ partition, message }) => {
153
+ const headers = normalizeHeaders(message.headers);
154
+ try {
155
+ await request.dispatch({
156
+ id: `${request.queue}-${partition}-${message.offset}`,
157
+ job: headers["x-job"] ?? "unknown",
158
+ payload: decodePayload(message.value),
159
+ headers,
160
+ attempt: readNumericHeader(headers[ATTEMPT_HEADER], 1),
161
+ raw: message
162
+ });
163
+ } catch (error) {
164
+ if ((options.onFailure ?? "skip") === "crash") throw error;
165
+ this.gLogger?.error(`Vercube/KafkaStrategy::Skipping a failed job of "${request.queue}"`, error);
166
+ }
167
+ }
168
+ });
169
+ } catch (error) {
170
+ await consumer.disconnect().catch(() => void 0);
171
+ throw toQueueError(error, "Failed to consume the Kafka topic", "consume", { queue: request.queue });
172
+ }
173
+ this.fConsumers.set(request.queue, consumer);
174
+ return {
175
+ queue: request.queue,
176
+ stop: async () => {
177
+ if (this.fConsumers.get(request.queue) === consumer) this.fConsumers.delete(request.queue);
178
+ await consumer.disconnect();
179
+ }
180
+ };
181
+ }
182
+ /**
183
+ * Reports how far the consumer group is behind the end of the topic.
184
+ *
185
+ * @param {string} queue - Topic to read
186
+ * @returns {Promise<QueueTypes.QueueStats>} How many records are still to be read
187
+ * @throws {QueueError} When the offsets cannot be read
188
+ */
189
+ async stats(queue) {
190
+ const options = this.requireOptions("stats");
191
+ if (!options.groupId) return {};
192
+ try {
193
+ const admin = await this.adminClient();
194
+ const [ends, committed] = await Promise.all([admin.fetchTopicOffsets(queue), admin.fetchOffsets({
195
+ groupId: options.groupId,
196
+ topics: [queue]
197
+ })]);
198
+ const offsets = new Map(committed[0]?.partitions.map((entry) => [entry.partition, Number(entry.offset)]) ?? []);
199
+ let waiting = 0;
200
+ for (const end of ends) {
201
+ const at = Math.max(0, offsets.get(end.partition) ?? 0);
202
+ waiting += Math.max(0, Number(end.offset) - at);
203
+ }
204
+ return { waiting };
205
+ } catch (error) {
206
+ throw toQueueError(error, "Failed to read Kafka offsets", "stats", { queue });
207
+ }
208
+ }
209
+ /**
210
+ * Disconnects every consumer, the producer and the admin client.
211
+ *
212
+ * @returns {Promise<void>} Resolves once everything is disconnected
213
+ */
214
+ async close() {
215
+ const consumers = [...this.fConsumers.values()];
216
+ const producer = this.fProducer;
217
+ const admin = this.fAdmin;
218
+ this.fConsumers.clear();
219
+ this.fProducer = null;
220
+ this.fAdmin = null;
221
+ try {
222
+ await Promise.all([
223
+ ...consumers.map((consumer) => consumer.disconnect()),
224
+ producer?.disconnect(),
225
+ admin?.disconnect()
226
+ ]);
227
+ } catch (error) {
228
+ this.gLogger?.warn("Vercube/KafkaStrategy::Failed to disconnect", error);
229
+ }
230
+ }
231
+ /**
232
+ * Returns the admin client, connecting it on first use.
233
+ *
234
+ * @returns {Promise<Admin>} The connected admin client
235
+ * @throws {QueueError} When the strategy has not been initialized
236
+ */
237
+ async adminClient() {
238
+ if (!this.fAdmin) {
239
+ this.fAdmin = this.requireClient("stats").admin();
240
+ await this.fAdmin.connect();
241
+ }
242
+ return this.fAdmin;
243
+ }
244
+ /**
245
+ * Returns the options the strategy was initialized with.
246
+ *
247
+ * @param {string} operation - Operation asking for them, reported in the error
248
+ * @returns {KafkaStrategyOptions} The options
249
+ * @throws {QueueError} When the strategy has not been initialized
250
+ */
251
+ requireOptions(operation) {
252
+ if (!this.fOptions) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
253
+ return this.fOptions;
254
+ }
255
+ /**
256
+ * Returns the Kafka client.
257
+ *
258
+ * @param {string} operation - Operation asking for it, reported in the error
259
+ * @returns {Kafka} The client
260
+ * @throws {QueueError} When the strategy has not been initialized
261
+ */
262
+ requireClient(operation) {
263
+ if (!this.fKafka) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
264
+ return this.fKafka;
265
+ }
266
+ /**
267
+ * Returns the connected producer.
268
+ *
269
+ * @param {string} operation - Operation asking for it, reported in the error
270
+ * @returns {Producer} The producer
271
+ * @throws {QueueError} When the strategy has not been initialized
272
+ */
273
+ requireProducer(operation) {
274
+ if (!this.fProducer) throw new QueueError("Kafka strategy is not initialized", operation, void 0, void 0, false);
275
+ return this.fProducer;
276
+ }
277
+ };
278
+ __decorate([InjectOptional(Logger)], KafkaStrategy.prototype, "gLogger", void 0);
279
+ //#endregion
280
+ export { KafkaStrategy };
@@ -0,0 +1,139 @@
1
+ import { n as QueueTypes, t as QueueStrategy } from "../QueueStrategy-saWSBeU6.mjs";
2
+ //#region src/Strategies/MemoryStrategy.d.ts
3
+ /**
4
+ * In-process queue implementation.
5
+ *
6
+ * Jobs never leave the running process, which makes this the strategy to use in
7
+ * tests, in examples and for background work that may be lost on restart. It is
8
+ * also the reference implementation: delays, priorities and progress all behave
9
+ * exactly as the module documents them.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * await queueManager.mount({ strategy: MemoryStrategy });
14
+ * ```
15
+ */
16
+ export declare class MemoryStrategy extends QueueStrategy {
17
+ /** Transport this strategy talks to. */
18
+ readonly transport: string;
19
+ /** Queues created so far, indexed by name */
20
+ private fQueues;
21
+ /** Monotonic counter keeping jobs of equal priority in publish order */
22
+ private fSequence;
23
+ /** Callbacks waiting for a condition on the queues to hold */
24
+ private fWaiters;
25
+ /**
26
+ * Everything the module offers is supported, jobs simply live in memory.
27
+ *
28
+ * @returns {QueueTypes.Capabilities} What this strategy supports
29
+ */
30
+ get capabilities(): QueueTypes.Capabilities;
31
+ /**
32
+ * Nothing to connect to.
33
+ *
34
+ * @returns {void}
35
+ */
36
+ initialize(): void;
37
+ /**
38
+ * Adds a job to an in-memory queue, honouring its delay and priority.
39
+ *
40
+ * @param {QueueTypes.PublishRequest} request - Job to publish
41
+ * @returns {Promise<QueueTypes.JobRef>} Reference to the published job
42
+ */
43
+ publish(request: QueueTypes.PublishRequest): Promise<QueueTypes.JobRef>;
44
+ /**
45
+ * Starts processing a queue. Only one consumer per queue is kept, a second
46
+ * call replaces the first.
47
+ *
48
+ * @param {QueueTypes.ConsumeRequest} request - Queue to consume, its concurrency and the dispatch callback
49
+ * @returns {Promise<QueueTypes.ConsumerHandle>} Handle used to stop the consumer again
50
+ */
51
+ consume(request: QueueTypes.ConsumeRequest): Promise<QueueTypes.ConsumerHandle>;
52
+ /**
53
+ * Reads the counters of a queue.
54
+ *
55
+ * @param {string} name - Queue to read
56
+ * @returns {Promise<QueueTypes.QueueStats>} Counters of that queue
57
+ */
58
+ stats(name: string): Promise<QueueTypes.QueueStats>;
59
+ /**
60
+ * Shows what a queue is holding: the jobs waiting to run and the ones still
61
+ * waiting for their delay.
62
+ *
63
+ * @param {QueueTypes.PeekRequest} request - Queue to look at, how many messages and which states
64
+ * @returns {Promise<QueueTypes.PeekedMessage[]>} The messages found, in the order they would run
65
+ */
66
+ peek(request: QueueTypes.PeekRequest): Promise<QueueTypes.PeekedMessage[]>;
67
+ /**
68
+ * Drops every queue, pending job and delay timer.
69
+ *
70
+ * @returns {Promise<void>} Resolves once everything is dropped
71
+ */
72
+ close(): Promise<void>;
73
+ /**
74
+ * Waits until no job is waiting, delayed or running.
75
+ * Handy in tests, where publishing and processing happen in the same process.
76
+ *
77
+ * @returns {Promise<void>} Resolves once every queue ran dry
78
+ */
79
+ idle(): Promise<void>;
80
+ /**
81
+ * Hands as many waiting jobs to the consumer as its concurrency allows.
82
+ *
83
+ * @param {string} name - Queue to pump
84
+ * @returns {void}
85
+ */
86
+ private pump;
87
+ /**
88
+ * Runs a single job and keeps the queue moving afterwards.
89
+ *
90
+ * A rejected dispatch means the job failed for good: the manager has already
91
+ * applied the retry policy, so nothing is put back on the queue.
92
+ *
93
+ * @param {string} name - Queue the job belongs to
94
+ * @param {MemoryQueue} queue - State of that queue
95
+ * @param {MemoryJob} job - Job to run
96
+ * @returns {Promise<void>} Resolves once the job settled
97
+ */
98
+ private run;
99
+ /**
100
+ * Describes a job the way the module reads a peeked message.
101
+ *
102
+ * @param {MemoryJob} job - Job sitting on the queue
103
+ * @param {QueueTypes.PeekState} state - Where it is sitting
104
+ * @returns {QueueTypes.PeekedMessage} The message, ready to be inspected
105
+ */
106
+ private describe;
107
+ /**
108
+ * Picks the job to run next: lowest priority value first, publish order otherwise.
109
+ *
110
+ * @param {MemoryQueue} queue - Queue to take from
111
+ * @returns {MemoryJob | undefined} The next job, or undefined when the queue is empty
112
+ */
113
+ private takeNext;
114
+ /**
115
+ * Returns the state of a queue, creating it on first use.
116
+ *
117
+ * @param {string} name - Queue name
118
+ * @returns {MemoryQueue} State of that queue
119
+ */
120
+ private queueFor;
121
+ /**
122
+ * @returns {boolean} True when no job is waiting, delayed or running
123
+ */
124
+ private isIdle;
125
+ /**
126
+ * Waits until a condition on the queues holds.
127
+ *
128
+ * @param {() => boolean} ready - Condition to wait for, checked after every job settles
129
+ * @returns {Promise<void>} Resolves once the condition holds
130
+ */
131
+ private waitFor;
132
+ /**
133
+ * Wakes everyone whose condition now holds.
134
+ *
135
+ * @returns {void}
136
+ */
137
+ private notify;
138
+ }
139
+ //#endregion