@amqp-contract/worker 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -34
- package/dist/index.cjs +185 -354
- package/dist/index.d.cts +131 -292
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +131 -292
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +184 -350
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +177 -889
- package/package.json +8 -8
package/dist/index.mjs
CHANGED
|
@@ -1,40 +1,20 @@
|
|
|
1
|
-
import { AmqpClient, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, startConsumeSpan } from "@amqp-contract/core";
|
|
1
|
+
import { AmqpClient, TechnicalError, defaultTelemetryProvider, endSpanError, endSpanSuccess, recordConsumeMetric, startConsumeSpan } from "@amqp-contract/core";
|
|
2
2
|
import { Future, Result } from "@swan-io/boxed";
|
|
3
3
|
import { gunzip, inflate } from "node:zlib";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
6
6
|
//#region src/errors.ts
|
|
7
7
|
/**
|
|
8
|
-
* Base error class for worker errors
|
|
9
|
-
*/
|
|
10
|
-
var WorkerError = class extends Error {
|
|
11
|
-
constructor(message) {
|
|
12
|
-
super(message);
|
|
13
|
-
this.name = "WorkerError";
|
|
14
|
-
const ErrorConstructor = Error;
|
|
15
|
-
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
/**
|
|
19
|
-
* Error for technical/runtime failures in worker operations
|
|
20
|
-
* This includes validation failures, parsing failures, and processing failures
|
|
21
|
-
*/
|
|
22
|
-
var TechnicalError = class extends WorkerError {
|
|
23
|
-
constructor(message, cause) {
|
|
24
|
-
super(message);
|
|
25
|
-
this.cause = cause;
|
|
26
|
-
this.name = "TechnicalError";
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
8
|
* Error thrown when message validation fails
|
|
31
9
|
*/
|
|
32
|
-
var MessageValidationError = class extends
|
|
10
|
+
var MessageValidationError = class extends Error {
|
|
33
11
|
constructor(consumerName, issues) {
|
|
34
12
|
super(`Message validation failed for consumer "${consumerName}"`);
|
|
35
13
|
this.consumerName = consumerName;
|
|
36
14
|
this.issues = issues;
|
|
37
15
|
this.name = "MessageValidationError";
|
|
16
|
+
const ErrorConstructor = Error;
|
|
17
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
38
18
|
}
|
|
39
19
|
};
|
|
40
20
|
/**
|
|
@@ -44,11 +24,13 @@ var MessageValidationError = class extends WorkerError {
|
|
|
44
24
|
* Use this error type when the operation might succeed if retried.
|
|
45
25
|
* The worker will apply exponential backoff and retry the message.
|
|
46
26
|
*/
|
|
47
|
-
var RetryableError = class extends
|
|
27
|
+
var RetryableError = class extends Error {
|
|
48
28
|
constructor(message, cause) {
|
|
49
29
|
super(message);
|
|
50
30
|
this.cause = cause;
|
|
51
31
|
this.name = "RetryableError";
|
|
32
|
+
const ErrorConstructor = Error;
|
|
33
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
52
34
|
}
|
|
53
35
|
};
|
|
54
36
|
/**
|
|
@@ -58,11 +40,13 @@ var RetryableError = class extends WorkerError {
|
|
|
58
40
|
* Use this error type when retrying would not help - the message will be
|
|
59
41
|
* immediately sent to the dead letter queue (DLQ) if configured.
|
|
60
42
|
*/
|
|
61
|
-
var NonRetryableError = class extends
|
|
43
|
+
var NonRetryableError = class extends Error {
|
|
62
44
|
constructor(message, cause) {
|
|
63
45
|
super(message);
|
|
64
46
|
this.cause = cause;
|
|
65
47
|
this.name = "NonRetryableError";
|
|
48
|
+
const ErrorConstructor = Error;
|
|
49
|
+
if (typeof ErrorConstructor.captureStackTrace === "function") ErrorConstructor.captureStackTrace(this, this.constructor);
|
|
66
50
|
}
|
|
67
51
|
};
|
|
68
52
|
|
|
@@ -71,27 +55,43 @@ var NonRetryableError = class extends WorkerError {
|
|
|
71
55
|
const gunzipAsync = promisify(gunzip);
|
|
72
56
|
const inflateAsync = promisify(inflate);
|
|
73
57
|
/**
|
|
58
|
+
* Supported content encodings for message decompression.
|
|
59
|
+
*/
|
|
60
|
+
const SUPPORTED_ENCODINGS = ["gzip", "deflate"];
|
|
61
|
+
/**
|
|
62
|
+
* Type guard to check if a string is a supported encoding.
|
|
63
|
+
*/
|
|
64
|
+
function isSupportedEncoding(encoding) {
|
|
65
|
+
return SUPPORTED_ENCODINGS.includes(encoding.toLowerCase());
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
74
68
|
* Decompress a buffer based on the content-encoding header.
|
|
75
69
|
*
|
|
76
70
|
* @param buffer - The buffer to decompress
|
|
77
71
|
* @param contentEncoding - The content-encoding header value (e.g., 'gzip', 'deflate')
|
|
78
|
-
* @returns A
|
|
79
|
-
* @throws Error if decompression fails or if the encoding is unsupported
|
|
72
|
+
* @returns A Future with the decompressed buffer or a TechnicalError
|
|
80
73
|
*
|
|
81
74
|
* @internal
|
|
82
75
|
*/
|
|
83
|
-
|
|
84
|
-
if (!contentEncoding) return buffer;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
76
|
+
function decompressBuffer(buffer, contentEncoding) {
|
|
77
|
+
if (!contentEncoding) return Future.value(Result.Ok(buffer));
|
|
78
|
+
const normalizedEncoding = contentEncoding.toLowerCase();
|
|
79
|
+
if (!isSupportedEncoding(normalizedEncoding)) return Future.value(Result.Error(new TechnicalError(`Unsupported content-encoding: "${contentEncoding}". Supported encodings are: ${SUPPORTED_ENCODINGS.join(", ")}. Please check your publisher configuration.`)));
|
|
80
|
+
switch (normalizedEncoding) {
|
|
81
|
+
case "gzip": return Future.fromPromise(gunzipAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress gzip", error));
|
|
82
|
+
case "deflate": return Future.fromPromise(inflateAsync(buffer)).mapError((error) => new TechnicalError("Failed to decompress deflate", error));
|
|
89
83
|
}
|
|
90
84
|
}
|
|
91
85
|
|
|
92
86
|
//#endregion
|
|
93
87
|
//#region src/worker.ts
|
|
94
88
|
/**
|
|
89
|
+
* Type guard to check if a handler entry is a tuple format [handler, options].
|
|
90
|
+
*/
|
|
91
|
+
function isHandlerTuple(entry) {
|
|
92
|
+
return Array.isArray(entry) && entry.length === 2;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
95
|
* Type-safe AMQP worker for consuming messages from RabbitMQ.
|
|
96
96
|
*
|
|
97
97
|
* This class provides automatic message validation, connection management,
|
|
@@ -133,16 +133,13 @@ async function decompressBuffer(buffer, contentEncoding) {
|
|
|
133
133
|
*/
|
|
134
134
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
135
135
|
/**
|
|
136
|
-
* Internal handler
|
|
137
|
-
* Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
|
|
136
|
+
* Internal handler storage - handlers returning `Future<Result>`.
|
|
138
137
|
*/
|
|
139
138
|
actualHandlers;
|
|
140
139
|
consumerOptions;
|
|
141
|
-
batchTimers = /* @__PURE__ */ new Map();
|
|
142
140
|
consumerTags = /* @__PURE__ */ new Set();
|
|
143
|
-
retryConfig;
|
|
144
141
|
telemetry;
|
|
145
|
-
constructor(contract, amqpClient, handlers, logger,
|
|
142
|
+
constructor(contract, amqpClient, handlers, logger, telemetry) {
|
|
146
143
|
this.contract = contract;
|
|
147
144
|
this.amqpClient = amqpClient;
|
|
148
145
|
this.logger = logger;
|
|
@@ -153,19 +150,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
153
150
|
for (const consumerName of Object.keys(handlersRecord)) {
|
|
154
151
|
const handlerEntry = handlersRecord[consumerName];
|
|
155
152
|
const typedConsumerName = consumerName;
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
this.
|
|
153
|
+
if (isHandlerTuple(handlerEntry)) {
|
|
154
|
+
const [handler, options] = handlerEntry;
|
|
155
|
+
this.actualHandlers[typedConsumerName] = handler;
|
|
156
|
+
this.consumerOptions[typedConsumerName] = options;
|
|
159
157
|
} else this.actualHandlers[typedConsumerName] = handlerEntry;
|
|
160
158
|
}
|
|
161
|
-
if (retryOptions === void 0) this.retryConfig = null;
|
|
162
|
-
else this.retryConfig = {
|
|
163
|
-
maxRetries: retryOptions.maxRetries ?? 3,
|
|
164
|
-
initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
|
|
165
|
-
maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
|
|
166
|
-
backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
|
|
167
|
-
jitter: retryOptions.jitter ?? true
|
|
168
|
-
};
|
|
169
159
|
}
|
|
170
160
|
/**
|
|
171
161
|
* Create a type-safe AMQP worker from a contract.
|
|
@@ -186,18 +176,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
186
176
|
* const worker = await TypedAmqpWorker.create({
|
|
187
177
|
* contract: myContract,
|
|
188
178
|
* handlers: {
|
|
189
|
-
* processOrder: async (
|
|
179
|
+
* processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
|
|
190
180
|
* },
|
|
191
181
|
* urls: ['amqp://localhost']
|
|
192
182
|
* }).resultToPromise();
|
|
193
183
|
* ```
|
|
194
184
|
*/
|
|
195
|
-
static create({ contract, handlers, urls, connectionOptions, logger,
|
|
185
|
+
static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
|
|
196
186
|
const worker = new TypedAmqpWorker(contract, new AmqpClient(contract, {
|
|
197
187
|
urls,
|
|
198
188
|
connectionOptions
|
|
199
|
-
}), handlers, logger,
|
|
200
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.
|
|
189
|
+
}), handlers, logger, telemetry);
|
|
190
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
201
191
|
}
|
|
202
192
|
/**
|
|
203
193
|
* Close the AMQP channel and connection.
|
|
@@ -216,9 +206,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
216
206
|
* ```
|
|
217
207
|
*/
|
|
218
208
|
close() {
|
|
219
|
-
|
|
220
|
-
this.batchTimers.clear();
|
|
221
|
-
return Future.all(Array.from(this.consumerTags).map((consumerTag) => Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
|
|
209
|
+
return Future.all(Array.from(this.consumerTags).map((consumerTag) => this.amqpClient.cancel(consumerTag).mapErrorToResult((error) => {
|
|
222
210
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
223
211
|
consumerTag,
|
|
224
212
|
error
|
|
@@ -226,146 +214,103 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
226
214
|
return Result.Ok(void 0);
|
|
227
215
|
}))).map(Result.all).tapOk(() => {
|
|
228
216
|
this.consumerTags.clear();
|
|
229
|
-
}).flatMapOk(() =>
|
|
217
|
+
}).flatMapOk(() => this.amqpClient.close()).mapOk(() => void 0);
|
|
230
218
|
}
|
|
231
219
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
220
|
+
* Get the retry configuration for a consumer's queue.
|
|
221
|
+
* Defaults are applied in the contract's defineQueue, so we just return the config.
|
|
234
222
|
*/
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if (!this.contract.consumers || !this.contract.queues) return Future.value(Result.Ok(void 0));
|
|
238
|
-
const setupTasks = [];
|
|
239
|
-
for (const consumerName of Object.keys(this.contract.consumers)) {
|
|
240
|
-
const consumer = this.contract.consumers[consumerName];
|
|
241
|
-
if (!consumer) continue;
|
|
242
|
-
const queue = consumer.queue;
|
|
243
|
-
const deadLetter = queue.deadLetter;
|
|
244
|
-
if (!deadLetter) continue;
|
|
245
|
-
const queueName = queue.name;
|
|
246
|
-
const waitQueueName = `${queueName}-wait`;
|
|
247
|
-
const dlxName = deadLetter.exchange.name;
|
|
248
|
-
const setupTask = Future.fromPromise(this.amqpClient.channel.addSetup(async (channel) => {
|
|
249
|
-
await channel.assertQueue(waitQueueName, {
|
|
250
|
-
durable: queue.durable ?? false,
|
|
251
|
-
deadLetterExchange: dlxName,
|
|
252
|
-
deadLetterRoutingKey: queueName
|
|
253
|
-
});
|
|
254
|
-
await channel.bindQueue(waitQueueName, dlxName, `${queueName}-wait`);
|
|
255
|
-
await channel.bindQueue(queueName, dlxName, queueName);
|
|
256
|
-
this.logger?.info("Wait queue created and bound", {
|
|
257
|
-
consumerName: String(consumerName),
|
|
258
|
-
queueName,
|
|
259
|
-
waitQueueName,
|
|
260
|
-
dlxName
|
|
261
|
-
});
|
|
262
|
-
})).mapError((error) => new TechnicalError(`Failed to setup wait queue for "${String(consumerName)}"`, error));
|
|
263
|
-
setupTasks.push(setupTask);
|
|
264
|
-
}
|
|
265
|
-
if (setupTasks.length === 0) return Future.value(Result.Ok(void 0));
|
|
266
|
-
return Future.all(setupTasks).map(Result.all).mapOk(() => void 0);
|
|
223
|
+
getRetryConfigForConsumer(consumer) {
|
|
224
|
+
return consumer.queue.retry;
|
|
267
225
|
}
|
|
268
226
|
/**
|
|
269
|
-
* Start consuming messages for all consumers
|
|
227
|
+
* Start consuming messages for all consumers.
|
|
228
|
+
* TypeScript guarantees consumers exist (handlers require matching consumers).
|
|
270
229
|
*/
|
|
271
230
|
consumeAll() {
|
|
272
|
-
|
|
273
|
-
const consumerNames = Object.keys(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
maxPrefetch = Math.max(maxPrefetch, options.prefetch);
|
|
280
|
-
}
|
|
281
|
-
if (options?.batchSize !== void 0) {
|
|
282
|
-
const effectivePrefetch = options.prefetch ?? options.batchSize;
|
|
283
|
-
maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
|
|
231
|
+
const consumers = this.contract.consumers;
|
|
232
|
+
const consumerNames = Object.keys(consumers);
|
|
233
|
+
const maxPrefetch = consumerNames.reduce((max, name) => {
|
|
234
|
+
const prefetch = this.consumerOptions[name]?.prefetch;
|
|
235
|
+
return prefetch ? Math.max(max, prefetch) : max;
|
|
236
|
+
}, 0);
|
|
237
|
+
if (maxPrefetch > 0) this.amqpClient.addSetup(async (channel) => {
|
|
287
238
|
await channel.prefetch(maxPrefetch);
|
|
288
239
|
});
|
|
289
|
-
return Future.all(consumerNames.map((
|
|
240
|
+
return Future.all(consumerNames.map((name) => this.consume(name))).map(Result.all).mapOk(() => void 0);
|
|
290
241
|
}
|
|
291
242
|
waitForConnectionReady() {
|
|
292
|
-
return
|
|
243
|
+
return this.amqpClient.waitForConnect();
|
|
293
244
|
}
|
|
294
245
|
/**
|
|
295
|
-
* Start consuming messages for a specific consumer
|
|
246
|
+
* Start consuming messages for a specific consumer.
|
|
247
|
+
* TypeScript guarantees consumer and handler exist for valid consumer names.
|
|
296
248
|
*/
|
|
297
249
|
consume(consumerName) {
|
|
298
|
-
const
|
|
299
|
-
if (!consumers) return Future.value(Result.Error(new TechnicalError("No consumers defined in contract")));
|
|
300
|
-
const consumer = consumers[consumerName];
|
|
301
|
-
if (!consumer) {
|
|
302
|
-
const availableConsumers = Object.keys(consumers);
|
|
303
|
-
const available = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
304
|
-
return Future.value(Result.Error(new TechnicalError(`Consumer not found: "${String(consumerName)}". Available consumers: ${available}`)));
|
|
305
|
-
}
|
|
250
|
+
const consumer = this.contract.consumers[consumerName];
|
|
306
251
|
const handler = this.actualHandlers[consumerName];
|
|
307
|
-
|
|
308
|
-
const options = this.consumerOptions[consumerName] ?? {};
|
|
309
|
-
if (options.batchSize !== void 0) {
|
|
310
|
-
if (options.batchSize <= 0 || !Number.isInteger(options.batchSize)) return Future.value(Result.Error(new TechnicalError(`Invalid batchSize for "${String(consumerName)}": must be a positive integer`)));
|
|
311
|
-
}
|
|
312
|
-
if (options.batchTimeout !== void 0) {
|
|
313
|
-
if (typeof options.batchTimeout !== "number" || !Number.isFinite(options.batchTimeout) || options.batchTimeout <= 0) return Future.value(Result.Error(new TechnicalError(`Invalid batchTimeout for "${String(consumerName)}": must be a positive number`)));
|
|
314
|
-
}
|
|
315
|
-
if (options.batchSize !== void 0 && options.batchSize > 0) return this.consumeBatch(consumerName, consumer, options, handler);
|
|
316
|
-
else return this.consumeSingle(consumerName, consumer, handler);
|
|
252
|
+
return this.consumeSingle(consumerName, consumer, handler);
|
|
317
253
|
}
|
|
318
254
|
/**
|
|
319
|
-
*
|
|
320
|
-
* @returns `Future<Result<validated message, void>>` - Ok with validated message, or Error (already handled with nack)
|
|
255
|
+
* Validate data against a Standard Schema and handle errors.
|
|
321
256
|
*/
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
257
|
+
validateSchema(schema, data, context, msg) {
|
|
258
|
+
const rawValidation = schema["~standard"].validate(data);
|
|
259
|
+
const validationPromise = rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation);
|
|
260
|
+
return Future.fromPromise(validationPromise).mapError((error) => new TechnicalError(`Error validating ${context.field}`, error)).mapOkToResult((result) => {
|
|
261
|
+
if (result.issues) return Result.Error(new TechnicalError(`${context.field} validation failed`, new MessageValidationError(context.consumerName, result.issues)));
|
|
262
|
+
return Result.Ok(result.value);
|
|
263
|
+
}).tapError((error) => {
|
|
264
|
+
this.logger?.error(`${context.field} validation failed`, {
|
|
265
|
+
consumerName: context.consumerName,
|
|
266
|
+
queueName: context.queueName,
|
|
328
267
|
error
|
|
329
268
|
});
|
|
330
|
-
this.amqpClient.
|
|
269
|
+
this.amqpClient.nack(msg, false, false);
|
|
331
270
|
});
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
return Future.value(Result.Error(void 0));
|
|
342
|
-
}
|
|
343
|
-
return Future.value(Result.Ok(parseResult.value));
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Parse and validate a message from AMQP.
|
|
274
|
+
* @returns Ok with validated message (payload + headers), or Error (message already nacked)
|
|
275
|
+
*/
|
|
276
|
+
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
277
|
+
const context = {
|
|
278
|
+
consumerName: String(consumerName),
|
|
279
|
+
queueName: consumer.queue.name
|
|
344
280
|
};
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
350
|
-
this.logger?.error("Message validation failed", {
|
|
351
|
-
consumerName: String(consumerName),
|
|
352
|
-
queueName: consumer.queue.name,
|
|
353
|
-
error
|
|
354
|
-
});
|
|
355
|
-
this.amqpClient.channel.nack(msg, false, false);
|
|
356
|
-
return Result.Error(void 0);
|
|
357
|
-
}
|
|
358
|
-
return Result.Ok(validationResult.value);
|
|
281
|
+
const nackAndError = (message, error) => {
|
|
282
|
+
this.logger?.error(message, {
|
|
283
|
+
...context,
|
|
284
|
+
error
|
|
359
285
|
});
|
|
286
|
+
this.amqpClient.nack(msg, false, false);
|
|
287
|
+
return new TechnicalError(message, error);
|
|
360
288
|
};
|
|
361
|
-
|
|
289
|
+
const parsePayload = decompressBuffer(msg.content, msg.properties.contentEncoding).tapError((error) => {
|
|
290
|
+
this.logger?.error("Failed to decompress message", {
|
|
291
|
+
...context,
|
|
292
|
+
error
|
|
293
|
+
});
|
|
294
|
+
this.amqpClient.nack(msg, false, false);
|
|
295
|
+
}).mapOkToResult((buffer) => Result.fromExecution(() => JSON.parse(buffer.toString())).mapError((error) => nackAndError("Failed to parse JSON", error))).flatMapOk((parsed) => this.validateSchema(consumer.message.payload, parsed, {
|
|
296
|
+
...context,
|
|
297
|
+
field: "payload"
|
|
298
|
+
}, msg));
|
|
299
|
+
const parseHeaders = consumer.message.headers ? this.validateSchema(consumer.message.headers, msg.properties.headers ?? {}, {
|
|
300
|
+
...context,
|
|
301
|
+
field: "headers"
|
|
302
|
+
}, msg) : Future.value(Result.Ok(void 0));
|
|
303
|
+
return Future.allFromDict({
|
|
304
|
+
payload: parsePayload,
|
|
305
|
+
headers: parseHeaders
|
|
306
|
+
}).map(Result.allFromDict);
|
|
362
307
|
}
|
|
363
308
|
/**
|
|
364
309
|
* Consume messages one at a time
|
|
365
310
|
*/
|
|
366
311
|
consumeSingle(consumerName, consumer, handler) {
|
|
367
312
|
const queueName = consumer.queue.name;
|
|
368
|
-
return
|
|
313
|
+
return this.amqpClient.consume(queueName, async (msg) => {
|
|
369
314
|
if (msg === null) {
|
|
370
315
|
this.logger?.warn("Consumer cancelled by server", {
|
|
371
316
|
consumerName: String(consumerName),
|
|
@@ -375,12 +320,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
375
320
|
}
|
|
376
321
|
const startTime = Date.now();
|
|
377
322
|
const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
378
|
-
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
|
|
323
|
+
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
|
|
379
324
|
this.logger?.info("Message consumed successfully", {
|
|
380
325
|
consumerName: String(consumerName),
|
|
381
326
|
queueName
|
|
382
327
|
});
|
|
383
|
-
this.amqpClient.
|
|
328
|
+
this.amqpClient.ack(msg);
|
|
384
329
|
const durationMs = Date.now() - startTime;
|
|
385
330
|
endSpanSuccess(span);
|
|
386
331
|
recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
@@ -401,112 +346,26 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
401
346
|
endSpanError(span, /* @__PURE__ */ new Error("Message validation failed"));
|
|
402
347
|
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
403
348
|
}).toPromise();
|
|
404
|
-
})
|
|
405
|
-
this.consumerTags.add(
|
|
406
|
-
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* Handle batch processing error by applying error handling to all messages.
|
|
410
|
-
*/
|
|
411
|
-
handleBatchError(error, currentBatch, consumerName, consumer) {
|
|
412
|
-
return Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(Result.all).mapOk(() => void 0);
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Consume messages in batches
|
|
416
|
-
*/
|
|
417
|
-
consumeBatch(consumerName, consumer, options, handler) {
|
|
418
|
-
const batchSize = options.batchSize;
|
|
419
|
-
const batchTimeout = options.batchTimeout ?? 1e3;
|
|
420
|
-
const timerKey = String(consumerName);
|
|
421
|
-
const queueName = consumer.queue.name;
|
|
422
|
-
let batch = [];
|
|
423
|
-
let isProcessing = false;
|
|
424
|
-
const processBatch = () => {
|
|
425
|
-
if (isProcessing || batch.length === 0) return Future.value(Result.Ok(void 0));
|
|
426
|
-
isProcessing = true;
|
|
427
|
-
const currentBatch = batch;
|
|
428
|
-
batch = [];
|
|
429
|
-
const timer = this.batchTimers.get(timerKey);
|
|
430
|
-
if (timer) {
|
|
431
|
-
clearTimeout(timer);
|
|
432
|
-
this.batchTimers.delete(timerKey);
|
|
433
|
-
}
|
|
434
|
-
const messages = currentBatch.map((item) => item.message);
|
|
435
|
-
const batchCount = currentBatch.length;
|
|
436
|
-
const startTime = Date.now();
|
|
437
|
-
const span = startConsumeSpan(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
|
|
438
|
-
this.logger?.info("Processing batch", {
|
|
439
|
-
consumerName: String(consumerName),
|
|
440
|
-
queueName,
|
|
441
|
-
batchSize: batchCount
|
|
442
|
-
});
|
|
443
|
-
return handler(messages).flatMapOk(() => {
|
|
444
|
-
for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
|
|
445
|
-
this.logger?.info("Batch processed successfully", {
|
|
446
|
-
consumerName: String(consumerName),
|
|
447
|
-
queueName,
|
|
448
|
-
batchSize: batchCount
|
|
449
|
-
});
|
|
450
|
-
const durationMs = Date.now() - startTime;
|
|
451
|
-
endSpanSuccess(span);
|
|
452
|
-
recordConsumeMetric(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
453
|
-
return Future.value(Result.Ok(void 0));
|
|
454
|
-
}).flatMapError((handlerError) => {
|
|
455
|
-
this.logger?.error("Error processing batch", {
|
|
456
|
-
consumerName: String(consumerName),
|
|
457
|
-
queueName,
|
|
458
|
-
batchSize: batchCount,
|
|
459
|
-
errorType: handlerError.name,
|
|
460
|
-
error: handlerError.message
|
|
461
|
-
});
|
|
462
|
-
const durationMs = Date.now() - startTime;
|
|
463
|
-
endSpanError(span, handlerError);
|
|
464
|
-
recordConsumeMetric(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
465
|
-
return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
|
|
466
|
-
}).tap(() => {
|
|
467
|
-
isProcessing = false;
|
|
468
|
-
});
|
|
469
|
-
};
|
|
470
|
-
const scheduleBatchProcessing = () => {
|
|
471
|
-
if (isProcessing) return;
|
|
472
|
-
const existingTimer = this.batchTimers.get(timerKey);
|
|
473
|
-
if (existingTimer) clearTimeout(existingTimer);
|
|
474
|
-
const timer = setTimeout(() => {
|
|
475
|
-
processBatch().toPromise();
|
|
476
|
-
}, batchTimeout);
|
|
477
|
-
this.batchTimers.set(timerKey, timer);
|
|
478
|
-
};
|
|
479
|
-
return Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
|
|
480
|
-
if (msg === null) {
|
|
481
|
-
this.logger?.warn("Consumer cancelled by server", {
|
|
482
|
-
consumerName: String(consumerName),
|
|
483
|
-
queueName
|
|
484
|
-
});
|
|
485
|
-
await processBatch().toPromise();
|
|
486
|
-
return;
|
|
487
|
-
}
|
|
488
|
-
const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
|
|
489
|
-
if (validationResult.isError()) return;
|
|
490
|
-
batch.push({
|
|
491
|
-
message: validationResult.value,
|
|
492
|
-
amqpMessage: msg
|
|
493
|
-
});
|
|
494
|
-
if (batch.length >= batchSize) {
|
|
495
|
-
await processBatch().toPromise();
|
|
496
|
-
if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
497
|
-
} else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
498
|
-
})).tapOk((reply) => {
|
|
499
|
-
this.consumerTags.add(reply.consumerTag);
|
|
349
|
+
}).tapOk((consumerTag) => {
|
|
350
|
+
this.consumerTags.add(consumerTag);
|
|
500
351
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
501
352
|
}
|
|
502
353
|
/**
|
|
503
354
|
* Handle error in message processing with retry logic.
|
|
504
355
|
*
|
|
505
|
-
* Flow:
|
|
356
|
+
* Flow depends on retry mode:
|
|
357
|
+
*
|
|
358
|
+
* **quorum-native mode:**
|
|
506
359
|
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
507
|
-
* 2.
|
|
508
|
-
*
|
|
509
|
-
*
|
|
360
|
+
* 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
|
|
361
|
+
*
|
|
362
|
+
* **ttl-backoff mode:**
|
|
363
|
+
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
364
|
+
* 2. If max retries exceeded -> send to DLQ
|
|
365
|
+
* 3. Otherwise -> publish to wait queue with TTL for retry
|
|
366
|
+
*
|
|
367
|
+
* **Legacy mode (no retry config):**
|
|
368
|
+
* 1. nack with requeue=true (immediate requeue)
|
|
510
369
|
*/
|
|
511
370
|
handleError(error, msg, consumerName, consumer) {
|
|
512
371
|
if (error instanceof NonRetryableError) {
|
|
@@ -518,16 +377,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
518
377
|
this.sendToDLQ(msg, consumer);
|
|
519
378
|
return Future.value(Result.Ok(void 0));
|
|
520
379
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
380
|
+
const config = this.getRetryConfigForConsumer(consumer);
|
|
381
|
+
if (config.mode === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
|
|
382
|
+
return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Handle error using quorum queue's native delivery limit feature.
|
|
386
|
+
*
|
|
387
|
+
* Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
|
|
388
|
+
* - Increments x-delivery-count header
|
|
389
|
+
* - Dead-letters the message when count exceeds x-delivery-limit
|
|
390
|
+
*
|
|
391
|
+
* This is simpler than TTL-based retry but provides immediate retries only.
|
|
392
|
+
*/
|
|
393
|
+
handleErrorQuorumNative(error, msg, consumerName, consumer) {
|
|
394
|
+
const queue = consumer.queue;
|
|
395
|
+
const queueName = queue.name;
|
|
396
|
+
const deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
|
|
397
|
+
const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
|
|
398
|
+
const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
|
|
399
|
+
if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
|
|
400
|
+
consumerName,
|
|
401
|
+
queueName,
|
|
402
|
+
deliveryCount,
|
|
403
|
+
deliveryLimit,
|
|
404
|
+
willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
|
|
405
|
+
alreadyExceededLimit: deliveryCount >= deliveryLimit,
|
|
406
|
+
error: error.message
|
|
407
|
+
});
|
|
408
|
+
else this.logger?.warn("Retrying message (quorum-native mode)", {
|
|
409
|
+
consumerName,
|
|
410
|
+
queueName,
|
|
411
|
+
deliveryCount,
|
|
412
|
+
deliveryLimit,
|
|
413
|
+
attemptsBeforeDeadLetter,
|
|
414
|
+
error: error.message
|
|
415
|
+
});
|
|
416
|
+
this.amqpClient.nack(msg, false, true);
|
|
417
|
+
return Future.value(Result.Ok(void 0));
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Handle error using TTL + wait queue pattern for exponential backoff.
|
|
421
|
+
*/
|
|
422
|
+
handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
|
|
529
423
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
530
|
-
const config = this.retryConfig;
|
|
531
424
|
if (retryCount >= config.maxRetries) {
|
|
532
425
|
this.logger?.error("Max retries exceeded, sending to DLQ", {
|
|
533
426
|
consumerName,
|
|
@@ -538,8 +431,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
538
431
|
this.sendToDLQ(msg, consumer);
|
|
539
432
|
return Future.value(Result.Ok(void 0));
|
|
540
433
|
}
|
|
541
|
-
const delayMs = this.calculateRetryDelay(retryCount);
|
|
542
|
-
this.logger?.warn("Retrying message", {
|
|
434
|
+
const delayMs = this.calculateRetryDelay(retryCount, config);
|
|
435
|
+
this.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
543
436
|
consumerName,
|
|
544
437
|
retryCount: retryCount + 1,
|
|
545
438
|
delayMs,
|
|
@@ -550,8 +443,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
550
443
|
/**
|
|
551
444
|
* Calculate retry delay with exponential backoff and optional jitter.
|
|
552
445
|
*/
|
|
553
|
-
calculateRetryDelay(retryCount) {
|
|
554
|
-
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } =
|
|
446
|
+
calculateRetryDelay(retryCount, config) {
|
|
447
|
+
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
555
448
|
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
556
449
|
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
557
450
|
return Math.floor(delay);
|
|
@@ -602,14 +495,14 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
602
495
|
const deadLetter = consumer.queue.deadLetter;
|
|
603
496
|
if (!deadLetter) {
|
|
604
497
|
this.logger?.warn("Cannot retry: queue does not have DLX configured, falling back to nack with requeue", { queueName });
|
|
605
|
-
this.amqpClient.
|
|
498
|
+
this.amqpClient.nack(msg, false, true);
|
|
606
499
|
return Future.value(Result.Ok(void 0));
|
|
607
500
|
}
|
|
608
501
|
const dlxName = deadLetter.exchange.name;
|
|
609
502
|
const waitRoutingKey = `${queueName}-wait`;
|
|
610
|
-
this.amqpClient.
|
|
503
|
+
this.amqpClient.ack(msg);
|
|
611
504
|
const content = this.parseMessageContentForRetry(msg, queueName);
|
|
612
|
-
return
|
|
505
|
+
return this.amqpClient.publish(dlxName, waitRoutingKey, content, {
|
|
613
506
|
...msg.properties,
|
|
614
507
|
expiration: delayMs.toString(),
|
|
615
508
|
headers: {
|
|
@@ -618,7 +511,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
618
511
|
"x-last-error": error.message,
|
|
619
512
|
"x-first-failure-timestamp": msg.properties.headers?.["x-first-failure-timestamp"] ?? Date.now()
|
|
620
513
|
}
|
|
621
|
-
})
|
|
514
|
+
}).mapOkToResult((published) => {
|
|
622
515
|
if (!published) {
|
|
623
516
|
this.logger?.error("Failed to publish message for retry (write buffer full)", {
|
|
624
517
|
queueName,
|
|
@@ -647,7 +540,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
647
540
|
queueName,
|
|
648
541
|
deliveryTag: msg.fields.deliveryTag
|
|
649
542
|
});
|
|
650
|
-
this.amqpClient.
|
|
543
|
+
this.amqpClient.nack(msg, false, false);
|
|
651
544
|
}
|
|
652
545
|
};
|
|
653
546
|
|
|
@@ -673,19 +566,6 @@ function validateHandlers(contract, handlers) {
|
|
|
673
566
|
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
674
567
|
for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
|
|
675
568
|
}
|
|
676
|
-
/**
|
|
677
|
-
* Wrap a Promise-based handler into a Future-based safe handler.
|
|
678
|
-
* This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
|
|
679
|
-
*/
|
|
680
|
-
function wrapUnsafeHandler(handler) {
|
|
681
|
-
return (input) => {
|
|
682
|
-
return Future.fromPromise(handler(input)).mapOkToResult(() => Result.Ok(void 0)).flatMapError((error) => {
|
|
683
|
-
if (error instanceof NonRetryableError || error instanceof RetryableError) return Future.value(Result.Error(error));
|
|
684
|
-
const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
|
|
685
|
-
return Future.value(Result.Error(retryableError));
|
|
686
|
-
});
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
569
|
function defineHandler(contract, consumerName, handler, options) {
|
|
690
570
|
validateConsumerExists(contract, String(consumerName));
|
|
691
571
|
if (options) return [handler, options];
|
|
@@ -709,12 +589,12 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
709
589
|
* import { orderContract } from './contract';
|
|
710
590
|
*
|
|
711
591
|
* const handlers = defineHandlers(orderContract, {
|
|
712
|
-
* processOrder: (
|
|
713
|
-
* Future.fromPromise(processPayment(
|
|
592
|
+
* processOrder: ({ payload }) =>
|
|
593
|
+
* Future.fromPromise(processPayment(payload))
|
|
714
594
|
* .mapOk(() => undefined)
|
|
715
595
|
* .mapError((error) => new RetryableError('Payment failed', error)),
|
|
716
|
-
* notifyOrder: (
|
|
717
|
-
* Future.fromPromise(sendNotification(
|
|
596
|
+
* notifyOrder: ({ payload }) =>
|
|
597
|
+
* Future.fromPromise(sendNotification(payload))
|
|
718
598
|
* .mapOk(() => undefined)
|
|
719
599
|
* .mapError((error) => new RetryableError('Notification failed', error)),
|
|
720
600
|
* });
|
|
@@ -724,53 +604,7 @@ function defineHandlers(contract, handlers) {
|
|
|
724
604
|
validateHandlers(contract, handlers);
|
|
725
605
|
return handlers;
|
|
726
606
|
}
|
|
727
|
-
function defineUnsafeHandler(contract, consumerName, handler, options) {
|
|
728
|
-
validateConsumerExists(contract, String(consumerName));
|
|
729
|
-
const wrappedHandler = wrapUnsafeHandler(handler);
|
|
730
|
-
if (options) return [wrappedHandler, options];
|
|
731
|
-
return wrappedHandler;
|
|
732
|
-
}
|
|
733
|
-
/**
|
|
734
|
-
* Define multiple unsafe handlers for consumers in a contract.
|
|
735
|
-
*
|
|
736
|
-
* @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
|
|
737
|
-
*
|
|
738
|
-
* **Warning:** Unsafe handlers use exception-based error handling.
|
|
739
|
-
* Consider migrating to safe handlers for better error control.
|
|
740
|
-
*
|
|
741
|
-
* **Note:** Internally, this function wraps all Promise-based handlers into Future-based
|
|
742
|
-
* safe handlers for consistent processing in the worker.
|
|
743
|
-
*
|
|
744
|
-
* @template TContract - The contract definition type
|
|
745
|
-
* @param contract - The contract definition containing the consumers
|
|
746
|
-
* @param handlers - An object with async handler functions for each consumer
|
|
747
|
-
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
748
|
-
*
|
|
749
|
-
* @example
|
|
750
|
-
* ```typescript
|
|
751
|
-
* import { defineUnsafeHandlers } from '@amqp-contract/worker';
|
|
752
|
-
*
|
|
753
|
-
* // ⚠️ Consider using defineHandlers for better error handling
|
|
754
|
-
* const handlers = defineUnsafeHandlers(orderContract, {
|
|
755
|
-
* processOrder: async (message) => {
|
|
756
|
-
* await processPayment(message);
|
|
757
|
-
* },
|
|
758
|
-
* notifyOrder: async (message) => {
|
|
759
|
-
* await sendNotification(message);
|
|
760
|
-
* },
|
|
761
|
-
* });
|
|
762
|
-
* ```
|
|
763
|
-
*/
|
|
764
|
-
function defineUnsafeHandlers(contract, handlers) {
|
|
765
|
-
validateHandlers(contract, handlers);
|
|
766
|
-
const result = {};
|
|
767
|
-
for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
|
|
768
|
-
const [handler, options] = entry;
|
|
769
|
-
result[name] = [wrapUnsafeHandler(handler), options];
|
|
770
|
-
} else result[name] = wrapUnsafeHandler(entry);
|
|
771
|
-
return result;
|
|
772
|
-
}
|
|
773
607
|
|
|
774
608
|
//#endregion
|
|
775
|
-
export { MessageValidationError, NonRetryableError, RetryableError,
|
|
609
|
+
export { MessageValidationError, NonRetryableError, RetryableError, TypedAmqpWorker, defineHandler, defineHandlers };
|
|
776
610
|
//# sourceMappingURL=index.mjs.map
|