Skip to content

Commit aab4dc5

Browse files
iifawziclaude
andauthored
fix: isolate publish path from setup-channel closures (#30) (#42)
The default channel was used for both queue/exchange setup and the public publish path, so a precondition_failed during assertQueue would close the channel and silently break all subsequent publishes. Acquire a dedicated publish channel in initialize() so publishing keeps working when the setup channel dies, and clear the cached defaultChannel reference on close so later setup operations re-acquire a live channel instead of reusing the dead one. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 83a5d20 commit aab4dc5

3 files changed

Lines changed: 108 additions & 3 deletions

File tree

src/core/RunMQ.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export class RunMQ {
2020
private readonly logger: RunMQLogger
2121
private retryAttempts: number = 0;
2222
private defaultChannel: AMQPChannel | undefined;
23+
private publishChannel: AMQPChannel | undefined;
2324

2425
private constructor(config: RunMQConnectionConfig, logger: RunMQLogger) {
2526
this.logger = logger;
@@ -62,14 +63,14 @@ export class RunMQ {
6263
* @param correlationId (Optional) A unique identifier for correlating messages; if not provided, a new UUID will be generated
6364
*/
6465
public publish(topic: string, message: Record<string, any>, correlationId: string = RunMQUtils.generateUUID()): void {
65-
if (!this.publisher || !this.defaultChannel) {
66+
if (!this.publisher || !this.publishChannel) {
6667
throw new RunMQException(Exceptions.NOT_INITIALIZED, {});
6768
}
6869
RunMQUtils.assertRecord(message);
6970
this.publisher.publish(topic,
7071
RabbitMQMessage.from(
7172
message,
72-
this.defaultChannel,
73+
this.publishChannel,
7374
new RabbitMQMessageProperties(RunMQUtils.generateUUID(), correlationId)
7475
)
7576
);
@@ -137,6 +138,9 @@ export class RunMQ {
137138
this.defaultChannel = await this.client.getDefaultChannel();
138139
await this.defaultChannel.assertExchange(Constants.ROUTER_EXCHANGE_NAME, 'direct', {durable: true});
139140
await this.defaultChannel.assertExchange(Constants.DEAD_LETTER_ROUTER_EXCHANGE_NAME, 'direct', {durable: true});
141+
// Use a dedicated channel for publishes so a setup-time channel close
142+
// (e.g. a precondition_failed on assertQueue) cannot break the publish path.
143+
this.publishChannel = await this.client.getChannel();
140144
this.publisher = new RunMQPublisherCreator(this.logger).createPublisher();
141145
}
142146
}

src/core/clients/RabbitMQClientAdapter.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,13 @@ export class RabbitMQClientAdapter implements AMQPClient {
128128

129129
public async getDefaultChannel(): Promise<AMQPChannel> {
130130
if (!this.defaultChannel) {
131-
this.defaultChannel = await this.getChannel();
131+
this.defaultChannel = await this.getChannel({
132+
onClose: () => {
133+
// Drop the cached reference so the next getDefaultChannel()
134+
// re-acquires a live channel — the previous one is dead.
135+
this.defaultChannel = undefined;
136+
},
137+
});
132138
}
133139
return this.defaultChannel;
134140
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import {RunMQ} from '@src/core/RunMQ';
2+
import {RabbitMQClientAdapter} from "@src/core/clients/RabbitMQClientAdapter";
3+
import {Constants} from "@src/core/constants";
4+
import {ChannelTestHelpers} from "@tests/helpers/ChannelTestHelpers";
5+
import {RunMQUtils} from "@src/core/utils/RunMQUtils";
6+
import {MockedRunMQLogger} from "@tests/mocks/MockedRunMQLogger";
7+
import {RunMQConnectionConfigExample} from "@tests/Examples/RunMQConnectionConfigExample";
8+
import {RunMQProcessorConfigurationExample} from "@tests/Examples/RunMQProcessorConfigurationExample";
9+
10+
describe('RunMQ Publish Channel Isolation E2E', () => {
11+
const validConfig = RunMQConnectionConfigExample.valid();
12+
const testingConnection = new RabbitMQClientAdapter(validConfig);
13+
14+
afterAll(async () => {
15+
await testingConnection.disconnect();
16+
});
17+
18+
it('should keep publishing working after a setup-channel close from a precondition_failed', async () => {
19+
const queueName = 'publish_channel_isolation_queue';
20+
const topic = 'publish.channel.isolation';
21+
22+
const setupChannel = await testingConnection.getChannel();
23+
await ChannelTestHelpers.deleteQueue(setupChannel, queueName);
24+
25+
// Pre-declare the queue with one set of arguments. A later assertQueue
26+
// with conflicting arguments will trigger PRECONDITION_FAILED, which
27+
// RabbitMQ resolves by closing the offending channel.
28+
await setupChannel.assertQueue(queueName, {
29+
durable: true,
30+
messageTtl: 60_000,
31+
});
32+
33+
const runMQ = await RunMQ.start(validConfig, MockedRunMQLogger);
34+
35+
// Trigger a precondition failure on the consumer-setup channel by
36+
// declaring a processor whose queue name collides with the existing
37+
// queue but whose args differ. This must NOT take the publish channel
38+
// down with it.
39+
const conflictingConfig = RunMQProcessorConfigurationExample.simpleNoSchema(queueName);
40+
await expect(
41+
runMQ.process(topic, conflictingConfig, async () => {})
42+
).rejects.toBeDefined();
43+
44+
// Give the broker a moment to actually close the setup channel.
45+
await RunMQUtils.delay(300);
46+
47+
// Set up a fresh consumer on a different queue, on a different topic,
48+
// so we have a place for the publish to land.
49+
const verifyQueue = 'publish_channel_isolation_verify';
50+
const verifyTopic = 'publish.channel.isolation.verify';
51+
await ChannelTestHelpers.deleteQueue(setupChannel, verifyQueue);
52+
const verifyConfig = RunMQProcessorConfigurationExample.simpleNoSchema(verifyQueue);
53+
const received: any[] = [];
54+
await runMQ.process(verifyTopic, verifyConfig, async (msg) => {
55+
received.push(msg);
56+
});
57+
58+
// The crux of the test: publish() must still work even though the
59+
// setup channel was closed by the prior precondition_failed.
60+
runMQ.publish(verifyTopic, {ok: true});
61+
62+
await RunMQUtils.delay(500);
63+
expect(received.length).toBe(1);
64+
65+
await ChannelTestHelpers.deleteQueue(setupChannel, queueName);
66+
await ChannelTestHelpers.deleteQueue(setupChannel, verifyQueue);
67+
await runMQ.disconnect();
68+
}, 30000);
69+
70+
it('should also publish via a channel distinct from getDefaultChannel', async () => {
71+
const runMQ = await RunMQ.start(validConfig, MockedRunMQLogger);
72+
73+
// Publishing immediately after start should not throw and should not
74+
// depend on the setup channel — covered by the first test, but this
75+
// case asserts the simple happy-path wiring still works.
76+
const queueName = 'publish_channel_smoke';
77+
const topic = 'publish.channel.smoke';
78+
79+
const setupChannel = await testingConnection.getChannel();
80+
await ChannelTestHelpers.deleteQueue(setupChannel, queueName);
81+
82+
const config = RunMQProcessorConfigurationExample.simpleNoSchema(queueName);
83+
const received: any[] = [];
84+
await runMQ.process(topic, config, async (msg) => {
85+
received.push(msg);
86+
});
87+
88+
runMQ.publish(topic, {ok: true});
89+
await RunMQUtils.delay(500);
90+
expect(received.length).toBe(1);
91+
92+
await ChannelTestHelpers.deleteQueue(setupChannel, queueName);
93+
await runMQ.disconnect();
94+
}, 15000);
95+
});

0 commit comments

Comments
 (0)