@amqp-contract/worker 0.10.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 -246
- 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 -244
- 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,41 +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
|
-
await channel.bindQueue(queueName, dlxName, queueName);
|
|
256
|
-
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", {
|
|
257
252
|
consumerName: String(consumerName),
|
|
258
|
-
queueName
|
|
259
|
-
waitQueueName,
|
|
260
|
-
dlxName
|
|
253
|
+
queueName: queue.name
|
|
261
254
|
});
|
|
262
|
-
}
|
|
263
|
-
|
|
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
|
+
});
|
|
264
263
|
}
|
|
265
|
-
|
|
266
|
-
|
|
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;
|
|
267
313
|
}
|
|
268
314
|
/**
|
|
269
315
|
* Start consuming messages for all consumers
|
|
@@ -278,10 +324,6 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
278
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`)));
|
|
279
325
|
maxPrefetch = Math.max(maxPrefetch, options.prefetch);
|
|
280
326
|
}
|
|
281
|
-
if (options?.batchSize !== void 0) {
|
|
282
|
-
const effectivePrefetch = options.prefetch ?? options.batchSize;
|
|
283
|
-
maxPrefetch = Math.max(maxPrefetch, effectivePrefetch);
|
|
284
|
-
}
|
|
285
327
|
}
|
|
286
328
|
if (maxPrefetch > 0) this.amqpClient.channel.addSetup(async (channel) => {
|
|
287
329
|
await channel.prefetch(maxPrefetch);
|
|
@@ -305,19 +347,11 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
305
347
|
}
|
|
306
348
|
const handler = this.actualHandlers[consumerName];
|
|
307
349
|
if (!handler) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(new TechnicalError(`Handler for "${String(consumerName)}" not provided`)));
|
|
308
|
-
|
|
309
|
-
if (options.batchSize !== void 0) {
|
|
310
|
-
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`)));
|
|
311
|
-
}
|
|
312
|
-
if (options.batchTimeout !== void 0) {
|
|
313
|
-
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`)));
|
|
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);
|
|
350
|
+
return this.consumeSingle(consumerName, consumer, handler);
|
|
317
351
|
}
|
|
318
352
|
/**
|
|
319
353
|
* Parse and validate a message from AMQP
|
|
320
|
-
* @returns `Future<Result<
|
|
354
|
+
* @returns `Future<Result<consumed message, void>>` - Ok with validated consumed message (payload + headers), or Error (already handled with nack)
|
|
321
355
|
*/
|
|
322
356
|
parseAndValidateMessage(msg, consumer, consumerName) {
|
|
323
357
|
const decompressMessage = _swan_io_boxed.Future.fromPromise(decompressBuffer(msg.content, msg.properties.contentEncoding)).tapError((error) => {
|
|
@@ -328,7 +362,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
328
362
|
error
|
|
329
363
|
});
|
|
330
364
|
this.amqpClient.channel.nack(msg, false, false);
|
|
331
|
-
});
|
|
365
|
+
}).mapError(() => void 0);
|
|
332
366
|
const parseMessage = (buffer) => {
|
|
333
367
|
const parseResult = _swan_io_boxed.Result.fromExecution(() => JSON.parse(buffer.toString()));
|
|
334
368
|
if (parseResult.isError()) {
|
|
@@ -342,12 +376,12 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
342
376
|
}
|
|
343
377
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(parseResult.value));
|
|
344
378
|
};
|
|
345
|
-
const
|
|
379
|
+
const validatePayload = (parsedMessage) => {
|
|
346
380
|
const rawValidation = consumer.message.payload["~standard"].validate(parsedMessage);
|
|
347
|
-
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) => {
|
|
348
382
|
if (validationResult.issues) {
|
|
349
383
|
const error = new MessageValidationError(String(consumerName), validationResult.issues);
|
|
350
|
-
this.logger?.error("Message validation failed", {
|
|
384
|
+
this.logger?.error("Message payload validation failed", {
|
|
351
385
|
consumerName: String(consumerName),
|
|
352
386
|
queueName: consumer.queue.name,
|
|
353
387
|
error
|
|
@@ -358,7 +392,45 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
358
392
|
return _swan_io_boxed.Result.Ok(validationResult.value);
|
|
359
393
|
});
|
|
360
394
|
};
|
|
361
|
-
|
|
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);
|
|
362
434
|
}
|
|
363
435
|
/**
|
|
364
436
|
* Consume messages one at a time
|
|
@@ -375,7 +447,7 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
375
447
|
}
|
|
376
448
|
const startTime = Date.now();
|
|
377
449
|
const span = (0, _amqp_contract_core.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(() => {
|
|
450
|
+
await this.parseAndValidateMessage(msg, consumer, consumerName).flatMapOk((validatedMessage) => handler(validatedMessage, msg).flatMapOk(() => {
|
|
379
451
|
this.logger?.info("Message consumed successfully", {
|
|
380
452
|
consumerName: String(consumerName),
|
|
381
453
|
queueName
|
|
@@ -406,107 +478,21 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
406
478
|
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
407
479
|
}
|
|
408
480
|
/**
|
|
409
|
-
* Handle batch processing error by applying error handling to all messages.
|
|
410
|
-
*/
|
|
411
|
-
handleBatchError(error, currentBatch, consumerName, consumer) {
|
|
412
|
-
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);
|
|
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 _swan_io_boxed.Future.value(_swan_io_boxed.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 = (0, _amqp_contract_core.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
|
-
(0, _amqp_contract_core.endSpanSuccess)(span);
|
|
452
|
-
(0, _amqp_contract_core.recordConsumeMetric)(this.telemetry, queueName, String(consumerName), true, durationMs);
|
|
453
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.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
|
-
(0, _amqp_contract_core.endSpanError)(span, handlerError);
|
|
464
|
-
(0, _amqp_contract_core.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 _swan_io_boxed.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);
|
|
500
|
-
}).mapError((error) => new TechnicalError(`Failed to start consuming for "${String(consumerName)}"`, error)).mapOk(() => void 0);
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
481
|
* Handle error in message processing with retry logic.
|
|
504
482
|
*
|
|
505
|
-
* Flow:
|
|
483
|
+
* Flow depends on retry mode:
|
|
484
|
+
*
|
|
485
|
+
* **quorum-native mode:**
|
|
506
486
|
* 1. If NonRetryableError -> send directly to DLQ (no retry)
|
|
507
|
-
* 2.
|
|
508
|
-
*
|
|
509
|
-
*
|
|
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)
|
|
510
496
|
*/
|
|
511
497
|
handleError(error, msg, consumerName, consumer) {
|
|
512
498
|
if (error instanceof NonRetryableError) {
|
|
@@ -518,16 +504,50 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
518
504
|
this.sendToDLQ(msg, consumer);
|
|
519
505
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
520
506
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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) {
|
|
529
550
|
const retryCount = msg.properties.headers?.["x-retry-count"] ?? 0;
|
|
530
|
-
const config = this.retryConfig;
|
|
531
551
|
if (retryCount >= config.maxRetries) {
|
|
532
552
|
this.logger?.error("Max retries exceeded, sending to DLQ", {
|
|
533
553
|
consumerName,
|
|
@@ -538,8 +558,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
538
558
|
this.sendToDLQ(msg, consumer);
|
|
539
559
|
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Ok(void 0));
|
|
540
560
|
}
|
|
541
|
-
const delayMs = this.calculateRetryDelay(retryCount);
|
|
542
|
-
this.logger?.warn("Retrying message", {
|
|
561
|
+
const delayMs = this.calculateRetryDelay(retryCount, config);
|
|
562
|
+
this.logger?.warn("Retrying message (ttl-backoff mode)", {
|
|
543
563
|
consumerName,
|
|
544
564
|
retryCount: retryCount + 1,
|
|
545
565
|
delayMs,
|
|
@@ -550,8 +570,8 @@ var TypedAmqpWorker = class TypedAmqpWorker {
|
|
|
550
570
|
/**
|
|
551
571
|
* Calculate retry delay with exponential backoff and optional jitter.
|
|
552
572
|
*/
|
|
553
|
-
calculateRetryDelay(retryCount) {
|
|
554
|
-
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } =
|
|
573
|
+
calculateRetryDelay(retryCount, config) {
|
|
574
|
+
const { initialDelayMs, maxDelayMs, backoffMultiplier, jitter } = config;
|
|
555
575
|
let delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, retryCount), maxDelayMs);
|
|
556
576
|
if (jitter) delay = delay * (.5 + Math.random() * .5);
|
|
557
577
|
return Math.floor(delay);
|
|
@@ -673,19 +693,6 @@ function validateHandlers(contract, handlers) {
|
|
|
673
693
|
const availableConsumerNames = availableConsumers.length > 0 ? availableConsumers.join(", ") : "none";
|
|
674
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}`);
|
|
675
695
|
}
|
|
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 _swan_io_boxed.Future.fromPromise(handler(input)).mapOkToResult(() => _swan_io_boxed.Result.Ok(void 0)).flatMapError((error) => {
|
|
683
|
-
if (error instanceof NonRetryableError || error instanceof RetryableError) return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(error));
|
|
684
|
-
const retryableError = new RetryableError(error instanceof Error ? error.message : String(error), error);
|
|
685
|
-
return _swan_io_boxed.Future.value(_swan_io_boxed.Result.Error(retryableError));
|
|
686
|
-
});
|
|
687
|
-
};
|
|
688
|
-
}
|
|
689
696
|
function defineHandler(contract, consumerName, handler, options) {
|
|
690
697
|
validateConsumerExists(contract, String(consumerName));
|
|
691
698
|
if (options) return [handler, options];
|
|
@@ -709,12 +716,12 @@ function defineHandler(contract, consumerName, handler, options) {
|
|
|
709
716
|
* import { orderContract } from './contract';
|
|
710
717
|
*
|
|
711
718
|
* const handlers = defineHandlers(orderContract, {
|
|
712
|
-
* processOrder: (
|
|
713
|
-
* Future.fromPromise(processPayment(
|
|
719
|
+
* processOrder: ({ payload }) =>
|
|
720
|
+
* Future.fromPromise(processPayment(payload))
|
|
714
721
|
* .mapOk(() => undefined)
|
|
715
722
|
* .mapError((error) => new RetryableError('Payment failed', error)),
|
|
716
|
-
* notifyOrder: (
|
|
717
|
-
* Future.fromPromise(sendNotification(
|
|
723
|
+
* notifyOrder: ({ payload }) =>
|
|
724
|
+
* Future.fromPromise(sendNotification(payload))
|
|
718
725
|
* .mapOk(() => undefined)
|
|
719
726
|
* .mapError((error) => new RetryableError('Notification failed', error)),
|
|
720
727
|
* });
|
|
@@ -724,52 +731,6 @@ function defineHandlers(contract, handlers) {
|
|
|
724
731
|
validateHandlers(contract, handlers);
|
|
725
732
|
return handlers;
|
|
726
733
|
}
|
|
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
734
|
|
|
774
735
|
//#endregion
|
|
775
736
|
exports.MessageValidationError = MessageValidationError;
|
|
@@ -778,6 +739,4 @@ exports.RetryableError = RetryableError;
|
|
|
778
739
|
exports.TechnicalError = TechnicalError;
|
|
779
740
|
exports.TypedAmqpWorker = TypedAmqpWorker;
|
|
780
741
|
exports.defineHandler = defineHandler;
|
|
781
|
-
exports.defineHandlers = defineHandlers;
|
|
782
|
-
exports.defineUnsafeHandler = defineUnsafeHandler;
|
|
783
|
-
exports.defineUnsafeHandlers = defineUnsafeHandlers;
|
|
742
|
+
exports.defineHandlers = defineHandlers;
|