@zdavison/matador 3.0.6 → 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.
- package/dist/core/fanout.d.ts +3 -2
- package/dist/core/fanout.d.ts.map +1 -1
- package/dist/core/fanout.js +4 -4
- package/dist/core/fanout.test.js +32 -24
- package/dist/core/matador.d.ts.map +1 -1
- package/dist/core/matador.js +16 -7
- package/dist/core/matador.test.js +32 -1
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +2 -2
- package/dist/errors/matador-errors.d.ts +28 -0
- package/dist/errors/matador-errors.d.ts.map +1 -1
- package/dist/errors/matador-errors.js +41 -0
- package/dist/index.cjs +325 -80
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/topology/builder.d.ts +26 -1
- package/dist/topology/builder.d.ts.map +1 -1
- package/dist/topology/builder.js +72 -0
- package/dist/topology/builder.test.js +179 -1
- package/dist/topology/index.d.ts +2 -2
- package/dist/topology/index.d.ts.map +1 -1
- package/dist/topology/index.js +1 -1
- package/dist/topology/types.d.ts +108 -6
- package/dist/topology/types.d.ts.map +1 -1
- package/dist/topology/types.js +50 -10
- package/dist/transport/local/local-transport.d.ts.map +1 -1
- package/dist/transport/local/local-transport.js +5 -2
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts +2 -0
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.d.ts.map +1 -0
- package/dist/transport/rabbitmq/rabbitmq-transport-reconnection.test.js +110 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts +24 -0
- package/dist/transport/rabbitmq/rabbitmq-transport.d.ts.map +1 -1
- package/dist/transport/rabbitmq/rabbitmq-transport.js +145 -68
- package/dist/transport/rabbitmq/rabbitmq-transport.test.js +130 -0
- 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":"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
133
|
-
this.publishChannel
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
521
|
+
await this.assertRetryQueue(channel, topology, queueDef);
|
|
453
522
|
}
|
|
454
523
|
}
|
|
455
|
-
async assertRetryQueue(channel, topology,
|
|
456
|
-
const
|
|
457
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
493
|
-
return
|
|
566
|
+
getMainExchangeName(topology) {
|
|
567
|
+
return (topology.naming?.mainExchange?.(topology.namespace) ??
|
|
568
|
+
applyPrefix(topology.prefix, `${topology.namespace}.exchange`));
|
|
494
569
|
}
|
|
495
|
-
getDLXExchangeName(
|
|
496
|
-
return
|
|
570
|
+
getDLXExchangeName(topology) {
|
|
571
|
+
return (topology.naming?.dlxExchange?.(topology.namespace) ??
|
|
572
|
+
applyPrefix(topology.prefix, `${topology.namespace}.dlx`));
|
|
497
573
|
}
|
|
498
|
-
getDelayedExchangeName(
|
|
499
|
-
return
|
|
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'];
|