@amqp-contract/worker 0.9.0 → 0.11.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 +205 -245
- package/dist/index.d.cts +136 -271
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +136 -271
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +205 -243
- package/dist/index.mjs.map +1 -1
- package/docs/index.md +123 -685
- package/package.json +8 -8
package/dist/index.cjs
CHANGED
|
@@ -92,6 +92,23 @@ async function decompressBuffer(buffer, contentEncoding) {
|
|
|
92
92
|
//#endregion
|
|
93
93
|
//#region src/worker.ts
|
|
94
94
|
/**
|
|
95
|
+
* Type guard to check if a handler entry is a tuple format [handler, options].
|
|
96
|
+
*/
|
|
97
|
+
function isHandlerTuple(entry) {
|
|
98
|
+
return Array.isArray(entry) && entry.length === 2;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Type guard to check if a value is a Standard Schema v1 compliant schema.
|
|
102
|
+
*/
|
|
103
|
+
function isStandardSchema(value) {
|
|
104
|
+
if (typeof value !== "object" || value === null) return false;
|
|
105
|
+
if (!("~standard" in value)) return false;
|
|
106
|
+
const standard = value["~standard"];
|
|
107
|
+
if (typeof standard !== "object" || standard === null) return false;
|
|
108
|
+
if (!("validate" in standard)) return false;
|
|
109
|
+
return typeof standard.validate === "function";
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
95
112
|
* Type-safe AMQP worker for consuming messages from RabbitMQ.
|
|
96
113
|
*
|
|
97
114
|
* This class provides automatic message validation, connection management,
|
|
@@ -133,16 +150,13 @@ async function decompressBuffer(buffer, contentEncoding) {
|
|
|
133
150
|
*/
|
|
134
151
|
var TypedAmqpWorker = class TypedAmqpWorker {
|
|
135
152
|
/**
|
|
136
|
-
* Internal handler
|
|
137
|
-
* Unsafe handlers are wrapped into safe handlers by defineUnsafeHandler/defineUnsafeHandlers.
|
|
153
|
+
* Internal handler storage - handlers returning `Future<Result>`.
|
|
138
154
|
*/
|
|
139
155
|
actualHandlers;
|
|
140
156
|
consumerOptions;
|
|
141
|
-
batchTimers = /* @__PURE__ */ new Map();
|
|
142
157
|
consumerTags = /* @__PURE__ */ new Set();
|
|
143
|
-
retryConfig;
|
|
144
158
|
telemetry;
|
|
145
|
-
constructor(contract, amqpClient, handlers, logger,
|
|
159
|
+
constructor(contract, amqpClient, handlers, logger, telemetry) {
|
|
146
160
|
this.contract = contract;
|
|
147
161
|
this.amqpClient = amqpClient;
|
|
148
162
|
this.logger = logger;
|
|
@@ -153,19 +167,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
153
167
|
for (const consumerName of Object.keys(handlersRecord)) {
|
|
154
168
|
const handlerEntry = handlersRecord[consumerName];
|
|
155
169
|
const typedConsumerName = consumerName;
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
this.
|
|
170
|
+
if (isHandlerTuple(handlerEntry)) {
|
|
171
|
+
const [handler, options] = handlerEntry;
|
|
172
|
+
this.actualHandlers[typedConsumerName] = handler;
|
|
173
|
+
this.consumerOptions[typedConsumerName] = options;
|
|
159
174
|
} else this.actualHandlers[typedConsumerName] = handlerEntry;
|
|
160
175
|
}
|
|
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
176
|
}
|
|
170
177
|
/**
|
|
171
178
|
* Create a type-safe AMQP worker from a contract.
|
|
@@ -186,18 +193,18 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
186
193
|
* const worker = await TypedAmqpWorker.create({
|
|
187
194
|
* contract: myContract,
|
|
188
195
|
* handlers: {
|
|
189
|
-
* processOrder: async (
|
|
196
|
+
* processOrder: async ({ payload }) => console.log('Order:', payload.orderId)
|
|
190
197
|
* },
|
|
191
198
|
* urls: ['amqp://localhost']
|
|
192
199
|
* }).resultToPromise();
|
|
193
200
|
* ```
|
|
194
201
|
*/
|
|
195
|
-
static create({ contract, handlers, urls, connectionOptions, logger,
|
|
202
|
+
static create({ contract, handlers, urls, connectionOptions, logger, telemetry }) {
|
|
196
203
|
const worker = new TypedAmqpWorker(contract, new _amqp_contract_core.AmqpClient(contract, {
|
|
197
204
|
urls,
|
|
198
205
|
connectionOptions
|
|
199
|
-
}), handlers, logger,
|
|
200
|
-
return worker.waitForConnectionReady().flatMapOk(() => worker.
|
|
206
|
+
}), handlers, logger, telemetry);
|
|
207
|
+
return worker.waitForConnectionReady().flatMapOk(() => worker.validateRetryConfiguration()).flatMapOk(() => worker.consumeAll()).mapOk(() => worker);
|
|
201
208
|
}
|
|
202
209
|
/**
|
|
203
210
|
* Close the AMQP channel and connection.
|
|
@@ -216,8 +223,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
216
223
|
* ```
|
|
217
224
|
*/
|
|
218
225
|
close() {
|
|
219
|
-
for (const timer of this.batchTimers.values()) clearTimeout(timer);
|
|
220
|
-
this.batchTimers.clear();
|
|
221
226
|
return _swan_io_boxed.Future.all(Array.from(this.consumerTags).map((consumerTag) => _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.cancel(consumerTag)).mapErrorToResult((error) => {
|
|
222
227
|
this.logger?.warn("Failed to cancel consumer during close", {
|
|
223
228
|
consumerTag,
|
|
@@ -229,40 +234,82 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
229
234
|
}).flatMapOk(() => _swan_io_boxed.Future.fromPromise(this.amqpClient.close())).mapError((error) => new TechnicalError("Failed to close AMQP connection", error)).mapOk(() => void 0);
|
|
230
235
|
}
|
|
231
236
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
237
|
+
* Validate retry configuration for all consumers.
|
|
238
|
+
*
|
|
239
|
+
* For quorum-native mode, validates that the queue is properly configured.
|
|
240
|
+
* For TTL-backoff mode, wait queues are created by setupAmqpTopology in the core package.
|
|
234
241
|
*/
|
|
235
|
-
|
|
236
|
-
if (this.
|
|
237
|
-
if (!this.contract.consumers || !this.contract.queues) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
238
|
-
const setupTasks = [];
|
|
242
|
+
validateRetryConfiguration() {
|
|
243
|
+
if (!this.contract.consumers) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
239
244
|
for (const consumerName of Object.keys(this.contract.consumers)) {
|
|
240
245
|
const consumer = this.contract.consumers[consumerName];
|
|
241
246
|
if (!consumer) continue;
|
|
242
247
|
const queue = consumer.queue;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const dlxName = deadLetter.exchange.name;
|
|
248
|
-
const setupTask = _swan_io_boxed.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
|
-
this.logger?.info("Wait queue created and bound", {
|
|
248
|
+
if ((queue.retry?.mode ?? "ttl-backoff") === "quorum-native") {
|
|
249
|
+
const validationError = this.validateQuorumNativeConfigForConsumer(String(consumerName), consumer);
|
|
250
|
+
if (validationError) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(validationError));
|
|
251
|
+
this.logger?.info("Using quorum-native retry mode", {
|
|
256
252
|
consumerName: String(consumerName),
|
|
257
|
-
queueName
|
|
258
|
-
waitQueueName,
|
|
259
|
-
dlxName
|
|
253
|
+
queueName: queue.name
|
|
260
254
|
});
|
|
261
|
-
}
|
|
262
|
-
|
|
255
|
+
} else if (queue.deadLetter) this.logger?.info("Using TTL-backoff retry mode", {
|
|
256
|
+
consumerName: String(consumerName),
|
|
257
|
+
queueName: queue.name
|
|
258
|
+
});
|
|
259
|
+
else this.logger?.warn("Queue has no deadLetter configured - retries will use nack with requeue", {
|
|
260
|
+
consumerName: String(consumerName),
|
|
261
|
+
queueName: queue.name
|
|
262
|
+
});
|
|
263
263
|
}
|
|
264
|
-
|
|
265
|
-
|
|
264
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get the resolved retry configuration for a consumer's queue.
|
|
268
|
+
* Reads retry config from the queue definition in the contract.
|
|
269
|
+
*/
|
|
270
|
+
getRetryConfigForConsumer(consumer) {
|
|
271
|
+
const retryOptions = consumer.queue.retry;
|
|
272
|
+
if (retryOptions?.mode === "quorum-native") return {
|
|
273
|
+
mode: "quorum-native",
|
|
274
|
+
maxRetries: 0,
|
|
275
|
+
initialDelayMs: 0,
|
|
276
|
+
maxDelayMs: 0,
|
|
277
|
+
backoffMultiplier: 0,
|
|
278
|
+
jitter: false
|
|
279
|
+
};
|
|
280
|
+
if (retryOptions?.mode === "ttl-backoff") return {
|
|
281
|
+
mode: "ttl-backoff",
|
|
282
|
+
maxRetries: retryOptions.maxRetries ?? 3,
|
|
283
|
+
initialDelayMs: retryOptions.initialDelayMs ?? 1e3,
|
|
284
|
+
maxDelayMs: retryOptions.maxDelayMs ?? 3e4,
|
|
285
|
+
backoffMultiplier: retryOptions.backoffMultiplier ?? 2,
|
|
286
|
+
jitter: retryOptions.jitter ?? true
|
|
287
|
+
};
|
|
288
|
+
return {
|
|
289
|
+
mode: "ttl-backoff",
|
|
290
|
+
maxRetries: 3,
|
|
291
|
+
initialDelayMs: 1e3,
|
|
292
|
+
maxDelayMs: 3e4,
|
|
293
|
+
backoffMultiplier: 2,
|
|
294
|
+
jitter: true
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Validate that quorum-native retry mode is properly configured for a specific consumer.
|
|
299
|
+
*
|
|
300
|
+
* Requirements for quorum-native mode:
|
|
301
|
+
* - Consumer queue must be a quorum queue
|
|
302
|
+
* - Consumer queue must have deliveryLimit configured
|
|
303
|
+
* - Consumer queue should have DLX configured (warning if not)
|
|
304
|
+
*
|
|
305
|
+
* @returns TechnicalError if validation fails, null if valid
|
|
306
|
+
*/
|
|
307
|
+
validateQuorumNativeConfigForConsumer(consumerName, consumer) {
|
|
308
|
+
const queue = consumer.queue;
|
|
309
|
+
if (queue.type !== "quorum") return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" with type "${queue.type}". Quorum-native retry mode requires quorum queues.`);
|
|
310
|
+
if (queue.deliveryLimit === void 0) return new TechnicalError(`Consumer "${consumerName}" uses queue "${queue.name}" without deliveryLimit configured. Quorum-native retry mode requires deliveryLimit to be set on the queue definition.`);
|
|
311
|
+
if (!queue.deadLetter) this.logger?.warn(`Consumer "${consumerName}" uses queue "${queue.name}" without deadLetter configured. Messages exceeding deliveryLimit will be dropped instead of dead-lettered.`);
|
|
312
|
+
return null;
|
|
266
313
|
}
|
|
267
314
|
/**
|
|
268
315
|
* Start consuming messages for all consumers
|
|
@@ -277,10 +324,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
277
324
|
if (options.prefetch <= 0 || !Number.isInteger(options.prefetch)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Invalid prefetch value for "${String(consumerName)}": must be a positive integer`)));
|
|
278
325
|
maxPrefetch = Math.max(maxPrefetch, options.prefetch);
|
|
279
326
|
}
|
|
280
|
-
if (options?.batchSize !== void 0) {
|
|
281
|
-
const effectivePrefetch = options.prefetch ?? options.batchSize;
|
|
282
|
-
maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
|
|
283
|
-
}
|
|
284
327
|
}
|
|
285
328
|
if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
|
|
286
329
|
await channel.prefetch(maxPrefetch);
|
|
@@ -304,19 +347,11 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
304
347
|
}
|
|
305
348
|
const handler = this.actualHandlers[consumerName];
|
|
306
349
|
if (!handler) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
|
|
307
|
-
|
|
308
|
-
if (options.batchSize !== void 0) {
|
|
309
|
-
if (options.batchSize <= 0 || !Number.isInteger(options.batchSize)) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Invalid batchSize for "${String(consumerName)}": must be a positive integer`)));
|
|
310
|
-
}
|
|
311
|
-
if (options.batchTimeout !== void 0) {
|
|
312
|
-
if (typeof options.batchTimeout !== "number" || !Number.isFinite(options.batchTimeout) || options.batchTimeout <= 0) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Invalid batchTimeout for "${String(consumerName)}": must be a positive number`)));
|
|
313
|
-
}
|
|
314
|
-
if (options.batchSize !== void 0 && options.batchSize > 0) return this.consumeBatch(consumerName, consumer, options, handler);
|
|
315
|
-
else return this.consumeSingle(consumerName, consumer, handler);
|
|
350
|
+
return this.consumeSingle(consumerName, consumer, handler);
|
|
316
351
|
}
|
|
317
352
|
/**
|
|
318
353
|
* Parse and validate a message from AMQP
|
|
319
|
-
* @returns `Future<Result<
|
|
354
|
+
* @returns `Future<Result<consumed message, void>>` - Ok with validated consumed message (payload + headers), or Error (already handled with nack)
|
|
320
355
|
*/
|
|
321
356
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
322
357
|
const decompressMessage = _swan_io_boxed.Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
|
|
@@ -327,7 +362,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
327
362
|
error
|
|
328
363
|
});
|
|
329
364
|
this.amqpClient.channel.nack(msg, false, false);
|
|
330
|
-
});
|
|
365
|
+
}).mapError(() => void 0);
|
|
331
366
|
const parseMessage = (buffer) => {
|
|
332
367
|
const parseResult = _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString()));
|
|
333
368
|
if (parseResult.isError()) {
|
|
@@ -341,12 +376,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
341
376
|
}
|
|
342
377
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(parseResult.value));
|
|
343
378
|
};
|
|
344
|
-
const
|
|
379
|
+
const validatePayload = (parsedMessage) => {
|
|
345
380
|
const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
|
|
346
|
-
return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapOkToResult((validationResult) => {
|
|
381
|
+
return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
|
|
347
382
|
if (validationResult.issues) {
|
|
348
383
|
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
349
|
-
this.logger?.error("Message validation failed", {
|
|
384
|
+
this.logger?.error("Message payload validation failed", {
|
|
350
385
|
consumerName: String(consumerName),
|
|
351
386
|
queueName: consumer.queue.name,
|
|
352
387
|
error
|
|
@@ -357,7 +392,45 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
357
392
|
return _swan_io_boxed.Result.Ok(validationResult.value);
|
|
358
393
|
});
|
|
359
394
|
};
|
|
360
|
-
|
|
395
|
+
const validateHeaders = () => {
|
|
396
|
+
const headersSchema = consumer.message.headers;
|
|
397
|
+
if (!headersSchema) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
398
|
+
if (!isStandardSchema(headersSchema)) {
|
|
399
|
+
const error = new MessageValidationError(String(consumerName), "Invalid headers schema: not a Standard Schema v1 compliant schema");
|
|
400
|
+
this.logger?.error("Message headers validation failed", {
|
|
401
|
+
consumerName: String(consumerName),
|
|
402
|
+
queueName: consumer.queue.name,
|
|
403
|
+
error
|
|
404
|
+
});
|
|
405
|
+
this.amqpClient.channel.nack(msg, false, false);
|
|
406
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(void 0));
|
|
407
|
+
}
|
|
408
|
+
const validSchema = headersSchema;
|
|
409
|
+
const rawHeaders = msg.properties.headers ?? {};
|
|
410
|
+
const rawValidation = validSchema["~standard"].validate(rawHeaders);
|
|
411
|
+
return _swan_io_boxed.Future.fromPromise(rawValidation instanceof Promise ? rawValidation : Promise.resolve(rawValidation)).mapError(() => void 0).mapOkToResult((validationResult) => {
|
|
412
|
+
if (validationResult.issues) {
|
|
413
|
+
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
414
|
+
this.logger?.error("Message headers validation failed", {
|
|
415
|
+
consumerName: String(consumerName),
|
|
416
|
+
queueName: consumer.queue.name,
|
|
417
|
+
error
|
|
418
|
+
});
|
|
419
|
+
this.amqpClient.channel.nack(msg, false, false);
|
|
420
|
+
return _swan_io_boxed.Result.Error(void 0);
|
|
421
|
+
}
|
|
422
|
+
return _swan_io_boxed.Result.Ok(validationResult.value);
|
|
423
|
+
});
|
|
424
|
+
};
|
|
425
|
+
const buildConsumedMessage = (validatedPayload) => {
|
|
426
|
+
return validateHeaders().mapOk((validatedHeaders) => {
|
|
427
|
+
return {
|
|
428
|
+
payload: validatedPayload,
|
|
429
|
+
headers: validatedHeaders
|
|
430
|
+
};
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
return decompressMessage.flatMapOk(parseMessage).flatMapOk(validatePayload).flatMapOk(buildConsumedMessage);
|
|
361
434
|
}
|
|
362
435
|
/**
|
|
363
436
|
* Consume messages one at a time
|
|
@@ -374,7 +447,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
374
447
|
}
|
|
375
448
|
const startTime = Date.now();
|
|
376
449
|
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "messaging.rabbitmq.message.delivery_tag": msg.fields.deliveryTag });
|
|
377
|
-
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage).flatMapOk(() => {
|
|
450
|
+
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
|
|
378
451
|
this.logger?.info("Message consumed successfully", {
|
|
379
452
|
consumerName: String(consumerName),
|
|
380
453
|
queueName
|
|
@@ -405,107 +478,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
405
478
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
406
479
|
}
|
|
407
480
|
/**
|
|
408
|
-
* Handle batch processing error by applying error handling to all messages.
|
|
409
|
-
*/
|
|
410
|
-
handleBatchError(error, currentBatch, consumerName, consumer) {
|
|
411
|
-
return _swan_io_boxed.Future.all(currentBatch.map((item) => this.handleError(error, item.amqpMessage, consumerName, consumer))).map(_swan_io_boxed.Result.all).mapOk(() => void 0);
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Consume messages in batches
|
|
415
|
-
*/
|
|
416
|
-
consumeBatch(consumerName, consumer, options, handler) {
|
|
417
|
-
const batchSize = options.batchSize;
|
|
418
|
-
const batchTimeout = options.batchTimeout ?? 1e3;
|
|
419
|
-
const timerKey = String(consumerName);
|
|
420
|
-
const queueName = consumer.queue.name;
|
|
421
|
-
let batch = [];
|
|
422
|
-
let isProcessing = false;
|
|
423
|
-
const processBatch = () => {
|
|
424
|
-
if (isProcessing || batch.length === 0) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
425
|
-
isProcessing = true;
|
|
426
|
-
const currentBatch = batch;
|
|
427
|
-
batch = [];
|
|
428
|
-
const timer = this.batchTimers.get(timerKey);
|
|
429
|
-
if (timer) {
|
|
430
|
-
clearTimeout(timer);
|
|
431
|
-
this.batchTimers.delete(timerKey);
|
|
432
|
-
}
|
|
433
|
-
const messages = currentBatch.map((item) => item.message);
|
|
434
|
-
const batchCount = currentBatch.length;
|
|
435
|
-
const startTime = Date.now();
|
|
436
|
-
const span = (0, _amqp_contract_core.startConsumeSpan)(this.telemetry, queueName, String(consumerName), { "amqp.batch.size": batchCount });
|
|
437
|
-
this.logger?.info("Processing batch", {
|
|
438
|
-
consumerName: String(consumerName),
|
|
439
|
-
queueName,
|
|
440
|
-
batchSize: batchCount
|
|
441
|
-
});
|
|
442
|
-
return handler(messages).flatMapOk(() => {
|
|
443
|
-
for (const item of currentBatch) this.amqpClient.channel.ack(item.amqpMessage);
|
|
444
|
-
this.logger?.info("Batch processed successfully", {
|
|
445
|
-
consumerName: String(consumerName),
|
|
446
|
-
queueName,
|
|
447
|
-
batchSize: batchCount
|
|
448
|
-
});
|
|
449
|
-
const durationMs = Date.now() - startTime;
|
|
450
|
-
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
451
|
-
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
452
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
453
|
-
}).flatMapError((handlerError) => {
|
|
454
|
-
this.logger?.error("Error processing batch", {
|
|
455
|
-
consumerName: String(consumerName),
|
|
456
|
-
queueName,
|
|
457
|
-
batchSize: batchCount,
|
|
458
|
-
errorType: handlerError.name,
|
|
459
|
-
error: handlerError.message
|
|
460
|
-
});
|
|
461
|
-
const durationMs = Date.now() - startTime;
|
|
462
|
-
(0, _amqp_contract_core.endSpanError)(span, handlerError);
|
|
463
|
-
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), false, durationMs);
|
|
464
|
-
return this.handleBatchError(handlerError, currentBatch, String(consumerName), consumer);
|
|
465
|
-
}).tap(() => {
|
|
466
|
-
isProcessing = false;
|
|
467
|
-
});
|
|
468
|
-
};
|
|
469
|
-
const scheduleBatchProcessing = () => {
|
|
470
|
-
if (isProcessing) return;
|
|
471
|
-
const existingTimer = this.batchTimers.get(timerKey);
|
|
472
|
-
if (existingTimer) clearTimeout(existingTimer);
|
|
473
|
-
const timer = setTimeout(() => {
|
|
474
|
-
processBatch().toPromise();
|
|
475
|
-
}, batchTimeout);
|
|
476
|
-
this.batchTimers.set(timerKey, timer);
|
|
477
|
-
};
|
|
478
|
-
return _swan_io_boxed.Future.fromPromise(this.amqpClient.channel.consume(queueName, async (msg) => {
|
|
479
|
-
if (msg === null) {
|
|
480
|
-
this.logger?.warn("Consumer cancelled by server", {
|
|
481
|
-
consumerName: String(consumerName),
|
|
482
|
-
queueName
|
|
483
|
-
});
|
|
484
|
-
await processBatch().toPromise();
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
const validationResult = await this.parseAndValidateMessage(msg, consumer, consumerName).toPromise();
|
|
488
|
-
if (validationResult.isError()) return;
|
|
489
|
-
batch.push({
|
|
490
|
-
message: validationResult.value,
|
|
491
|
-
amqpMessage: msg
|
|
492
|
-
});
|
|
493
|
-
if (batch.length >= batchSize) {
|
|
494
|
-
await processBatch().toPromise();
|
|
495
|
-
if (batch.length > 0 && !this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
496
|
-
} else if (!this.batchTimers.has(timerKey)) scheduleBatchProcessing();
|
|
497
|
-
})).tapOk((reply) => {
|
|
498
|
-
this.consumerTags.add(reply.consumerTag);
|
|
499
|
-
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
500
|
-
}
|
|
501
|
-
/**
|
|
502
481
|
* Handle error in message processing with retry logic.
|
|
503
482
|
*
|
|
504
|
-
* Flow:
|
|
483
|
+
* Flow depends on retry mode:
|
|
484
|
+
*
|
|
485
|
+
* **quorum-native mode:**
|
|
505
486
|
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
506
|
-
* 2.
|
|
507
|
-
*
|
|
508
|
-
*
|
|
487
|
+
* 2. Otherwise -> nack with requeue=true (RabbitMQ handles delivery count)
|
|
488
|
+
*
|
|
489
|
+
* **ttl-backoff mode:**
|
|
490
|
+
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
491
|
+
* 2. If max retries exceeded -> send to DLQ
|
|
492
|
+
* 3. Otherwise -> publish to wait queue with TTL for retry
|
|
493
|
+
*
|
|
494
|
+
* **Legacy mode (no retry config):**
|
|
495
|
+
* 1. nack with requeue=true (immediate requeue)
|
|
509
496
|
*/
|
|
510
497
|
handleError(error, msg, consumerName, consumer) {
|
|
511
498
|
if (error instanceof NonRetryableError) {
|
|
@@ -517,16 +504,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
517
504
|
this.sendToDLQ(msg, consumer);
|
|
518
505
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
519
506
|
}
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
507
|
+
const config = this.getRetryConfigForConsumer(consumer);
|
|
508
|
+
if (config.mode === "quorum-native") return this.handleErrorQuorumNative(error, msg, consumerName, consumer);
|
|
509
|
+
return this.handleErrorTtlBackoff(error, msg, consumerName, consumer, config);
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Handle error using quorum queue's native delivery limit feature.
|
|
513
|
+
*
|
|
514
|
+
* Simply requeues the message with nack(requeue=true). RabbitMQ automatically:
|
|
515
|
+
* - Increments x-delivery-count header
|
|
516
|
+
* - Dead-letters the message when count exceeds x-delivery-limit
|
|
517
|
+
*
|
|
518
|
+
* This is simpler than TTL-based retry but provides immediate retries only.
|
|
519
|
+
*/
|
|
520
|
+
handleErrorQuorumNative(error, msg, consumerName, consumer) {
|
|
521
|
+
const queue = consumer.queue;
|
|
522
|
+
const queueName = queue.name;
|
|
523
|
+
const deliveryCount = msg.properties.headers?.["x-delivery-count"] ?? 0;
|
|
524
|
+
const deliveryLimit = queue.type === "quorum" ? queue.deliveryLimit : void 0;
|
|
525
|
+
const attemptsBeforeDeadLetter = deliveryLimit !== void 0 ? Math.max(0, deliveryLimit - deliveryCount - 1) : "unknown";
|
|
526
|
+
if (deliveryLimit !== void 0 && deliveryCount >= deliveryLimit - 1) this.logger?.warn("Message at final delivery attempt (quorum-native mode)", {
|
|
527
|
+
consumerName,
|
|
528
|
+
queueName,
|
|
529
|
+
deliveryCount,
|
|
530
|
+
deliveryLimit,
|
|
531
|
+
willDeadLetterOnNextFailure: deliveryCount === deliveryLimit - 1,
|
|
532
|
+
alreadyExceededLimit: deliveryCount >= deliveryLimit,
|
|
533
|
+
error: error.message
|
|
534
|
+
});
|
|
535
|
+
else this.logger?.warn("Retrying message (quorum-native mode)", {
|
|
536
|
+
consumerName,
|
|
537
|
+
queueName,
|
|
538
|
+
deliveryCount,
|
|
539
|
+
deliveryLimit,
|
|
540
|
+
attemptsBeforeDeadLetter,
|
|
541
|
+
error: error.message
|
|
542
|
+
});
|
|
543
|
+
this.amqpClient.channel.nack(msg, false, true);
|
|
544
|
+
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Handle error using TTL + wait queue pattern for exponential backoff.
|
|
548
|
+
*/
|
|
549
|
+
handleErrorTtlBackoff(error, msg, consumerName, consumer, config) {
|
|
528
550
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
529
|
-
const config = this.retryConfig;
|
|
530
551
|
if (retryCount >= config.maxRetries) {
|
|
531
552
|
this.logger?.error("Max retries exceeded, sending to DLQ", {
|
|
532
553
|
consumerName,
|
|
@@ -537,8 +558,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
537
558
|
this.sendToDLQ(msg, consumer);
|
|
538
559
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
539
560
|
}
|
|
540
|
-
const delayMs = this.calculateRetryDelay(retryCount);
|
|
541
|
-
this.logger?.warn("Retrying message", {
|
|
561
|
+
const delayMs = this.calculateRetryDelay(retryCount, config);
|
|
562
|
+
this.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
542
563
|
consumerName,
|
|
543
564
|
retryCount: retryCount + 1,
|
|
544
565
|
delayMs,
|
|
@@ -549,8 +570,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
549
570
|
/**
|
|
550
571
|
* Calculate retry delay with exponential backoff and optional jitter.
|
|
551
572
|
*/
|
|
552
|
-
calculateRetryDelay(retryCount) {
|
|
553
|
-
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } =
|
|
573
|
+
calculateRetryDelay(retryCount, config) {
|
|
574
|
+
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
554
575
|
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
555
576
|
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
556
577
|
return Math.floor(delay);
|
|
@@ -672,19 +693,6 @@ function validateHandlers(contract, handlers) {
|
|
|
672
693
|
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
673
694
|
for (const handlerName of Object.keys(handlers)) if (!consumers || !(handlerName in consumers)) throw new Error(`Consumer "${handlerName}" not found in contract. Available consumers: ${availableConsumerNames}`);
|
|
674
695
|
}
|
|
675
|
-
/**
|
|
676
|
-
* Wrap a Promise-based handler into a Future-based safe handler.
|
|
677
|
-
* This is used internally by defineUnsafeHandler to convert Promise handlers to Future handlers.
|
|
678
|
-
*/
|
|
679
|
-
function wrapUnsafeHandler(handler) {
|
|
680
|
-
return (input) => {
|
|
681
|
-
return _swan_io_boxed.Future.fromPromise(handler(input)).mapOkToResult(() => _swan_io_boxed.Result.Ok(void 0)).flatMapError((error) => {
|
|
682
|
-
if (error instanceof NonRetryableError || error instanceof RetryableError) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(error));
|
|
683
|
-
const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
|
|
684
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(retryableError));
|
|
685
|
-
});
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
696
|
function defineHandler(contract, consumerName, handler, options) {
|
|
689
697
|
validateConsumerExists(contract, String(consumerName));
|
|
690
698
|
if (options) return [handler, options];
|
|
@@ -708,12 +716,12 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
708
716
|
* import { orderContract } from './contract';
|
|
709
717
|
*
|
|
710
718
|
* const handlers = defineHandlers(orderContract, {
|
|
711
|
-
* processOrder: (
|
|
712
|
-
* Future.fromPromise(processPayment(
|
|
719
|
+
* processOrder: ({ payload }) =>
|
|
720
|
+
* Future.fromPromise(processPayment(payload))
|
|
713
721
|
* .mapOk(() => undefined)
|
|
714
722
|
* .mapError((error) => new RetryableError('Payment failed', error)),
|
|
715
|
-
* notifyOrder: (
|
|
716
|
-
* Future.fromPromise(sendNotification(
|
|
723
|
+
* notifyOrder: ({ payload }) =>
|
|
724
|
+
* Future.fromPromise(sendNotification(payload))
|
|
717
725
|
* .mapOk(() => undefined)
|
|
718
726
|
* .mapError((error) => new RetryableError('Notification failed', error)),
|
|
719
727
|
* });
|
|
@@ -723,52 +731,6 @@ function defineHandlers(contract, handlers) {
|
|
|
723
731
|
validateHandlers(contract, handlers);
|
|
724
732
|
return handlers;
|
|
725
733
|
}
|
|
726
|
-
function defineUnsafeHandler(contract, consumerName, handler, options) {
|
|
727
|
-
validateConsumerExists(contract, String(consumerName));
|
|
728
|
-
const wrappedHandler = wrapUnsafeHandler(handler);
|
|
729
|
-
if (options) return [wrappedHandler, options];
|
|
730
|
-
return wrappedHandler;
|
|
731
|
-
}
|
|
732
|
-
/**
|
|
733
|
-
* Define multiple unsafe handlers for consumers in a contract.
|
|
734
|
-
*
|
|
735
|
-
* @deprecated Use `defineHandlers` instead for explicit error handling with `Future<Result>`.
|
|
736
|
-
*
|
|
737
|
-
* **Warning:** Unsafe handlers use exception-based error handling.
|
|
738
|
-
* Consider migrating to safe handlers for better error control.
|
|
739
|
-
*
|
|
740
|
-
* **Note:** Internally, this function wraps all Promise-based handlers into Future-based
|
|
741
|
-
* safe handlers for consistent processing in the worker.
|
|
742
|
-
*
|
|
743
|
-
* @template TContract - The contract definition type
|
|
744
|
-
* @param contract - The contract definition containing the consumers
|
|
745
|
-
* @param handlers - An object with async handler functions for each consumer
|
|
746
|
-
* @returns A type-safe handlers object that can be used with TypedAmqpWorker
|
|
747
|
-
*
|
|
748
|
-
* @example
|
|
749
|
-
* ```typescript
|
|
750
|
-
* import { defineUnsafeHandlers } from '@amqp-contract/worker';
|
|
751
|
-
*
|
|
752
|
-
* // ⚠️ Consider using defineHandlers for better error handling
|
|
753
|
-
* const handlers = defineUnsafeHandlers(orderContract, {
|
|
754
|
-
* processOrder: async (message) => {
|
|
755
|
-
* await processPayment(message);
|
|
756
|
-
* },
|
|
757
|
-
* notifyOrder: async (message) => {
|
|
758
|
-
* await sendNotification(message);
|
|
759
|
-
* },
|
|
760
|
-
* });
|
|
761
|
-
* ```
|
|
762
|
-
*/
|
|
763
|
-
function defineUnsafeHandlers(contract, handlers) {
|
|
764
|
-
validateHandlers(contract, handlers);
|
|
765
|
-
const result = {};
|
|
766
|
-
for (const [name, entry] of Object.entries(handlers)) if (Array.isArray(entry)) {
|
|
767
|
-
const [handler, options] = entry;
|
|
768
|
-
result[name] = [wrapUnsafeHandler(handler), options];
|
|
769
|
-
} else result[name] = wrapUnsafeHandler(entry);
|
|
770
|
-
return result;
|
|
771
|
-
}
|
|
772
734
|
|
|
773
735
|
//#endregion
|
|
774
736
|
exports.MessageValidationError = MessageValidationError;
|
|
@@ -777,6 +739,4 @@ exports.RetryableError = RetryableError;
|
|
|
777
739
|
exports.TechnicalError = TechnicalError;
|
|
778
740
|
exports.TypedAmqpWorker = TypedAmqpWorker;
|
|
779
741
|
exports.defineHandler = defineHandler;
|
|
780
|
-
exports.defineHandlers = defineHandlers;
|
|
781
|
-
exports.defineUnsafeHandler = defineUnsafeHandler;
|
|
782
|
-
exports.defineUnsafeHandlers = defineUnsafeHandlers;
|
|
742
|
+
exports.defineHandlers = defineHandlers;
|