@zdavison/matador 2.0.8 → 2.0.10

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.
Files changed (77) hide show
  1. package/dist/index.d.cts +1 -1
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.d.ts.map +1 -1
  4. package/package.json +6 -2
  5. package/examples/config.ts +0 -126
  6. package/examples/event.ts +0 -26
  7. package/examples/order-event.json +0 -19
  8. package/src/checkpoint/context.test.ts +0 -510
  9. package/src/checkpoint/context.ts +0 -213
  10. package/src/checkpoint/index.ts +0 -30
  11. package/src/checkpoint/stores/memory.ts +0 -47
  12. package/src/checkpoint/stores/noop.ts +0 -22
  13. package/src/checkpoint/stores/stores.test.ts +0 -177
  14. package/src/checkpoint/types.ts +0 -147
  15. package/src/codec/codec.ts +0 -42
  16. package/src/codec/header-aware-codec.ts +0 -41
  17. package/src/codec/index.ts +0 -11
  18. package/src/codec/json-codec.ts +0 -69
  19. package/src/codec/rabbitmq-codec.test.ts +0 -516
  20. package/src/codec/rabbitmq-codec.ts +0 -336
  21. package/src/core/fanout.test.ts +0 -1350
  22. package/src/core/fanout.ts +0 -184
  23. package/src/core/index.ts +0 -12
  24. package/src/core/matador.test.ts +0 -575
  25. package/src/core/matador.ts +0 -357
  26. package/src/core/shutdown.test.ts +0 -853
  27. package/src/core/shutdown.ts +0 -165
  28. package/src/errors/checkpoint-errors.ts +0 -62
  29. package/src/errors/has-description.ts +0 -25
  30. package/src/errors/index.ts +0 -58
  31. package/src/errors/matador-errors.ts +0 -477
  32. package/src/errors/retry-errors.test.ts +0 -175
  33. package/src/errors/retry-errors.ts +0 -188
  34. package/src/hooks/index.ts +0 -14
  35. package/src/hooks/safe-hooks.ts +0 -200
  36. package/src/hooks/types.ts +0 -226
  37. package/src/index.ts +0 -241
  38. package/src/pipeline/index.ts +0 -2
  39. package/src/pipeline/pipeline.test.ts +0 -1377
  40. package/src/pipeline/pipeline.ts +0 -393
  41. package/src/retry/index.ts +0 -4
  42. package/src/retry/policy.ts +0 -46
  43. package/src/retry/standard-policy.test.ts +0 -290
  44. package/src/retry/standard-policy.ts +0 -156
  45. package/src/schema/index.ts +0 -16
  46. package/src/schema/registry.test.ts +0 -339
  47. package/src/schema/registry.ts +0 -229
  48. package/src/schema/types.test.ts +0 -280
  49. package/src/schema/types.ts +0 -217
  50. package/src/topology/builder.test.ts +0 -451
  51. package/src/topology/builder.ts +0 -238
  52. package/src/topology/index.ts +0 -19
  53. package/src/topology/types.ts +0 -183
  54. package/src/transport/capabilities.ts +0 -88
  55. package/src/transport/connection-manager.ts +0 -218
  56. package/src/transport/index.ts +0 -42
  57. package/src/transport/local/local-transport.test.ts +0 -262
  58. package/src/transport/local/local-transport.ts +0 -330
  59. package/src/transport/multi/multi-transport.test.ts +0 -320
  60. package/src/transport/multi/multi-transport.ts +0 -294
  61. package/src/transport/rabbitmq/rabbitmq-transport.test.ts +0 -120
  62. package/src/transport/rabbitmq/rabbitmq-transport.ts +0 -782
  63. package/src/transport/transport.ts +0 -200
  64. package/src/types/common.ts +0 -53
  65. package/src/types/dispatcher.ts +0 -18
  66. package/src/types/envelope.ts +0 -244
  67. package/src/types/event.test.ts +0 -157
  68. package/src/types/event.ts +0 -112
  69. package/src/types/index.ts +0 -62
  70. package/src/types/subscriber.ts +0 -333
  71. package/test/e2e/multi-transport.e2e.test.ts +0 -237
  72. package/test/e2e/rabbitmq-transport.e2e.test.ts +0 -618
  73. package/test/e2e/transport-compliance.e2e.test.ts +0 -506
  74. package/test/integration/matador.integration.test.ts +0 -634
  75. package/tsconfig.json +0 -29
  76. package/tsconfig.tsbuildinfo +0 -1
  77. package/tsup.config.ts +0 -13
@@ -1,782 +0,0 @@
1
- import type { Channel, ChannelModel, ConsumeMessage, Options } from 'amqplib';
2
- import amqplib from 'amqplib';
3
- import { RabbitMQCodec } from '../../codec/rabbitmq-codec.js';
4
- import {
5
- DelayedMessagesNotSupportedError,
6
- TransportNotConnectedError,
7
- } from '../../errors/index.js';
8
- import { type Logger, consoleLogger } from '../../hooks/index.js';
9
- import type { QueueDefinition, Topology } from '../../topology/types.js';
10
- import type { Envelope } from '../../types/index.js';
11
- import type { TransportCapabilities } from '../capabilities.js';
12
- import {
13
- ConnectionManager,
14
- type ConnectionManagerConfig,
15
- } from '../connection-manager.js';
16
- import type {
17
- MessageHandler,
18
- MessageReceipt,
19
- SendOptions,
20
- SubscribeOptions,
21
- Subscription,
22
- Transport,
23
- } from '../transport.js';
24
-
25
- /**
26
- * Configuration options for the RabbitMQ transport.
27
- */
28
- export interface RabbitMQTransportConfig {
29
- /** RabbitMQ connection URL */
30
- readonly url: string;
31
-
32
- /** Connection name displayed in RabbitMQ management UI */
33
- readonly connectionName: string;
34
-
35
- /** Connection manager configuration */
36
- readonly connection?: Partial<ConnectionManagerConfig> | undefined;
37
-
38
- /** Use quorum queues for durability (default: true) */
39
- readonly quorumQueues?: boolean | undefined;
40
-
41
- /** Default prefetch count per consumer (default: 10) */
42
- readonly defaultPrefetch?: number | undefined;
43
-
44
- /** Enable the delayed message exchange plugin if available (default: true) */
45
- readonly enableDelayedMessages?: boolean | undefined;
46
-
47
- /** Logger for transport events (defaults to console) */
48
- readonly logger?: Logger | undefined;
49
- }
50
-
51
- /**
52
- * Internal structure for tracking a queue's dedicated channel.
53
- */
54
- interface QueueChannel {
55
- readonly channel: Channel;
56
- readonly consumers: ActiveConsumer[];
57
- }
58
-
59
- /**
60
- * Internal structure for tracking active consumers.
61
- */
62
- interface ActiveConsumer {
63
- readonly consumerTag: string;
64
- readonly queue: string;
65
- active: boolean;
66
- }
67
-
68
- /**
69
- * Redacts credentials from an AMQP URL.
70
- * Replaces username and password with '****' regardless of their length.
71
- *
72
- * @example
73
- * redactAmqpUrl('amqp://user:pass@host:5672') // 'amqp://****:****@host:5672'
74
- */
75
- export function redactAmqpUrl(url: string): string {
76
- const regex = /^(amqps?:\/\/)[^:]+:[^@]+@/;
77
- return url.replace(regex, '$1****:****@');
78
- }
79
-
80
- /**
81
- * RabbitMQ transport implementation using amqplib.
82
- */
83
- export class RabbitMQTransport implements Transport {
84
- readonly name = 'rabbitmq';
85
-
86
- private _capabilities: TransportCapabilities = {
87
- deliveryModes: ['at-least-once'],
88
- delayedMessages: false,
89
- deadLetterRouting: 'native',
90
- attemptTracking: true,
91
- concurrencyModel: 'prefetch',
92
- ordering: 'none',
93
- priorities: true,
94
- };
95
-
96
- get capabilities(): TransportCapabilities {
97
- return this._capabilities;
98
- }
99
-
100
- private connection: ChannelModel | null = null;
101
- private publishChannel: Channel | null = null;
102
- private readonly connectionManager: ConnectionManager;
103
- private readonly queueChannels = new Map<string, QueueChannel>();
104
- private topology: Topology | null = null;
105
- private readonly codec = new RabbitMQCodec();
106
-
107
- private readonly config: Required<
108
- Omit<RabbitMQTransportConfig, 'connection' | 'logger'>
109
- > & {
110
- readonly connection: Partial<ConnectionManagerConfig>;
111
- };
112
-
113
- private readonly logger: Logger;
114
- private delayedExchangeAvailable = false;
115
-
116
- constructor(config: RabbitMQTransportConfig) {
117
- this.logger = config.logger ?? consoleLogger;
118
- this.config = {
119
- url: config.url,
120
- connectionName: config.connectionName,
121
- connection: config.connection ?? {},
122
- quorumQueues: config.quorumQueues ?? true,
123
- defaultPrefetch: config.defaultPrefetch ?? 10,
124
- enableDelayedMessages: config.enableDelayedMessages ?? true,
125
- };
126
-
127
- this.connectionManager = new ConnectionManager(
128
- () => this.doConnect(),
129
- () => this.doDisconnect(),
130
- this.config.connection,
131
- );
132
- }
133
-
134
- async connect(): Promise<void> {
135
- await this.connectionManager.connect();
136
- }
137
-
138
- async disconnect(): Promise<void> {
139
- await this.connectionManager.disconnect();
140
- }
141
-
142
- isConnected(): boolean {
143
- return this.connectionManager.isConnected();
144
- }
145
-
146
- async applyTopology(topology: Topology): Promise<void> {
147
- this.topology = topology;
148
-
149
- if (!this.publishChannel) {
150
- throw new TransportNotConnectedError(this.name, 'applyTopology');
151
- }
152
-
153
- const channel = this.publishChannel;
154
-
155
- // Create the main exchange for routing messages to queues
156
- const mainExchange = this.getMainExchangeName(topology.namespace);
157
- await channel.assertExchange(mainExchange, 'direct', { durable: true });
158
-
159
- // Create dead-letter exchange if DLQ is enabled
160
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
161
- if (
162
- topology.deadLetter.unhandled.enabled ||
163
- topology.deadLetter.undeliverable.enabled
164
- ) {
165
- await channel.assertExchange(dlxExchange, 'direct', { durable: true });
166
- }
167
-
168
- // Check for delayed message exchange plugin
169
- if (this.config.enableDelayedMessages) {
170
- await this.setupDelayedExchange(topology.namespace);
171
- }
172
-
173
- // Create work queues
174
- for (const queueDef of topology.queues) {
175
- await this.assertWorkQueue(channel, topology, queueDef);
176
- }
177
-
178
- // Create DLQs
179
- if (topology.deadLetter.unhandled.enabled) {
180
- await this.assertDeadLetterQueues(channel, topology, 'unhandled');
181
- }
182
-
183
- if (topology.deadLetter.undeliverable.enabled) {
184
- await this.assertDeadLetterQueues(channel, topology, 'undeliverable');
185
- }
186
- }
187
-
188
- async send(
189
- queue: string,
190
- envelope: Envelope,
191
- options?: SendOptions,
192
- ): Promise<Transport['name']> {
193
- if (!this.publishChannel || !this.topology) {
194
- throw new TransportNotConnectedError(this.name, 'send');
195
- }
196
-
197
- const encoded = this.codec.encode(envelope);
198
- const buffer = Buffer.from(encoded.body);
199
-
200
- const publishOptions: Options.Publish = {
201
- persistent: true,
202
- contentType: encoded.contentType,
203
- messageId: envelope.id,
204
- timestamp: Date.now(),
205
- headers: encoded.headers,
206
- };
207
-
208
- if (options?.priority !== undefined) {
209
- publishOptions.priority = options.priority;
210
- }
211
-
212
- // Handle delayed messages
213
- if (options?.delay !== undefined && options.delay > 0) {
214
- if (!this.delayedExchangeAvailable) {
215
- throw new DelayedMessagesNotSupportedError(this.name);
216
- }
217
-
218
- const delayedExchange = this.getDelayedExchangeName(
219
- this.topology.namespace,
220
- );
221
- publishOptions.headers = {
222
- ...publishOptions.headers,
223
- 'x-delay': options.delay,
224
- };
225
- this.publishChannel.publish(
226
- delayedExchange,
227
- queue,
228
- buffer,
229
- publishOptions,
230
- );
231
- return this.name;
232
- }
233
-
234
- // Transport-specific options
235
- if (options?.transport?.rabbitmq?.expiration !== undefined) {
236
- publishOptions.expiration = String(options.transport.rabbitmq.expiration);
237
- }
238
-
239
- if (options?.transport?.rabbitmq?.persistent !== undefined) {
240
- publishOptions.persistent = options.transport.rabbitmq.persistent;
241
- }
242
-
243
- const routingKey = options?.transport?.rabbitmq?.routingKey ?? queue;
244
- const exchange = this.getMainExchangeName(this.topology.namespace);
245
-
246
- this.publishChannel.publish(exchange, routingKey, buffer, publishOptions);
247
- return this.name;
248
- }
249
-
250
- async subscribe(
251
- queue: string,
252
- handler: MessageHandler,
253
- options: SubscribeOptions = {},
254
- ): Promise<Subscription> {
255
- if (!this.connection || !this.topology) {
256
- throw new TransportNotConnectedError(this.name, 'subscribe');
257
- }
258
-
259
- // Get or create a dedicated channel for this queue
260
- const queueChannel = await this.getOrCreateQueueChannel(queue, options);
261
- const { channel } = queueChannel;
262
-
263
- const consumer: ActiveConsumer = {
264
- consumerTag: '',
265
- queue,
266
- active: true,
267
- };
268
-
269
- const { consumerTag } = await channel.consume(
270
- queue,
271
- async (msg: ConsumeMessage | null) => {
272
- if (!msg || !consumer.active) return;
273
-
274
- const attemptNumber = this.getAttemptNumber(msg);
275
- const receipt: MessageReceipt = {
276
- handle: { channel, msg },
277
- redelivered: msg.fields.redelivered,
278
- attemptNumber,
279
- deliveryCount: this.getDeliveryCount(msg, attemptNumber),
280
- sourceQueue: queue,
281
- sourceTransport: this.name,
282
- };
283
-
284
- try {
285
- const headers = (msg.properties.headers ?? {}) as Record<
286
- string,
287
- unknown
288
- >;
289
- const envelope = this.codec.decode(
290
- new Uint8Array(msg.content),
291
- headers,
292
- );
293
- await handler(envelope, receipt);
294
- } catch (error) {
295
- // Handler errors should be caught in the pipeline
296
- this.logger.error(
297
- '[Matador] 🔴 Handler error in message processing',
298
- error,
299
- );
300
- }
301
- },
302
- { noAck: false }, // Always manually ack
303
- );
304
-
305
- // Update the consumer tag
306
- (consumer as { consumerTag: string }).consumerTag = consumerTag;
307
-
308
- // Track the consumer
309
- queueChannel.consumers.push(consumer);
310
-
311
- return {
312
- unsubscribe: async () => {
313
- consumer.active = false;
314
- try {
315
- await channel.cancel(consumerTag);
316
- } catch {
317
- // Channel may already be closed
318
- }
319
-
320
- // Remove consumer from tracking
321
- const idx = queueChannel.consumers.indexOf(consumer);
322
- if (idx !== -1) {
323
- queueChannel.consumers.splice(idx, 1);
324
- }
325
-
326
- // Close channel if no more consumers on this queue
327
- if (queueChannel.consumers.length === 0) {
328
- try {
329
- await channel.close();
330
- } catch {
331
- // Ignore
332
- }
333
- this.queueChannels.delete(queue);
334
- }
335
- },
336
- get isActive() {
337
- return consumer.active;
338
- },
339
- };
340
- }
341
-
342
- async complete(receipt: MessageReceipt): Promise<void> {
343
- const { channel, msg } = receipt.handle as {
344
- channel: Channel;
345
- msg: ConsumeMessage;
346
- };
347
-
348
- try {
349
- channel.ack(msg);
350
- } catch {
351
- // Channel may be closed, ignore
352
- }
353
- }
354
-
355
- async sendToDeadLetter(
356
- receipt: MessageReceipt,
357
- dlqName: string,
358
- envelope: Envelope,
359
- reason: string,
360
- ): Promise<void> {
361
- if (!this.publishChannel || !this.topology) {
362
- throw new TransportNotConnectedError(this.name, 'sendToDeadLetter');
363
- }
364
-
365
- // Add error info to envelope
366
- const dlqEnvelope: Envelope = {
367
- ...envelope,
368
- docket: {
369
- ...envelope.docket,
370
- lastError: reason,
371
- firstError: envelope.docket.firstError ?? reason,
372
- originalQueue: receipt.sourceQueue,
373
- },
374
- };
375
-
376
- const encoded = this.codec.encode(dlqEnvelope);
377
- const buffer = Buffer.from(encoded.body);
378
- const dlxExchange = this.getDLXExchangeName(this.topology.namespace);
379
- const dlqQueueName = `${receipt.sourceQueue}.${dlqName}`;
380
-
381
- const publishOptions: Options.Publish = {
382
- persistent: true,
383
- contentType: encoded.contentType,
384
- messageId: envelope.id,
385
- timestamp: Date.now(),
386
- headers: {
387
- ...encoded.headers,
388
- 'x-matador-dead-letter-reason': reason,
389
- },
390
- };
391
-
392
- this.publishChannel.publish(
393
- dlxExchange,
394
- dlqQueueName,
395
- buffer,
396
- publishOptions,
397
- );
398
-
399
- // Complete the original message
400
- await this.complete(receipt);
401
- }
402
-
403
- // Private methods
404
-
405
- /**
406
- * Gets or creates a dedicated channel for a queue subscription.
407
- *
408
- * We create separate channels per subscribed queue to enable independent
409
- * prefetch/concurrency control.
410
- */
411
- private async getOrCreateQueueChannel(
412
- queue: string,
413
- options: SubscribeOptions,
414
- ): Promise<QueueChannel> {
415
- const existing = this.queueChannels.get(queue);
416
- if (existing) {
417
- return existing;
418
- }
419
-
420
- if (!this.connection) {
421
- throw new TransportNotConnectedError(
422
- this.name,
423
- 'getOrCreateQueueChannel',
424
- );
425
- }
426
-
427
- // Create a dedicated channel for this queue to control prefetch independently
428
- const channel = await this.connection.createChannel();
429
-
430
- const prefetch =
431
- options.transport?.rabbitmq?.prefetch ??
432
- options.concurrency ??
433
- this.config.defaultPrefetch ??
434
- 10;
435
-
436
- await channel.prefetch(prefetch);
437
-
438
- const queueChannel: QueueChannel = {
439
- channel,
440
- consumers: [],
441
- };
442
-
443
- this.queueChannels.set(queue, queueChannel);
444
-
445
- return queueChannel;
446
- }
447
-
448
- private async doConnect(): Promise<void> {
449
- this.logger.info(
450
- `[Matador] ⏳ Connecting to RabbitMQ at '${redactAmqpUrl(this.config.url)}'.`,
451
- );
452
- const connection = await amqplib.connect(this.config.url, {
453
- clientProperties: { connection_name: this.config.connectionName },
454
- });
455
- this.connection = connection;
456
-
457
- // Handle connection errors - let ConnectionManager handle reconnection
458
- connection.on('error', (err: Error) => {
459
- this.logger.error('[Matador] 🔴 RabbitMQ connection error', err);
460
- });
461
-
462
- connection.on('close', () => {
463
- if (this.connectionManager.isConnected()) {
464
- // Unexpected close, trigger reconnection
465
- this.connectionManager.handleConnectionLost(
466
- new Error('Connection closed unexpectedly'),
467
- );
468
- }
469
- });
470
-
471
- // Create the publish channel
472
- this.publishChannel = await connection.createChannel();
473
-
474
- // Handle publish channel errors to prevent unhandled error events
475
- this.publishChannel.on('error', (err: Error) => {
476
- this.logger.error('[Matador] 🔴 RabbitMQ publish channel error', err);
477
- });
478
-
479
- // Re-apply topology if we have one (reconnection scenario)
480
- if (this.topology) {
481
- await this.applyTopology(this.topology);
482
- }
483
- }
484
-
485
- private async doDisconnect(): Promise<void> {
486
- // Cancel all consumers and close queue channels
487
- for (const queueChannel of this.queueChannels.values()) {
488
- for (const consumer of queueChannel.consumers) {
489
- consumer.active = false;
490
- try {
491
- await queueChannel.channel.cancel(consumer.consumerTag);
492
- } catch {
493
- // Ignore errors during cleanup
494
- }
495
- }
496
- try {
497
- await queueChannel.channel.close();
498
- } catch {
499
- // Ignore
500
- }
501
- }
502
- this.queueChannels.clear();
503
-
504
- // Close publish channel
505
- if (this.publishChannel) {
506
- try {
507
- await this.publishChannel.close();
508
- } catch {
509
- // Ignore
510
- }
511
- this.publishChannel = null;
512
- }
513
-
514
- // Close connection
515
- if (this.connection) {
516
- try {
517
- await this.connection.close();
518
- } catch {
519
- // Ignore
520
- }
521
- this.connection = null;
522
- }
523
-
524
- // Reset capabilities
525
- this.delayedExchangeAvailable = false;
526
- this._capabilities = {
527
- ...this._capabilities,
528
- delayedMessages: false,
529
- };
530
- }
531
-
532
- private async setupDelayedExchange(namespace: string): Promise<void> {
533
- if (!this.connection) {
534
- return;
535
- }
536
-
537
- // Default to disabled
538
- this.delayedExchangeAvailable = false;
539
-
540
- const delayedExchange = this.getDelayedExchangeName(namespace);
541
- const connection = this.connection;
542
-
543
- // Use a promise-based approach to ensure all error paths resolve cleanly
544
- // This prevents any error from propagating and affecting other channels
545
- return new Promise<void>((resolve) => {
546
- let resolved = false;
547
- const safeResolve = () => {
548
- if (!resolved) {
549
- resolved = true;
550
- resolve();
551
- }
552
- };
553
-
554
- connection
555
- .createChannel()
556
- .then((probeChannel) => {
557
- // Handle channel errors - this fires when RabbitMQ closes the channel
558
- probeChannel.on('error', () => {
559
- safeResolve();
560
- });
561
-
562
- // Handle channel close
563
- probeChannel.on('close', () => {
564
- safeResolve();
565
- });
566
-
567
- // Try to declare a delayed exchange
568
- // This will fail if the plugin is not installed
569
- probeChannel
570
- .assertExchange(delayedExchange, 'x-delayed-message', {
571
- durable: true,
572
- arguments: { 'x-delayed-type': 'direct' },
573
- })
574
- .then(() => {
575
- this.delayedExchangeAvailable = true;
576
- this._capabilities = {
577
- ...this._capabilities,
578
- delayedMessages: true,
579
- };
580
- this.logger.debug(
581
- '[Matador] 🔌 Delayed message exchange plugin detected',
582
- );
583
- // Close the probe channel gracefully
584
- probeChannel.close().catch(() => {});
585
- safeResolve();
586
- })
587
- .catch(() => {
588
- // assertExchange failed - plugin not available
589
- // Channel is already closed by RabbitMQ, no need to close
590
- this.logger.warn(
591
- '[Matador] 🟡 RabbitMQ delayed message exchange plugin not available. ' +
592
- 'Delayed messages will not be supported.',
593
- );
594
- safeResolve();
595
- });
596
- })
597
- .catch(() => {
598
- // Failed to create channel - shouldn't happen but handle it
599
- safeResolve();
600
- });
601
- });
602
- }
603
-
604
- private async assertWorkQueue(
605
- channel: Channel,
606
- topology: Topology,
607
- queueDef: QueueDefinition,
608
- ): Promise<void> {
609
- const queueName = queueDef.exact
610
- ? queueDef.name
611
- : `${topology.namespace}.${queueDef.name}`;
612
-
613
- const rabbitmqOptions = queueDef.transport?.rabbitmq?.options;
614
-
615
- // If user provided exact RabbitMQ options, use them directly (replaces all defaults)
616
- if (rabbitmqOptions) {
617
- await channel.assertQueue(queueName, rabbitmqOptions);
618
- } else {
619
- // Use computed defaults
620
- const queueOptions: Options.AssertQueue = {
621
- durable: true,
622
- arguments: {} as Record<string, unknown>,
623
- };
624
-
625
- // Use quorum queues for durability
626
- if (this.config.quorumQueues && !queueDef.exact) {
627
- queueOptions.arguments['x-queue-type'] = 'quorum';
628
- }
629
-
630
- // Set up dead-letter exchange routing
631
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
632
- if (
633
- topology.deadLetter.unhandled.enabled ||
634
- topology.deadLetter.undeliverable.enabled
635
- ) {
636
- queueOptions.arguments['x-dead-letter-exchange'] = dlxExchange;
637
- }
638
-
639
- // Enable priority if requested
640
- if (queueDef.priorities) {
641
- queueOptions.arguments['x-max-priority'] = 10;
642
- }
643
-
644
- // Set consumer timeout if specified
645
- if (queueDef.consumerTimeout) {
646
- queueOptions.arguments['x-consumer-timeout'] = queueDef.consumerTimeout;
647
- }
648
-
649
- await channel.assertQueue(queueName, queueOptions);
650
- }
651
-
652
- // Bind queue to main exchange
653
- const mainExchange = this.getMainExchangeName(topology.namespace);
654
- await channel.bindQueue(queueName, mainExchange, queueName);
655
-
656
- // Bind to delayed exchange if available
657
- if (this.delayedExchangeAvailable) {
658
- const delayedExchange = this.getDelayedExchangeName(topology.namespace);
659
- await channel.bindQueue(queueName, delayedExchange, queueName);
660
- }
661
-
662
- // Create retry queue if retry is enabled
663
- if (topology.retry.enabled) {
664
- await this.assertRetryQueue(channel, topology, queueName);
665
- }
666
- }
667
-
668
- private async assertRetryQueue(
669
- channel: Channel,
670
- topology: Topology,
671
- workQueueName: string,
672
- ): Promise<void> {
673
- const retryQueueName = `${workQueueName}.retry`;
674
- const mainExchange = this.getMainExchangeName(topology.namespace);
675
-
676
- const retryQueueOptions: Options.AssertQueue = {
677
- durable: true,
678
- arguments: {
679
- 'x-dead-letter-exchange': mainExchange,
680
- 'x-dead-letter-routing-key': workQueueName,
681
- 'x-message-ttl': topology.retry.defaultDelayMs,
682
- } as Record<string, unknown>,
683
- };
684
-
685
- if (this.config.quorumQueues) {
686
- retryQueueOptions.arguments['x-queue-type'] = 'quorum';
687
- }
688
-
689
- await channel.assertQueue(retryQueueName, retryQueueOptions);
690
- await channel.bindQueue(retryQueueName, mainExchange, retryQueueName);
691
- }
692
-
693
- private async assertDeadLetterQueues(
694
- channel: Channel,
695
- topology: Topology,
696
- dlqType: 'unhandled' | 'undeliverable',
697
- ): Promise<void> {
698
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
699
- const dlConfig = topology.deadLetter[dlqType];
700
-
701
- for (const queueDef of topology.queues) {
702
- if (queueDef.exact) continue;
703
-
704
- const workQueueName = `${topology.namespace}.${queueDef.name}`;
705
- const dlqName = `${workQueueName}.${dlqType}`;
706
-
707
- const dlqOptions: Options.AssertQueue = {
708
- durable: true,
709
- arguments: {} as Record<string, unknown>,
710
- };
711
-
712
- if (dlConfig.maxLength) {
713
- dlqOptions.arguments['x-max-length'] = dlConfig.maxLength;
714
- }
715
-
716
- // DLQs use classic queues (not quorum) for simplicity
717
- await channel.assertQueue(dlqName, dlqOptions);
718
- await channel.bindQueue(dlqName, dlxExchange, dlqName);
719
- }
720
- }
721
-
722
- private getMainExchangeName(namespace: string): string {
723
- return `${namespace}.exchange`;
724
- }
725
-
726
- private getDLXExchangeName(namespace: string): string {
727
- return `${namespace}.dlx`;
728
- }
729
-
730
- private getDelayedExchangeName(namespace: string): string {
731
- return `${namespace}.delayed`;
732
- }
733
-
734
- private getAttemptNumber(msg: ConsumeMessage): number {
735
- const headerValue = msg.properties.headers?.['x-matador-attempts'];
736
- if (typeof headerValue === 'number') {
737
- return headerValue;
738
- }
739
- // Check for x-death header (native DLX redelivery count)
740
- const xDeath = msg.properties.headers?.['x-death'];
741
- if (Array.isArray(xDeath) && xDeath.length > 0) {
742
- const deathCount = xDeath.reduce(
743
- (sum: number, death: { count?: number }) => sum + (death.count ?? 0),
744
- 0,
745
- );
746
- return deathCount + 1;
747
- }
748
- return 1;
749
- }
750
-
751
- /**
752
- * Gets the native delivery count for poison message detection.
753
- * This tracks how many times the message was delivered without acknowledgment,
754
- * which helps detect crash loops.
755
- */
756
- private getDeliveryCount(msg: ConsumeMessage, attemptNumber: number): number {
757
- // Check for explicit delivery count header (some RabbitMQ setups track this)
758
- const deliveryCount = msg.properties.headers?.['x-delivery-count'];
759
- if (typeof deliveryCount === 'number') {
760
- return deliveryCount;
761
- }
762
-
763
- // Check x-death header for dead-letter redelivery count
764
- const xDeath = msg.properties.headers?.['x-death'];
765
- if (Array.isArray(xDeath) && xDeath.length > 0) {
766
- const deathCount = xDeath.reduce(
767
- (sum: number, death: { count?: number }) => sum + (death.count ?? 0),
768
- 0,
769
- );
770
- // Add 1 because we're currently being delivered again
771
- return deathCount + 1;
772
- }
773
-
774
- // If redelivered flag is set but no other tracking, count as 2 (first + this delivery)
775
- if (msg.fields.redelivered) {
776
- return Math.max(2, attemptNumber);
777
- }
778
-
779
- // Default to attempt number
780
- return attemptNumber;
781
- }
782
- }