Skip to content

Commit 20a0ccf

Browse files
sonus21claude
andcommitted
feat(nats): wire NATS-native dead-letter advisory bridge per queue at startup
Introduces NatsDeadLetterBridgeRegistrar (SmartInitializingSingleton + DisposableBean) that walks EndpointRegistry and calls JetStreamMessageBroker.installDeadLetterBridge(queue, queue.resolvedConsumerName()) for each registered queue, so $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.* fired by JetStream is caught and the offending payload is republished onto the queue's DLQ stream (<streamPrefix><queue><dlqStreamSuffix>). Producer-only mode (RqueueConfig .isProducer()=true) is detected and skipped. Bean is wired from RqueueNatsAutoConfig so it loads only when rqueue.backend=nats. This is the NATS-side analog of the rqueue-level DLQ path (PostProcessingHandler → broker.moveToDlq, already covered by NatsSchedulingAdvancedE2EIT). The advisory bridge is the defensive net for cases outside rqueue's control: a handler that hangs past visibilityTimeout, or a process that crashes mid-handler. Also fixes a critical test-isolation bug discovered while validating the bridge end-to-end: nine NATS Spring Boot E2E classes were setting "rqueue.nats.stream-prefix" / "rqueue.nats.subject-prefix" but the actual property paths are "rqueue.nats.naming.stream-prefix" / "rqueue.nats.naming.subject-prefix". Streams from every test class were collapsing onto the same default prefix "rqueue-js-", and the @BeforeAll cleanup hooks were silently no-op'ing because they targeted the wrong (never-used) per-class prefix. Patched all nine classes to use the correct property paths; streams now actually live under per-class prefixes and cleanup actually wipes them between runs. Other test changes: - NatsConsumerNameOverrideE2EIT: ConsumerInfo lookup now derives the stream name from STREAM_PREFIX instead of hardcoding "rqueue-js-custom-consumer". - NatsRetryAndDlqE2EIT: re-enabled, rewritten to publish a synthetic max-delivery advisory matching nats-server 2.12's payload shape and assert the bridge republishes onto the DLQ stream. Uses a blocking listener so the source message stays in flight (un-acked) — matching the production scenario the bridge is designed for. Going through a real handler-exhaustion path was racy because rqueue acks the message on numRetries exhaustion, so NATS never reaches its derived maxDeliver and never fires the advisory. Local run (NATS_RUNNING=true, nats-server 2.12.8): 19 tests passing, 1 skipped (the multi-listener fan-out test, which still requires per-queue retention-policy work to support Limits/Interest retention — left @disabled with the existing comment). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 17af14d commit 20a0ccf

11 files changed

Lines changed: 237 additions & 46 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*
2+
* Copyright (c) 2026 Sonu Kumar
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* You may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and limitations under the License.
14+
*
15+
*/
16+
package com.github.sonus21.rqueue.nats.js;
17+
18+
import com.github.sonus21.rqueue.config.RqueueConfig;
19+
import com.github.sonus21.rqueue.core.EndpointRegistry;
20+
import com.github.sonus21.rqueue.core.spi.MessageBroker;
21+
import com.github.sonus21.rqueue.listener.QueueDetail;
22+
import java.util.ArrayList;
23+
import java.util.List;
24+
import java.util.logging.Level;
25+
import java.util.logging.Logger;
26+
import org.springframework.beans.factory.DisposableBean;
27+
import org.springframework.beans.factory.SmartInitializingSingleton;
28+
29+
/**
30+
* Bootstrap-time installer for the NATS-native dead-letter advisory bridge. For every active queue
31+
* registered in {@link EndpointRegistry}, calls
32+
* {@link JetStreamMessageBroker#installDeadLetterBridge(QueueDetail, String)} so that messages
33+
* exceeding {@code maxDeliver} on the durable consumer are republished onto the queue's DLQ
34+
* stream via the {@code $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES} advisory subject.
35+
*
36+
* <p>This is the NATS-side equivalent of the Redis backend's {@code RqueueDeadLetterPublisher}:
37+
* the rqueue post-processing handler already routes failed deliveries to a configured Rqueue-level
38+
* DLQ ({@code @RqueueListener(deadLetterQueue=...)}); the advisory bridge registered here is an
39+
* additional, independent path that catches messages whose handler exhausted retries without the
40+
* post-processor noticing (e.g. listener restart, container shutdown mid-retry, JetStream-driven
41+
* redelivery exhaustion outside the rqueue retry counter).
42+
*
43+
* <p><b>Lifecycle.</b> Implements {@link SmartInitializingSingleton} so it runs after every
44+
* {@code @RqueueListener} bean has registered with {@link EndpointRegistry} and the
45+
* {@link com.github.sonus21.rqueue.nats.js.NatsStreamValidator} has provisioned the underlying
46+
* streams — but before {@code SmartLifecycle.start()} spawns the message pollers, so the bridge
47+
* is in place before the first delivery attempt. Implements {@link DisposableBean} so the
48+
* advisory dispatchers are torn down on context shutdown.
49+
*
50+
* <p><b>Producer-only mode.</b> When {@link RqueueConfig#isProducer()} is true the application
51+
* has no listeners and therefore no consumers that could exhaust retries; the registrar exits
52+
* early and installs nothing.
53+
*
54+
* <p><b>Backend gating.</b> Only does its work when the active broker is a
55+
* {@link JetStreamMessageBroker}; on Redis or other backends the bean simply no-ops, so it is safe
56+
* to wire unconditionally from the NATS auto-config (which is itself gated on
57+
* {@code rqueue.backend=nats}).
58+
*/
59+
public class NatsDeadLetterBridgeRegistrar
60+
implements SmartInitializingSingleton, DisposableBean {
61+
62+
private static final Logger log =
63+
Logger.getLogger(NatsDeadLetterBridgeRegistrar.class.getName());
64+
65+
private final MessageBroker broker;
66+
private final RqueueConfig rqueueConfig;
67+
private final List<AutoCloseable> bridges = new ArrayList<>();
68+
69+
public NatsDeadLetterBridgeRegistrar(MessageBroker broker, RqueueConfig rqueueConfig) {
70+
this.broker = broker;
71+
this.rqueueConfig = rqueueConfig;
72+
}
73+
74+
@Override
75+
public void afterSingletonsInstantiated() {
76+
if (rqueueConfig != null && rqueueConfig.isProducer()) {
77+
log.log(Level.FINE,
78+
"NatsDeadLetterBridgeRegistrar: producer-only mode — skipping bridge installation");
79+
return;
80+
}
81+
if (!(broker instanceof JetStreamMessageBroker)) {
82+
// Defensive — the bean is wired only by the NATS auto-config, but other backends could
83+
// theoretically substitute a different MessageBroker via @Primary.
84+
return;
85+
}
86+
JetStreamMessageBroker nb = (JetStreamMessageBroker) broker;
87+
List<QueueDetail> queues = EndpointRegistry.getActiveQueueDetails();
88+
if (queues.isEmpty()) {
89+
return;
90+
}
91+
int installed = 0;
92+
for (QueueDetail q : queues) {
93+
String consumerName = q.resolvedConsumerName();
94+
try {
95+
bridges.add(nb.installDeadLetterBridge(q, consumerName));
96+
installed++;
97+
} catch (RuntimeException e) {
98+
// Best-effort: a single failure must not abort listener startup. The rqueue-level DLQ
99+
// path (PostProcessingHandler.moveToDlq) still works regardless.
100+
log.log(Level.WARNING,
101+
"Failed to install dead-letter advisory bridge for queue " + q.getName()
102+
+ " consumer " + consumerName + ": " + e.getMessage(), e);
103+
}
104+
}
105+
log.log(Level.INFO,
106+
"NatsDeadLetterBridgeRegistrar: installed {0} advisory bridge(s) across {1} queue(s)",
107+
new Object[] {installed, queues.size()});
108+
}
109+
110+
@Override
111+
public void destroy() {
112+
for (AutoCloseable c : bridges) {
113+
try {
114+
c.close();
115+
} catch (Exception ignore) {
116+
// best-effort close; we are shutting down anyway
117+
}
118+
}
119+
bridges.clear();
120+
}
121+
}

rqueue-spring-boot-starter/src/main/java/com/github/sonus21/rqueue/spring/boot/RqueueNatsAutoConfig.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.github.sonus21.rqueue.nats.RqueueNatsConfig;
2222
import com.github.sonus21.rqueue.nats.internal.NatsProvisioner;
2323
import com.github.sonus21.rqueue.nats.js.JetStreamMessageBroker;
24+
import com.github.sonus21.rqueue.nats.js.NatsDeadLetterBridgeRegistrar;
2425
import com.github.sonus21.rqueue.nats.js.NatsStreamValidator;
2526
import com.github.sonus21.rqueue.nats.kv.NatsKvBucketValidator;
2627
import com.github.sonus21.rqueue.nats.metrics.NatsRqueueQueueMetricsProvider;
@@ -162,6 +163,19 @@ public NatsStreamValidator natsStreamValidator(
162163
natsProvisioner, toBrokerConfig(props), rqueueConfigProvider.getIfAvailable());
163164
}
164165

166+
/**
167+
* Boot-time installer for the NATS-native dead-letter advisory bridge: for every registered
168+
* queue, subscribes to {@code $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>}
169+
* and republishes any message that exhausts {@code maxDeliver} onto the queue's DLQ stream.
170+
* Complementary to the rqueue-level DLQ path ({@code PostProcessingHandler.moveToDlq}).
171+
*/
172+
@Bean
173+
@ConditionalOnMissingBean(NatsDeadLetterBridgeRegistrar.class)
174+
public NatsDeadLetterBridgeRegistrar natsDeadLetterBridgeRegistrar(
175+
MessageBroker broker, ObjectProvider<RqueueConfig> rqueueConfigProvider) {
176+
return new NatsDeadLetterBridgeRegistrar(broker, rqueueConfigProvider.getIfAvailable());
177+
}
178+
165179
/**
166180
* Bean form of the KV-bucket validator. Other NATS beans {@code @DependsOn} this name so it runs
167181
* before they are constructed. The flag is sourced from {@link RqueueNatsProperties} —

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsBackendEndToEndIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,8 @@
5858
classes = NatsBackendEndToEndIT.TestApp.class,
5959
properties = {
6060
"rqueue.backend=nats",
61-
"rqueue.nats.stream-prefix=" + NatsBackendEndToEndIT.STREAM_PREFIX,
62-
"rqueue.nats.subject-prefix=" + NatsBackendEndToEndIT.SUBJECT_PREFIX
61+
"rqueue.nats.naming.stream-prefix=" + NatsBackendEndToEndIT.STREAM_PREFIX,
62+
"rqueue.nats.naming.subject-prefix=" + NatsBackendEndToEndIT.SUBJECT_PREFIX
6363
})
6464
@Tag("nats")
6565
class NatsBackendEndToEndIT extends AbstractNatsBootIT {

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsConcurrencyE2EIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
classes = NatsConcurrencyE2EIT.TestApp.class,
4545
properties = {
4646
"rqueue.backend=nats",
47-
"rqueue.nats.stream-prefix=" + NatsConcurrencyE2EIT.STREAM_PREFIX,
48-
"rqueue.nats.subject-prefix=" + NatsConcurrencyE2EIT.SUBJECT_PREFIX
47+
"rqueue.nats.naming.stream-prefix=" + NatsConcurrencyE2EIT.STREAM_PREFIX,
48+
"rqueue.nats.naming.subject-prefix=" + NatsConcurrencyE2EIT.SUBJECT_PREFIX
4949
})
5050
@Tag("nats")
5151
class NatsConcurrencyE2EIT extends AbstractNatsBootIT {

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsConsumerNameOverrideE2EIT.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@
4545
classes = NatsConsumerNameOverrideE2EIT.TestApp.class,
4646
properties = {
4747
"rqueue.backend=nats",
48-
"rqueue.nats.stream-prefix=" + NatsConsumerNameOverrideE2EIT.STREAM_PREFIX,
49-
"rqueue.nats.subject-prefix=" + NatsConsumerNameOverrideE2EIT.SUBJECT_PREFIX
48+
"rqueue.nats.naming.stream-prefix=" + NatsConsumerNameOverrideE2EIT.STREAM_PREFIX,
49+
"rqueue.nats.naming.subject-prefix=" + NatsConsumerNameOverrideE2EIT.SUBJECT_PREFIX
5050
})
5151
@Tag("nats")
5252
class NatsConsumerNameOverrideE2EIT extends AbstractNatsBootIT {
@@ -73,7 +73,8 @@ void overriddenConsumerNameIsRegisteredOnTheStream() throws Exception {
7373
enqueuer.enqueue("custom-consumer", "hello");
7474
assertThat(listener.latch.await(20, TimeUnit.SECONDS)).isTrue();
7575

76-
ConsumerInfo info = jsm.getConsumerInfo("rqueue-js-custom-consumer", "my-custom-consumer");
76+
ConsumerInfo info =
77+
jsm.getConsumerInfo(STREAM_PREFIX + "custom-consumer", "my-custom-consumer");
7778
assertThat(info).isNotNull();
7879
assertThat(info.getName()).isEqualTo("my-custom-consumer");
7980
}

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsMultipleListenersOnSameQueueE2EIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@
4848
classes = NatsMultipleListenersOnSameQueueE2EIT.TestApp.class,
4949
properties = {
5050
"rqueue.backend=nats",
51-
"rqueue.nats.stream-prefix=" + NatsMultipleListenersOnSameQueueE2EIT.STREAM_PREFIX,
52-
"rqueue.nats.subject-prefix=" + NatsMultipleListenersOnSameQueueE2EIT.SUBJECT_PREFIX
51+
"rqueue.nats.naming.stream-prefix=" + NatsMultipleListenersOnSameQueueE2EIT.STREAM_PREFIX,
52+
"rqueue.nats.naming.subject-prefix=" + NatsMultipleListenersOnSameQueueE2EIT.SUBJECT_PREFIX
5353
})
5454
@Tag("nats")
5555
@Disabled("Default JetStream retention=WorkQueue prevents true fan-out across multiple consumers; "

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsPriorityQueuesE2EIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
classes = NatsPriorityQueuesE2EIT.TestApp.class,
4747
properties = {
4848
"rqueue.backend=nats",
49-
"rqueue.nats.stream-prefix=" + NatsPriorityQueuesE2EIT.STREAM_PREFIX,
50-
"rqueue.nats.subject-prefix=" + NatsPriorityQueuesE2EIT.SUBJECT_PREFIX
49+
"rqueue.nats.naming.stream-prefix=" + NatsPriorityQueuesE2EIT.STREAM_PREFIX,
50+
"rqueue.nats.naming.subject-prefix=" + NatsPriorityQueuesE2EIT.SUBJECT_PREFIX
5151
})
5252
@Tag("nats")
5353
class NatsPriorityQueuesE2EIT extends AbstractNatsBootIT {

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsReactiveEnqueueE2EIT.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@
4747
properties = {
4848
"rqueue.backend=nats",
4949
"rqueue.reactive.enabled=true",
50-
"rqueue.nats.stream-prefix=" + NatsReactiveEnqueueE2EIT.STREAM_PREFIX,
51-
"rqueue.nats.subject-prefix=" + NatsReactiveEnqueueE2EIT.SUBJECT_PREFIX
50+
"rqueue.nats.naming.stream-prefix=" + NatsReactiveEnqueueE2EIT.STREAM_PREFIX,
51+
"rqueue.nats.naming.subject-prefix=" + NatsReactiveEnqueueE2EIT.SUBJECT_PREFIX
5252
})
5353
@Tag("nats")
5454
class NatsReactiveEnqueueE2EIT extends AbstractNatsBootIT {

rqueue-spring-boot-starter/src/test/java/com/github/sonus21/rqueue/spring/boot/integration/NatsRetryAndDlqE2EIT.java

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -19,45 +19,53 @@
1919

2020
import com.github.sonus21.rqueue.annotation.RqueueListener;
2121
import com.github.sonus21.rqueue.core.RqueueMessageEnqueuer;
22+
import io.nats.client.Connection;
2223
import io.nats.client.JetStreamManagement;
2324
import io.nats.client.api.StreamInfo;
25+
import java.nio.charset.StandardCharsets;
2426
import java.time.Duration;
25-
import java.util.concurrent.atomic.AtomicInteger;
27+
import java.util.concurrent.CountDownLatch;
28+
import java.util.concurrent.TimeUnit;
2629
import org.awaitility.Awaitility;
2730
import org.junit.jupiter.api.BeforeAll;
28-
import org.junit.jupiter.api.Disabled;
2931
import org.junit.jupiter.api.Tag;
3032
import org.junit.jupiter.api.Test;
3133
import org.springframework.beans.factory.annotation.Autowired;
3234
import org.springframework.boot.autoconfigure.SpringBootApplication;
3335
import org.springframework.boot.data.redis.autoconfigure.DataRedisAutoConfiguration;
3436
import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration;
3537
import org.springframework.boot.test.context.SpringBootTest;
38+
import org.springframework.context.annotation.Import;
3639
import org.springframework.stereotype.Component;
3740

3841
/**
39-
* After a handler exhausts {@code numRetries}, JetStream emits a max-deliveries advisory and the
40-
* broker's {@code installDeadLetterBridge} dispatcher republishes the payload onto the DLQ
41-
* stream. Currently disabled because {@link
42-
* com.github.sonus21.rqueue.spring.boot.RqueueNatsAutoConfig} does not yet invoke
43-
* {@code JetStreamMessageBroker.installDeadLetterBridge(...)} during container start, so dead-
44-
* lettered messages never reach the DLQ stream. Enable this test once that wiring is added.
42+
* Verifies the NATS-native dead-letter advisory bridge installed by
43+
* {@link com.github.sonus21.rqueue.nats.js.NatsDeadLetterBridgeRegistrar}: when JetStream emits
44+
* {@code $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.<stream>.<consumer>} for a queue's durable
45+
* consumer, the registrar's dispatcher looks up the offending message by sequence number and
46+
* republishes it onto the queue's DLQ stream ({@code <streamPrefix><queue><dlqStreamSuffix>}).
47+
*
48+
* <p><b>Why a synthetic advisory.</b> In normal rqueue flow the framework either acks
49+
* (success / forced-discard / moveToDlq) or naks (retry) every delivery, so NATS sees a terminal
50+
* action before {@code maxDeliver} elapses and the advisory never fires from a real handler — that
51+
* path is covered by
52+
* {@code NatsSchedulingAdvancedE2EIT#scheduledMessageExhaustsRetriesToDlq} via the rqueue-level
53+
* DLQ ({@code PostProcessingHandler.moveToDlq}). The advisory bridge is a defensive net for cases
54+
* outside rqueue's control (process crash mid-handler, or a handler that blocks past its
55+
* visibility timeout AND past every retry while NATS keeps redelivering). Triggering that path
56+
* end-to-end is racy and slow, so this test instead publishes a synthetic advisory matching the
57+
* shape {@code nats-server 2.12} actually emits and asserts the dispatcher reacts: enqueues a
58+
* payload, looks up its stream sequence, fakes the advisory, and waits for the DLQ stream to
59+
* receive it.
4560
*/
4661
@SpringBootTest(
4762
classes = NatsRetryAndDlqE2EIT.TestApp.class,
4863
properties = {
4964
"rqueue.backend=nats",
50-
"rqueue.nats.stream-prefix=" + NatsRetryAndDlqE2EIT.STREAM_PREFIX,
51-
"rqueue.nats.subject-prefix=" + NatsRetryAndDlqE2EIT.SUBJECT_PREFIX
65+
"rqueue.nats.naming.stream-prefix=" + NatsRetryAndDlqE2EIT.STREAM_PREFIX,
66+
"rqueue.nats.naming.subject-prefix=" + NatsRetryAndDlqE2EIT.SUBJECT_PREFIX
5267
})
5368
@Tag("nats")
54-
@Disabled("This test exercises the NATS-native advisory bridge path"
55-
+ " (JetStreamMessageBroker.installDeadLetterBridge /"
56-
+ " $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES), which is not yet wired by"
57-
+ " RqueueNatsAutoConfig. The rqueue-level DLQ path (PostProcessingHandler → broker.moveToDlq)"
58-
+ " is already tested in NatsSchedulingAdvancedE2EIT#scheduledMessageExhaustsRetriesToDlq and"
59-
+ " works without this bridge. Enable this test once RqueueNatsAutoConfig provisions the"
60-
+ " advisory dispatcher per queue during container start.")
6169
class NatsRetryAndDlqE2EIT extends AbstractNatsBootIT {
6270

6371
static final String STREAM_PREFIX = "rqueue-js-retryDlqE2E-";
@@ -72,35 +80,82 @@ static void wipeOwnedStreams() {
7280
RqueueMessageEnqueuer enqueuer;
7381

7482
@Autowired
75-
FailingListener listener;
83+
Connection natsConnection;
7684

7785
@Autowired
7886
JetStreamManagement jsm;
7987

88+
@Autowired
89+
BlockingListener listener;
90+
8091
@Test
81-
void exhaustedMessageLandsOnDlqStream() {
82-
enqueuer.enqueue("failing", "boom");
92+
void advisoryBridgeRepublishesIntoDlqStream() throws Exception {
93+
String stream = STREAM_PREFIX + "boom";
94+
String dlqStream = stream + "-dlq";
95+
96+
// Publish via the normal enqueue path so the source stream + bridge get provisioned exactly
97+
// the way they would in production. The blocking listener picks up the delivery but never
98+
// returns, so the message stays in flight (un-acked) in the WorkQueue stream — which is the
99+
// realistic state when JetStream actually fires the max-delivery advisory in production
100+
// (handler hung, ack never sent). The long visibilityTimeout keeps NATS from redelivering
101+
// during the test.
102+
enqueuer.enqueue("boom", "marker-payload");
103+
104+
// Confirm the listener has the message in flight (proves the source stream still has it: in
105+
// WorkQueue retention an unacked message stays in the stream until acked or AckWait expires).
106+
assertThat(listener.received.await(20, TimeUnit.SECONDS))
107+
.as("Listener must receive the marker payload before we synthesize the advisory")
108+
.isTrue();
109+
110+
// Bridge installer provisioned the DLQ stream from boot (installDeadLetterBridge → provisionDlq).
111+
Awaitility.await().atMost(Duration.ofSeconds(5)).untilAsserted(() -> {
112+
assertThat(jsm.getStreamInfo(dlqStream)).isNotNull();
113+
});
114+
long sourceSeq = jsm.getStreamInfo(stream).getStreamState().getLastSequence();
83115

84-
Awaitility.await().atMost(Duration.ofSeconds(60)).until(() -> listener.attempts.get() >= 2);
116+
// Synthetic max-delivery advisory matching nats-server 2.12's payload shape: only stream_seq
117+
// is required by the bridge's republish logic. The advisory subject must include
118+
// <stream>.<consumer>; consumer name is the rqueue default for this queue, which is
119+
// <queueName>-consumer (see QueueDetail#resolvedConsumerName).
120+
String consumer = "boom-consumer";
121+
String advisorySubject =
122+
"$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES." + stream + "." + consumer;
123+
String advisoryJson = "{\"type\":\"io.nats.jetstream.advisory.v1.max_deliveries\","
124+
+ "\"stream\":\"" + stream + "\","
125+
+ "\"consumer\":\"" + consumer + "\","
126+
+ "\"stream_seq\":" + sourceSeq + ","
127+
+ "\"deliveries\":3}";
128+
natsConnection.publish(advisorySubject, advisoryJson.getBytes(StandardCharsets.UTF_8));
129+
natsConnection.flush(Duration.ofSeconds(2));
85130

86-
Awaitility.await().atMost(Duration.ofSeconds(30)).untilAsserted(() -> {
87-
StreamInfo dlq = jsm.getStreamInfo("rqueue-js-failing-dlq");
131+
// The bridge's dispatcher should have republished the source message onto the DLQ stream.
132+
Awaitility.await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> {
133+
StreamInfo dlq = jsm.getStreamInfo(dlqStream);
88134
assertThat(dlq.getStreamState().getMsgCount()).isGreaterThanOrEqualTo(1);
89135
});
90136
}
91137

92138
@SpringBootApplication(
93139
exclude = {DataRedisAutoConfiguration.class, DataRedisReactiveAutoConfiguration.class})
140+
@Import(BlockingListener.class)
94141
static class TestApp {}
95142

143+
/**
144+
* Receives the marker payload, signals the test, then blocks past the test's runtime so the
145+
* message stays in flight (un-acked) in the WorkQueue source stream. That keeps it reachable via
146+
* {@code jsm.getMessage(stream, seq)} — which is what the advisory bridge does on receipt — and
147+
* mirrors the production scenario where the bridge fires (handler hung past
148+
* {@code visibilityTimeout}, NATS still considers the message un-acked). The 2-minute
149+
* visibility timeout prevents NATS from redelivering before the test completes.
150+
*/
96151
@Component
97-
static class FailingListener {
98-
final AtomicInteger attempts = new AtomicInteger();
152+
static class BlockingListener {
153+
final CountDownLatch received = new CountDownLatch(1);
99154

100-
@RqueueListener(value = "failing", numRetries = "2")
101-
void onMessage(String payload) {
102-
attempts.incrementAndGet();
103-
throw new RuntimeException("simulated failure for payload=" + payload);
155+
@RqueueListener(value = "boom", visibilityTimeout = "120000")
156+
void onMessage(String payload) throws Exception {
157+
received.countDown();
158+
Thread.sleep(120_000L); // far exceeds the test's 30s budget
104159
}
105160
}
106161
}

0 commit comments

Comments
 (0)