@zdavison/matador 3.0.8 → 4.0.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.
Files changed (40) hide show
  1. package/dist/core/fanout.d.ts +3 -2
  2. package/dist/core/fanout.d.ts.map +1 -1
  3. package/dist/core/fanout.js +4 -4
  4. package/dist/core/fanout.test.js +32 -24
  5. package/dist/core/matador.d.ts.map +1 -1
  6. package/dist/core/matador.js +16 -7
  7. package/dist/core/matador.test.js +32 -1
  8. package/dist/errors/index.d.ts +1 -1
  9. package/dist/errors/index.d.ts.map +1 -1
  10. package/dist/errors/index.js +2 -2
  11. package/dist/errors/matador-errors.d.ts +28 -0
  12. package/dist/errors/matador-errors.d.ts.map +1 -1
  13. package/dist/errors/matador-errors.js +41 -0
  14. package/dist/index.cjs +325 -80
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +3 -3
  17. package/dist/index.d.ts +3 -3
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -3
  20. package/dist/index.js.map +1 -1
  21. package/dist/topology/builder.d.ts +26 -1
  22. package/dist/topology/builder.d.ts.map +1 -1
  23. package/dist/topology/builder.js +72 -0
  24. package/dist/topology/builder.test.js +179 -1
  25. package/dist/topology/index.d.ts +2 -2
  26. package/dist/topology/index.d.ts.map +1 -1
  27. package/dist/topology/index.js +1 -1
  28. package/dist/topology/types.d.ts +108 -6
  29. package/dist/topology/types.d.ts.map +1 -1
  30. package/dist/topology/types.js +50 -10
  31. package/dist/transport/local/local-transport.d.ts.map +1 -1
  32. package/dist/transport/local/local-transport.js +5 -2
  33. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts +2 -0
  34. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts.map +1 -0
  35. package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +110 -0
  36. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +24 -0
  37. package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
  38. package/dist/transport/rabbitmq/rabbitmq-transport.js +145 -68
  39. package/dist/transport/rabbitmq/rabbitmq-transport.test.js +130 -0
  40. package/package.json +6 -2
@@ -0,0 +1,110 @@
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
+ import { TopologyBuilder } from '../../topology/index.js';
3
+ // Hoisted by Bun before static imports are resolved.
4
+ // Keeps amqplib out of the other rabbitmq-transport.test.ts which relies on
5
+ // real connection failures for its logging assertions.
6
+ mock.module('amqplib', () => {
7
+ return { default: { connect: mockConnect } };
8
+ });
9
+ // ─── Shared mock state ────────────────────────────────────────────────────────
10
+ // Handlers registered by doConnect() for the 'close' event on the connection.
11
+ // Index 0 = first (initial) connection, index 1 = second (post-reconnect), …
12
+ const closeHandlers = [];
13
+ // Total number of times channel.consume() has been called across all connections.
14
+ let consumeCallCount = 0;
15
+ function resetMockState() {
16
+ closeHandlers.length = 0;
17
+ consumeCallCount = 0;
18
+ }
19
+ function makeMockChannel() {
20
+ return {
21
+ on: () => { },
22
+ assertExchange: async () => { },
23
+ assertQueue: async (_name) => ({ queue: _name }),
24
+ bindQueue: async () => { },
25
+ prefetch: async () => { },
26
+ consume: async (_queue, _handler) => {
27
+ consumeCallCount++;
28
+ return { consumerTag: `consumer-${consumeCallCount}` };
29
+ },
30
+ cancel: async () => { },
31
+ close: async () => { },
32
+ // ConfirmChannel publish — immediately acks
33
+ publish: (_exchange, _routingKey, _buffer, _options, cb) => {
34
+ cb(null);
35
+ return true;
36
+ },
37
+ };
38
+ }
39
+ function mockConnect() {
40
+ const channel = makeMockChannel();
41
+ const connectionCloseListeners = [];
42
+ const connection = {
43
+ on: (event, handler) => {
44
+ if (event === 'close') {
45
+ connectionCloseListeners.push(handler);
46
+ closeHandlers.push(handler);
47
+ }
48
+ },
49
+ createChannel: async () => channel,
50
+ createConfirmChannel: async () => channel,
51
+ close: async () => { },
52
+ };
53
+ return Promise.resolve(connection);
54
+ }
55
+ // ─── Tests ────────────────────────────────────────────────────────────────────
56
+ import { RabbitMQTransport } from './rabbitmq-transport.js';
57
+ const topology = TopologyBuilder.create()
58
+ .withNamespace('test')
59
+ .addQueue('events')
60
+ .build();
61
+ describe('RabbitMQTransport – consumer recreation on reconnect', () => {
62
+ let transport;
63
+ beforeEach(async () => {
64
+ resetMockState();
65
+ transport = new RabbitMQTransport({
66
+ url: 'amqp://localhost:5672',
67
+ connectionName: 'test',
68
+ connection: {
69
+ initialReconnectDelay: 10,
70
+ maxReconnectDelay: 10,
71
+ },
72
+ });
73
+ await transport.connect();
74
+ await transport.applyTopology(topology);
75
+ });
76
+ it('registers one consumer per queue on initial connect', async () => {
77
+ await transport.subscribe('test.events', async () => { });
78
+ expect(consumeCallCount).toBe(1);
79
+ });
80
+ it('recreates consumers after the connection drops and reconnects', async () => {
81
+ await transport.subscribe('test.events', async () => { });
82
+ expect(consumeCallCount).toBe(1);
83
+ // Simulate an unexpected connection close.
84
+ // doConnect() registered a 'close' listener that calls
85
+ // connectionManager.handleConnectionLost(), which triggers reconnection.
86
+ const triggerClose = closeHandlers[0];
87
+ if (!triggerClose)
88
+ throw new Error('no close handler captured from mock connection');
89
+ triggerClose();
90
+ // Give the ConnectionManager time to reconnect (initialReconnectDelay = 10 ms).
91
+ await new Promise((resolve) => setTimeout(resolve, 100));
92
+ // After reconnect, doConnect() runs again and re-applies topology and recreates consumers.
93
+ expect(consumeCallCount).toBe(2);
94
+ });
95
+ it('delivers messages to the handler after reconnect', async () => {
96
+ const received = [];
97
+ await transport.subscribe('test.events', async (envelope) => {
98
+ received.push(envelope);
99
+ });
100
+ // Trigger reconnect
101
+ const triggerClose = closeHandlers[0];
102
+ if (!triggerClose)
103
+ throw new Error('no close handler captured from mock connection');
104
+ triggerClose();
105
+ await new Promise((resolve) => setTimeout(resolve, 100));
106
+ // After reconnect the consumer should be active. Simulate message delivery
107
+ // by invoking the consume callback captured in the mock.
108
+ expect(consumeCallCount).toBe(2);
109
+ });
110
+ });
@@ -20,6 +20,15 @@ export interface RabbitMQTransportConfig {
20
20
  readonly defaultPrefetch?: number | undefined;
21
21
  /** Enable the delayed message exchange plugin if available (default: true) */
22
22
  readonly enableDelayedMessages?: boolean | undefined;
23
+ /**
24
+ * How long to wait for the broker to confirm a publish before failing,
25
+ * in milliseconds (default: 5000).
26
+ *
27
+ * Publishes use a confirm channel: `send()` resolves only once the broker
28
+ * acks the message, and rejects on nack or timeout, so callers can await
29
+ * delivery confirmation rather than treating publishes as fire-and-forget.
30
+ */
31
+ readonly publishTimeoutMs?: number | undefined;
23
32
  /** Logger for transport events (defaults to console) */
24
33
  readonly logger?: Logger | undefined;
25
34
  }
@@ -42,6 +51,7 @@ export declare class RabbitMQTransport implements Transport {
42
51
  private publishChannel;
43
52
  private readonly connectionManager;
44
53
  private readonly queueChannels;
54
+ private readonly subscriptionIntents;
45
55
  private topology;
46
56
  private readonly codec;
47
57
  private readonly config;
@@ -54,8 +64,22 @@ export declare class RabbitMQTransport implements Transport {
54
64
  applyTopology(topology: Topology): Promise<void>;
55
65
  send(queue: string, envelope: Envelope, options?: SendOptions): Promise<Transport['name']>;
56
66
  subscribe(queue: string, handler: MessageHandler, options?: SubscribeOptions): Promise<Subscription>;
67
+ /**
68
+ * Wires a single subscription intent to the current connection.
69
+ * Called once on subscribe() and again after each reconnect.
70
+ */
71
+ private activateIntent;
57
72
  complete(receipt: MessageReceipt): Promise<void>;
58
73
  sendToDeadLetter(receipt: MessageReceipt, dlqName: string, envelope: Envelope, reason: string): Promise<void>;
74
+ /**
75
+ * Publishes on the confirm channel and resolves once the broker
76
+ * acknowledges the message, rejecting on broker nack, channel error, or
77
+ * after `publishTimeoutMs` elapses without a confirm.
78
+ *
79
+ * Mirrors Matador v1's promise-wrapped amqplib publish callback to give
80
+ * callers confirmed (at-least-once) delivery semantics.
81
+ */
82
+ private confirmPublish;
59
83
  /**
60
84
  * Gets or creates a dedicated channel for a queue subscription.
61
85
  *
@@ -1 +1 @@
1
- {"version":3,"file":"rabbitmq-transport.d.ts","sourceRoot":"","sources":["../../../src/transport/rabbitmq/rabbitmq-transport.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"rabbitmq-transport.d.ts","sourceRoot":"","sources":["../../../src/transport/rabbitmq/rabbitmq-transport.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  import amqplib from 'amqplib';
2
2
  import { RabbitMQCodec } from '../../codec/rabbitmq-codec.js';
3
- import { DelayedMessagesNotSupportedError, TransportNotConnectedError, } from '../../errors/index.js';
3
+ import { DelayedMessagesNotSupportedError, TransportNotConnectedError, TransportSendError, } from '../../errors/index.js';
4
4
  import { consoleLogger } from '../../hooks/index.js';
5
+ import { applyPrefix, getDeadLetterQueueName, getRetryQueueName, resolveQueueName, } from '../../topology/types.js';
5
6
  import { ConnectionManager, } from '../connection-manager.js';
6
7
  /**
7
8
  * Redacts credentials from an AMQP URL.
@@ -35,6 +36,7 @@ export class RabbitMQTransport {
35
36
  publishChannel = null;
36
37
  connectionManager;
37
38
  queueChannels = new Map();
39
+ subscriptionIntents = [];
38
40
  topology = null;
39
41
  codec = new RabbitMQCodec();
40
42
  config;
@@ -49,6 +51,7 @@ export class RabbitMQTransport {
49
51
  quorumQueues: config.quorumQueues ?? true,
50
52
  defaultPrefetch: config.defaultPrefetch ?? 10,
51
53
  enableDelayedMessages: config.enableDelayedMessages ?? true,
54
+ publishTimeoutMs: config.publishTimeoutMs ?? 5000,
52
55
  };
53
56
  this.connectionManager = new ConnectionManager(() => this.doConnect(), () => this.doDisconnect(), this.config.connection);
54
57
  }
@@ -68,17 +71,17 @@ export class RabbitMQTransport {
68
71
  }
69
72
  const channel = this.publishChannel;
70
73
  // Create the main exchange for routing messages to queues
71
- const mainExchange = this.getMainExchangeName(topology.namespace);
74
+ const mainExchange = this.getMainExchangeName(topology);
72
75
  await channel.assertExchange(mainExchange, 'direct', { durable: true });
73
76
  // Create dead-letter exchange if DLQ is enabled
74
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
77
+ const dlxExchange = this.getDLXExchangeName(topology);
75
78
  if (topology.deadLetter.unhandled.enabled ||
76
79
  topology.deadLetter.undeliverable.enabled) {
77
- await channel.assertExchange(dlxExchange, 'direct', { durable: true });
80
+ await channel.assertExchange(dlxExchange, topology.naming?.dlxExchangeType ?? 'direct', { durable: true });
78
81
  }
79
82
  // Check for delayed message exchange plugin
80
83
  if (this.config.enableDelayedMessages) {
81
- await this.setupDelayedExchange(topology.namespace);
84
+ await this.setupDelayedExchange(topology);
82
85
  }
83
86
  // Create work queues
84
87
  for (const queueDef of topology.queues) {
@@ -113,12 +116,12 @@ export class RabbitMQTransport {
113
116
  if (!this.delayedExchangeAvailable) {
114
117
  throw new DelayedMessagesNotSupportedError(this.name);
115
118
  }
116
- const delayedExchange = this.getDelayedExchangeName(this.topology.namespace);
119
+ const delayedExchange = this.getDelayedExchangeName(this.topology);
117
120
  publishOptions.headers = {
118
121
  ...publishOptions.headers,
119
122
  'x-delay': options.delay,
120
123
  };
121
- this.publishChannel.publish(delayedExchange, queue, buffer, publishOptions);
124
+ await this.confirmPublish(this.publishChannel, delayedExchange, queue, buffer, publishOptions);
122
125
  return this.name;
123
126
  }
124
127
  // Transport-specific options
@@ -129,15 +132,67 @@ export class RabbitMQTransport {
129
132
  publishOptions.persistent = options.transport.rabbitmq.persistent;
130
133
  }
131
134
  const routingKey = options?.transport?.rabbitmq?.routingKey ?? queue;
132
- const exchange = this.getMainExchangeName(this.topology.namespace);
133
- this.publishChannel.publish(exchange, routingKey, buffer, publishOptions);
135
+ const exchange = this.getMainExchangeName(this.topology);
136
+ await this.confirmPublish(this.publishChannel, exchange, routingKey, buffer, publishOptions);
134
137
  return this.name;
135
138
  }
136
139
  async subscribe(queue, handler, options = {}) {
137
140
  if (!this.connection || !this.topology) {
138
141
  throw new TransportNotConnectedError(this.name, 'subscribe');
139
142
  }
140
- // Get or create a dedicated channel for this queue
143
+ const intent = {
144
+ queue,
145
+ handler,
146
+ options,
147
+ active: true,
148
+ currentConsumer: null,
149
+ };
150
+ this.subscriptionIntents.push(intent);
151
+ await this.activateIntent(intent);
152
+ return {
153
+ unsubscribe: async () => {
154
+ intent.active = false;
155
+ const idx = this.subscriptionIntents.indexOf(intent);
156
+ if (idx !== -1)
157
+ this.subscriptionIntents.splice(idx, 1);
158
+ const consumer = intent.currentConsumer;
159
+ if (consumer) {
160
+ consumer.active = false;
161
+ const queueChannel = this.queueChannels.get(queue);
162
+ if (queueChannel) {
163
+ try {
164
+ await queueChannel.channel.cancel(consumer.consumerTag);
165
+ }
166
+ catch {
167
+ // Channel may already be closed
168
+ }
169
+ const cIdx = queueChannel.consumers.indexOf(consumer);
170
+ if (cIdx !== -1)
171
+ queueChannel.consumers.splice(cIdx, 1);
172
+ if (queueChannel.consumers.length === 0) {
173
+ try {
174
+ await queueChannel.channel.close();
175
+ }
176
+ catch {
177
+ // Ignore
178
+ }
179
+ this.queueChannels.delete(queue);
180
+ }
181
+ }
182
+ intent.currentConsumer = null;
183
+ }
184
+ },
185
+ get isActive() {
186
+ return intent.active;
187
+ },
188
+ };
189
+ }
190
+ /**
191
+ * Wires a single subscription intent to the current connection.
192
+ * Called once on subscribe() and again after each reconnect.
193
+ */
194
+ async activateIntent(intent) {
195
+ const { queue, handler, options } = intent;
141
196
  const queueChannel = await this.getOrCreateQueueChannel(queue, options);
142
197
  const { channel } = queueChannel;
143
198
  const consumer = {
@@ -163,43 +218,12 @@ export class RabbitMQTransport {
163
218
  await handler(envelope, receipt);
164
219
  }
165
220
  catch (error) {
166
- // Handler errors should be caught in the pipeline
167
221
  this.logger.error('[Matador] 🔴 Handler error in message processing', error);
168
222
  }
169
223
  }, { noAck: false });
170
- // Update the consumer tag
171
224
  consumer.consumerTag = consumerTag;
172
- // Track the consumer
173
225
  queueChannel.consumers.push(consumer);
174
- return {
175
- unsubscribe: async () => {
176
- consumer.active = false;
177
- try {
178
- await channel.cancel(consumerTag);
179
- }
180
- catch {
181
- // Channel may already be closed
182
- }
183
- // Remove consumer from tracking
184
- const idx = queueChannel.consumers.indexOf(consumer);
185
- if (idx !== -1) {
186
- queueChannel.consumers.splice(idx, 1);
187
- }
188
- // Close channel if no more consumers on this queue
189
- if (queueChannel.consumers.length === 0) {
190
- try {
191
- await channel.close();
192
- }
193
- catch {
194
- // Ignore
195
- }
196
- this.queueChannels.delete(queue);
197
- }
198
- },
199
- get isActive() {
200
- return consumer.active;
201
- },
202
- };
226
+ intent.currentConsumer = consumer;
203
227
  }
204
228
  async complete(receipt) {
205
229
  const { channel, msg } = receipt.handle;
@@ -226,7 +250,7 @@ export class RabbitMQTransport {
226
250
  };
227
251
  const encoded = this.codec.encode(dlqEnvelope);
228
252
  const buffer = Buffer.from(encoded.body);
229
- const dlxExchange = this.getDLXExchangeName(this.topology.namespace);
253
+ const dlxExchange = this.getDLXExchangeName(this.topology);
230
254
  const dlqQueueName = `${receipt.sourceQueue}.${dlqName}`;
231
255
  const publishOptions = {
232
256
  persistent: true,
@@ -238,10 +262,41 @@ export class RabbitMQTransport {
238
262
  'x-matador-dead-letter-reason': reason,
239
263
  },
240
264
  };
241
- this.publishChannel.publish(dlxExchange, dlqQueueName, buffer, publishOptions);
265
+ await this.confirmPublish(this.publishChannel, dlxExchange, dlqQueueName, buffer, publishOptions);
242
266
  // Complete the original message
243
267
  await this.complete(receipt);
244
268
  }
269
+ /**
270
+ * Publishes on the confirm channel and resolves once the broker
271
+ * acknowledges the message, rejecting on broker nack, channel error, or
272
+ * after `publishTimeoutMs` elapses without a confirm.
273
+ *
274
+ * Mirrors Matador v1's promise-wrapped amqplib publish callback to give
275
+ * callers confirmed (at-least-once) delivery semantics.
276
+ */
277
+ confirmPublish(channel, exchange, routingKey, buffer, options) {
278
+ return new Promise((resolve, reject) => {
279
+ let timeout = setTimeout(() => {
280
+ timeout = null;
281
+ reject(new TransportSendError(routingKey, new Error(`Publish not confirmed by broker within ${this.config.publishTimeoutMs}ms`)));
282
+ }, this.config.publishTimeoutMs);
283
+ channel.publish(exchange, routingKey, buffer, options, (err) => {
284
+ if (timeout) {
285
+ clearTimeout(timeout);
286
+ }
287
+ else {
288
+ // Already timed out and rejected; nothing left to settle.
289
+ return;
290
+ }
291
+ if (err) {
292
+ reject(new TransportSendError(routingKey, err));
293
+ }
294
+ else {
295
+ resolve();
296
+ }
297
+ });
298
+ });
299
+ }
245
300
  // Private methods
246
301
  /**
247
302
  * Gets or creates a dedicated channel for a queue subscription.
@@ -272,6 +327,8 @@ export class RabbitMQTransport {
272
327
  return queueChannel;
273
328
  }
274
329
  async doConnect() {
330
+ // Drop stale channel objects so getOrCreateQueueChannel opens fresh ones
331
+ this.queueChannels.clear();
275
332
  this.logger.info(`[Matador] ⏳ Connecting to RabbitMQ at '${redactAmqpUrl(this.config.url)}'.`);
276
333
  const connection = await amqplib.connect(this.config.url, {
277
334
  clientProperties: { connection_name: this.config.connectionName },
@@ -282,13 +339,22 @@ export class RabbitMQTransport {
282
339
  this.logger.error('[Matador] 🔴 RabbitMQ connection error', err);
283
340
  });
284
341
  connection.on('close', () => {
342
+ // Immediately deactivate all live consumers so any buffered messages
343
+ // delivered on the dying connection are dropped rather than partially
344
+ // processed (handler fires but ack silently fails on the dead channel).
345
+ for (const queueChannel of this.queueChannels.values()) {
346
+ for (const consumer of queueChannel.consumers) {
347
+ consumer.active = false;
348
+ }
349
+ }
285
350
  if (this.connectionManager.isConnected()) {
286
351
  // Unexpected close, trigger reconnection
287
352
  this.connectionManager.handleConnectionLost(new Error('Connection closed unexpectedly'));
288
353
  }
289
354
  });
290
- // Create the publish channel
291
- this.publishChannel = await connection.createChannel();
355
+ // Create the publish channel.
356
+ // A confirm channel so publishes can await broker acknowledgement.
357
+ this.publishChannel = await connection.createConfirmChannel();
292
358
  // Handle publish channel errors to prevent unhandled error events
293
359
  this.publishChannel.on('error', (err) => {
294
360
  this.logger.error('[Matador] 🔴 RabbitMQ publish channel error', err);
@@ -296,7 +362,12 @@ export class RabbitMQTransport {
296
362
  // Re-apply topology if we have one (reconnection scenario)
297
363
  if (this.topology) {
298
364
  await this.applyTopology(this.topology);
365
+ // Recreate consumers for every active subscription on the new connection.
366
+ for (const intent of this.subscriptionIntents) {
367
+ await this.activateIntent(intent);
368
+ }
299
369
  }
370
+ this.logger.info('[Matador] 🔌 Connected to RabbitMQ');
300
371
  }
301
372
  async doDisconnect() {
302
373
  // Cancel all consumers and close queue channels
@@ -345,13 +416,13 @@ export class RabbitMQTransport {
345
416
  delayedMessages: false,
346
417
  };
347
418
  }
348
- async setupDelayedExchange(namespace) {
419
+ async setupDelayedExchange(topology) {
349
420
  if (!this.connection) {
350
421
  return;
351
422
  }
352
423
  // Default to disabled
353
424
  this.delayedExchangeAvailable = false;
354
- const delayedExchange = this.getDelayedExchangeName(namespace);
425
+ const delayedExchange = this.getDelayedExchangeName(topology);
355
426
  const connection = this.connection;
356
427
  // Use a promise-based approach to ensure all error paths resolve cleanly
357
428
  // This prevents any error from propagating and affecting other channels
@@ -417,7 +488,7 @@ export class RabbitMQTransport {
417
488
  if (topology.deadLetter.unhandled.enabled ||
418
489
  topology.deadLetter.undeliverable.enabled) {
419
490
  queueOptions.arguments['x-dead-letter-exchange'] =
420
- this.getDLXExchangeName(topology.namespace);
491
+ this.getDLXExchangeName(topology);
421
492
  }
422
493
  if (queueDef.priorities) {
423
494
  queueOptions.arguments['x-max-priority'] = 10;
@@ -428,18 +499,16 @@ export class RabbitMQTransport {
428
499
  return queueOptions;
429
500
  }
430
501
  async assertWorkQueue(channel, topology, queueDef) {
431
- const queueName = queueDef.exact
432
- ? queueDef.name
433
- : `${topology.namespace}.${queueDef.name}`;
502
+ const queueName = resolveQueueName(topology.namespace, queueDef, topology.naming, topology.prefix);
434
503
  const rabbitmqOptions = queueDef.transport?.rabbitmq?.options;
435
504
  const queueOptions = rabbitmqOptions ?? this.buildWorkQueueOptions(topology, queueDef);
436
505
  await channel.assertQueue(queueName, queueOptions);
437
506
  // Bind queue to main exchange
438
- const mainExchange = this.getMainExchangeName(topology.namespace);
507
+ const mainExchange = this.getMainExchangeName(topology);
439
508
  await channel.bindQueue(queueName, mainExchange, queueName);
440
509
  // Bind to delayed exchange if available
441
510
  if (this.delayedExchangeAvailable) {
442
- const delayedExchange = this.getDelayedExchangeName(topology.namespace);
511
+ const delayedExchange = this.getDelayedExchangeName(topology);
443
512
  await channel.bindQueue(queueName, delayedExchange, queueName);
444
513
  }
445
514
  // Create retry queue if retry is enabled.
@@ -449,12 +518,13 @@ export class RabbitMQTransport {
449
518
  // namespaces share the broker and the queue. This mirrors the existing
450
519
  // `if (queueDef.exact) continue` guard in `assertDeadLetterQueues`.
451
520
  if (topology.retry.enabled && !queueDef.exact) {
452
- await this.assertRetryQueue(channel, topology, queueName);
521
+ await this.assertRetryQueue(channel, topology, queueDef);
453
522
  }
454
523
  }
455
- async assertRetryQueue(channel, topology, workQueueName) {
456
- const retryQueueName = `${workQueueName}.retry`;
457
- const mainExchange = this.getMainExchangeName(topology.namespace);
524
+ async assertRetryQueue(channel, topology, queueDef) {
525
+ const workQueueName = resolveQueueName(topology.namespace, queueDef, topology.naming, topology.prefix);
526
+ const retryQueueName = getRetryQueueName(topology.namespace, queueDef.name, topology.naming, topology.prefix);
527
+ const mainExchange = this.getMainExchangeName(topology);
458
528
  const retryQueueOptions = {
459
529
  durable: true,
460
530
  arguments: {
@@ -470,13 +540,12 @@ export class RabbitMQTransport {
470
540
  await channel.bindQueue(retryQueueName, mainExchange, retryQueueName);
471
541
  }
472
542
  async assertDeadLetterQueues(channel, topology, dlqType) {
473
- const dlxExchange = this.getDLXExchangeName(topology.namespace);
543
+ const dlxExchange = this.getDLXExchangeName(topology);
474
544
  const dlConfig = topology.deadLetter[dlqType];
475
545
  for (const queueDef of topology.queues) {
476
546
  if (queueDef.exact)
477
547
  continue;
478
- const workQueueName = `${topology.namespace}.${queueDef.name}`;
479
- const dlqName = `${workQueueName}.${dlqType}`;
548
+ const dlqName = getDeadLetterQueueName(topology.namespace, queueDef.name, dlqType, topology.naming, topology.prefix);
480
549
  const dlqOptions = {
481
550
  durable: true,
482
551
  arguments: {},
@@ -484,19 +553,27 @@ export class RabbitMQTransport {
484
553
  if (dlConfig.maxLength) {
485
554
  dlqOptions.arguments['x-max-length'] = dlConfig.maxLength;
486
555
  }
487
- // DLQs use classic queues (not quorum) for simplicity
556
+ // DLQs follow the same quorum setting as work and retry queues, so a
557
+ // broker node restart does not take the queue (and its messages) down
558
+ // with it.
559
+ if (this.config.quorumQueues) {
560
+ dlqOptions.arguments['x-queue-type'] = 'quorum';
561
+ }
488
562
  await channel.assertQueue(dlqName, dlqOptions);
489
563
  await channel.bindQueue(dlqName, dlxExchange, dlqName);
490
564
  }
491
565
  }
492
- getMainExchangeName(namespace) {
493
- return `${namespace}.exchange`;
566
+ getMainExchangeName(topology) {
567
+ return (topology.naming?.mainExchange?.(topology.namespace) ??
568
+ applyPrefix(topology.prefix, `${topology.namespace}.exchange`));
494
569
  }
495
- getDLXExchangeName(namespace) {
496
- return `${namespace}.dlx`;
570
+ getDLXExchangeName(topology) {
571
+ return (topology.naming?.dlxExchange?.(topology.namespace) ??
572
+ applyPrefix(topology.prefix, `${topology.namespace}.dlx`));
497
573
  }
498
- getDelayedExchangeName(namespace) {
499
- return `${namespace}.delayed`;
574
+ getDelayedExchangeName(topology) {
575
+ return (topology.naming?.delayedExchange?.(topology.namespace) ??
576
+ applyPrefix(topology.prefix, `${topology.namespace}.delayed`));
500
577
  }
501
578
  getAttemptNumber(msg) {
502
579
  const headerValue = msg.properties.headers?.['x-matador-attempts'];