This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 187bcde98c2c CAMEL-25847: camel-nats - a stopped JetStream push
consumer unsubscribes through its dispatcher (#28320)
187bcde98c2c is described below
commit 187bcde98c2c58d094f2c11018ae12a2c4d4e995
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Oct 25 14:00:22 2026 +0530
CAMEL-25847: camel-nats - a stopped JetStream push consumer unsubscribes
through its dispatcher (#28320)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claus Ibsen <[email protected]>
---
.../apache/camel/component/nats/NatsConsumer.java | 98 ++++-
.../nats/NatsConsumerJetStreamPushStopTest.java | 438 +++++++++++++++++++++
2 files changed, 534 insertions(+), 2 deletions(-)
diff --git
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
index 567fe9673e91..7a31579e1d61 100644
---
a/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
+++
b/components/camel-nats/src/main/java/org/apache/camel/component/nats/NatsConsumer.java
@@ -65,6 +65,8 @@ public class NatsConsumer extends DefaultConsumer {
private ExecutorService executor;
private Connection connection;
private Dispatcher dispatcher;
+ // the message handler of the dispatcher, which keeps the messages in
flight of that dispatcher (one per start)
+ private volatile NatsConsumingTask.CamelNatsMessageHandler
dispatcherHandler;
private boolean active;
// set by the consuming task, and again by the push consumer keeper, and
read when the consumer stops
private volatile JetStreamSubscription jetStreamSubscription;
@@ -127,6 +129,33 @@ public class NatsConsumer extends DefaultConsumer {
} catch (final Exception e) {
this.getExceptionHandler().handleException("Error during
unsubscribing", e);
}
+ // a JetStream push subscription belongs to the dispatcher (it
cannot unsubscribe directly) and is on its
+ // deliver subject, not on the topic: end it through the
dispatcher, which matters when the connection is
+ // shared and stays open. This does not interrupt the dispatcher
thread, which may still run an exchange
+ JetStreamSubscription pushSubscription =
this.jetStreamSubscription;
+ if (pushSubscription != null && pushSubscription.isActive()) {
+ try {
+ this.dispatcher.unsubscribe(pushSubscription);
+ } catch (final Exception e) {
+ this.getExceptionHandler().handleException("Error during
unsubscribing", e);
+ }
+ }
+ // the dispatcher was created by this consumer: close it (which
ends its thread) once no message is in
+ // flight, as closing it interrupts its thread, and Camel stops a
consumer before it waits for the exchanges
+ // in flight
+ final Dispatcher stoppedDispatcher = this.dispatcher;
+ final Connection stoppedConnection = this.connection;
+ Runnable close = () -> closeDispatcher(stoppedConnection,
stoppedDispatcher);
+ NatsConsumingTask.CamelNatsMessageHandler handler =
this.dispatcherHandler;
+ if (handler != null) {
+ // the handler of this dispatcher only: a message of an
earlier run that is still in flight (after a
+ // forced stop) closes the dispatcher of its own run
+ handler.closeWhenIdle(close);
+ } else {
+ close.run();
+ }
+ this.dispatcher = null;
+ this.dispatcherHandler = null;
}
if (ObjectHelper.isNotEmpty(this.jetStreamSubscription) &&
this.jetStreamSubscription.isActive()) {
@@ -156,6 +185,19 @@ public class NatsConsumer extends DefaultConsumer {
super.doStop();
}
+ /**
+ * Closes a dispatcher of this consumer, unless its connection is closed
(which closed it).
+ */
+ private void closeDispatcher(Connection c, Dispatcher d) {
+ if (c != null && c.getStatus() != Connection.Status.CLOSED) {
+ try {
+ c.closeDispatcher(d);
+ } catch (final Exception e) {
+ LOG.debug("Error closing the dispatcher of the stopped
consumer", e);
+ }
+ }
+ }
+
public boolean isActive() {
return this.active;
}
@@ -204,7 +246,14 @@ public class NatsConsumer extends DefaultConsumer {
setupStandardNatsConsumer(topic, queueName, maxMessages);
}
} catch (final Exception e) {
- NatsConsumer.this.getExceptionHandler().handleException("Error
during processing", e);
+ if (!NatsConsumer.this.isRunAllowed()) {
+ // the consumer stopped meanwhile (its stop closes the
dispatcher that a JetStream push subscribe
+ // uses, which then fails): nothing to report
+ LOG.debug("Not consuming from topic {} as the consumer is
stopping",
+ getEndpoint().getConfiguration().getTopic(), e);
+ } else {
+
NatsConsumer.this.getExceptionHandler().handleException("Error during
processing", e);
+ }
}
}
@@ -276,6 +325,7 @@ public class NatsConsumer extends DefaultConsumer {
runPullFetchLoop(NatsConsumer.this.jetStreamSubscription,
messageHandler, cc);
} else {
NatsConsumer.this.dispatcher =
this.connection.createDispatcher(messageHandler);
+ NatsConsumer.this.dispatcherHandler = messageHandler;
PushSubscribeOptions pushOptions =
PushSubscribeOptions.builder()
.configuration(cc)
@@ -538,7 +588,9 @@ public class NatsConsumer extends DefaultConsumer {
}
private void setupStandardNatsConsumer(String topic, String queueName,
Integer maxMessages) {
- NatsConsumer.this.dispatcher = connection.createDispatcher(new
CamelNatsMessageHandler());
+ CamelNatsMessageHandler messageHandler = new
CamelNatsMessageHandler();
+ NatsConsumer.this.dispatcher =
connection.createDispatcher(messageHandler);
+ NatsConsumer.this.dispatcherHandler = messageHandler;
if (ObjectHelper.isNotEmpty(queueName)) {
NatsConsumer.this.dispatcher =
NatsConsumer.this.dispatcher.subscribe(topic, queueName);
} else {
@@ -556,9 +608,51 @@ public class NatsConsumer extends DefaultConsumer {
final boolean ackPolicyNone = configuration.getAckPolicy() ==
AckPolicy.None;
final boolean manualAckEnabled = configuration.isManualAck() &&
configuration.isJetstreamEnabled();
+ // the messages that this handler is processing, and the close of
its dispatcher (the consumer stopped)
+ // that waits until none is: closing a dispatcher interrupts its
thread, which runs the route
+ private final Object inFlightLock = new Object();
+ private int messagesInFlight;
+ private Runnable pendingClose;
+
+ /**
+ * Closes the dispatcher of this handler now, or once the message
in flight is done.
+ */
+ void closeWhenIdle(Runnable close) {
+ synchronized (inFlightLock) {
+ if (messagesInFlight > 0) {
+ // closed by onMessage once the message is done
+ pendingClose = close;
+ return;
+ }
+ }
+ // no message in flight: close now (outside the lock, as the
close may block)
+ close.run();
+ }
@Override
public void onMessage(Message msg) throws InterruptedException {
+ synchronized (inFlightLock) {
+ messagesInFlight++;
+ }
+ try {
+ processMessage(msg);
+ } finally {
+ Runnable close = null;
+ synchronized (inFlightLock) {
+ if (--messagesInFlight == 0) {
+ close = pendingClose;
+ pendingClose = null;
+ }
+ }
+ if (close != null) {
+ // the consumer stopped while this message was
processed: close its dispatcher now (on this
+ // thread, which the close interrupts once the message
is done)
+ close.run();
+ }
+ }
+ }
+
+ private void processMessage(Message msg) throws
InterruptedException {
LOG.debug("Received Message: {}", msg);
final Exchange exchange =
NatsConsumer.this.createExchange(false);
diff --git
a/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerJetStreamPushStopTest.java
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerJetStreamPushStopTest.java
new file mode 100644
index 000000000000..ea1f942f82b4
--- /dev/null
+++
b/components/camel-nats/src/test/java/org/apache/camel/component/nats/NatsConsumerJetStreamPushStopTest.java
@@ -0,0 +1,438 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.nats;
+
+import java.lang.reflect.Proxy;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.nats.client.Connection;
+import io.nats.client.Dispatcher;
+import io.nats.client.JetStream;
+import io.nats.client.JetStreamManagement;
+import io.nats.client.JetStreamSubscription;
+import io.nats.client.Message;
+import io.nats.client.MessageHandler;
+import io.nats.client.PushSubscribeOptions;
+import io.nats.client.api.ConsumerConfiguration;
+import io.nats.client.api.DeliverPolicy;
+import io.nats.client.api.ServerInfo;
+import io.nats.client.impl.NatsJetStreamMetaData;
+import io.nats.client.impl.NatsMessage;
+import org.apache.camel.Consumer;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.spi.ExceptionHandler;
+import org.apache.camel.support.DefaultConsumer;
+import org.apache.camel.support.service.ServiceHelper;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * A JetStream push subscription belongs to the dispatcher of the consumer,
and jnats refuses to unsubscribe it directly
+ * ("Subscriptions that belong to a dispatcher cannot respond to unsubscribe
directly"). When the connection is shared
+ * (connection option) it stays open when the route stops, so the consumer
must unsubscribe through its dispatcher;
+ * otherwise the stopped route keeps getting messages, and after a restart
every message is routed twice.
+ */
+class NatsConsumerJetStreamPushStopTest extends CamelTestSupport {
+
+ private final FakeServer server = new FakeServer();
+ private final CountDownLatch slowEntered = new CountDownLatch(1);
+ private final CountDownLatch releaseSlow = new CountDownLatch(1);
+ private final AtomicBoolean slowInterrupted = new AtomicBoolean();
+ private final AtomicBoolean slowDone = new AtomicBoolean();
+
+ @Override
+ protected RouteBuilder createRouteBuilder() {
+ context.getRegistry().bind("conn", server.connection());
+ context.getRegistry().bind("cc",
ConsumerConfiguration.builder().deliverPolicy(DeliverPolicy.New).build());
+ return new RouteBuilder() {
+ @Override
+ public void configure() {
+
from("nats:orders?connection=#conn&jetstreamEnabled=true&jetstreamName=ORDERS&pullSubscription=false"
+ + "&consumerConfiguration=#cc")
+ .routeId("orders")
+ .convertBodyTo(String.class)
+ .process(e -> {
+ if ("slow".equals(e.getMessage().getBody())) {
+ // an exchange in flight while the route
stops, such as a call that waits on I/O
+ slowEntered.countDown();
+ try {
+ releaseSlow.await(20, TimeUnit.SECONDS);
+ } catch (InterruptedException ie) {
+ slowInterrupted.set(true);
+ Thread.currentThread().interrupt();
+ throw ie;
+ }
+ slowDone.set(true);
+ }
+ })
+ .to("mock:result");
+ }
+ };
+ }
+
+ @Test
+ void stoppedRouteGetsNoMessagesAndRestartedRouteGetsEachMessageOnce()
throws Exception {
+ MockEndpoint mock = getMockEndpoint("mock:result");
+ mock.expectedBodiesReceived("order-1");
+ server.publish("order-1");
+ mock.assertIsSatisfied();
+
+ context.getRouteController().stopRoute("orders");
+ int afterStop = server.activeSubscriptions();
+
+ // the route is started again: one subscription, each message routed
once
+ context.getRouteController().startRoute("orders");
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.activeSubscriptions() > 0);
+ mock.reset();
+ mock.expectedBodiesReceived("order-2");
+ // and no second copy shortly after
+ mock.setAssertPeriod(500);
+ server.publish("order-2");
+ mock.assertIsSatisfied();
+ assertEquals(0, afterStop, "no subscription left once the route
stopped");
+ assertEquals(1, server.activeSubscriptions(), "one subscription once
the route started again");
+ assertEquals(1, server.closedDispatchers(), "the dispatcher of the
first run is closed");
+ }
+
+ @Test
+ void stoppingTheRouteDoesNotInterruptTheExchangeInFlight() throws
Exception {
+ server.publish("slow");
+ assertTrue(slowEntered.await(10, TimeUnit.SECONDS), "the exchange is
in flight");
+
+ // the shutdown strategy stops the consumer (it is not Suspendable)
and then waits for the exchange in flight
+ Thread stopper = new Thread(() -> {
+ try {
+ context.getRouteController().stopRoute("orders");
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }, "route-stopper");
+ stopper.start();
+ await().atMost(10, TimeUnit.SECONDS)
+ .until(() ->
ServiceHelper.isStopped(context.getRoute("orders").getConsumer()));
+ assertEquals(0, server.activeSubscriptions(), "no subscription left
once the consumer stopped");
+ releaseSlow.countDown();
+ stopper.join(TimeUnit.SECONDS.toMillis(20));
+
+ assertFalse(slowInterrupted.get(), "the step in flight must not be
interrupted by the stop");
+ assertTrue(slowDone.get(), "the step in flight completes");
+ // the dispatcher (and its thread) is closed once the message is done
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.closedDispatchers() == 1);
+ }
+
+ @Test
+ void eachRunOfTheConsumerClosesItsOwnDispatcher() throws Exception {
+ server.publish("slow");
+ assertTrue(slowEntered.await(10, TimeUnit.SECONDS), "the exchange is
in flight");
+
+ // a route policy (such as a throttling policy) stops and starts the
same consumer, which is not Suspendable,
+ // while an exchange is in flight: each start makes a new dispatcher
+ Consumer consumer = context.getRoute("orders").getConsumer();
+ ServiceHelper.stopService(consumer);
+ assertEquals(0, server.closedDispatchers(), "the dispatcher of the
exchange in flight is not closed yet");
+ ServiceHelper.startService(consumer);
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.activeSubscriptions() > 0);
+
+ // the second run has no message in flight, so its dispatcher is
closed when it stops
+ ServiceHelper.stopService(consumer);
+ assertEquals(1, server.closedDispatchers(), "the dispatcher of the
second run is closed when it stops");
+
+ // the exchange of the first run is done: the dispatcher of the first
run is closed too
+ releaseSlow.countDown();
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.closedDispatchers() == 2);
+ assertFalse(slowInterrupted.get(), "the step in flight must not be
interrupted by the stops");
+ }
+
+ @Test
+ void stopWhileSubscribingIsNotReportedAsAnError() throws Exception {
+ DefaultConsumer consumer = (DefaultConsumer)
context.getRoute("orders").getConsumer();
+ ServiceHelper.stopService(consumer);
+ List<Throwable> reported = new CopyOnWriteArrayList<>();
+ consumer.setExceptionHandler(new ExceptionHandler() {
+ @Override
+ public void handleException(Throwable exception) {
+ reported.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Throwable exception) {
+ reported.add(exception);
+ }
+
+ @Override
+ public void handleException(String message, Exchange exchange,
Throwable exception) {
+ reported.add(exception);
+ }
+ });
+
+ // the consumer stops while its task subscribes: the stop closes the
dispatcher that the task passes to the
+ // subscribe, which then fails with "Dispatcher is closed"
+ server.beforeSubscribe = () -> ServiceHelper.stopService(consumer);
+ ServiceHelper.startService(consumer);
+ await().atMost(10, TimeUnit.SECONDS).until(() ->
server.refusedSubscribes() == 1);
+
+ // the default exception handler would log it at WARN, but a stop is
not an error
+ await().during(Duration.ofMillis(500)).atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() -> assertTrue(reported.isEmpty(), () ->
"reported: " + reported));
+ assertEquals(0, server.activeSubscriptions(), "no subscription left
once the consumer stopped");
+ }
+
+ /**
+ * A JetStream server with one stream, faked at the level of the jnats
interfaces the consumer calls. Each push
+ * subscription has its own consumer (DeliverPolicy.New starts after the
last published message) which delivers on
+ * its own thread while the subscription is active. Like jnats, a
subscription that belongs to a dispatcher cannot
+ * unsubscribe directly, the dispatcher unsubscribes it by subscription
(not by the topic: a push subscription is on
+ * its deliver subject), and closing the dispatcher unsubscribes all its
subscriptions and interrupts the thread
+ * that delivers its messages (jnats NatsDispatcher.stop cancels its
thread with Future.cancel(true)). A closed
+ * dispatcher refuses to subscribe or unsubscribe ("Dispatcher is closed").
+ */
+ static final class FakeServer {
+ final List<byte[]> stream = new ArrayList<>();
+ final List<FakeSubscription> subscriptions = new ArrayList<>();
+ final Map<JetStreamSubscription, FakeSubscription> bySubscription =
new IdentityHashMap<>();
+ final Set<Dispatcher> closed = Collections.newSetFromMap(new
IdentityHashMap<>());
+ // runs on the thread of the consumer, before a push subscribe reaches
the server
+ volatile Runnable beforeSubscribe;
+ int refusedSubscribes;
+
+ final class FakeSubscription {
+ final Dispatcher dispatcher;
+ Thread thread;
+ long next;
+ boolean active = true;
+ final List<Long> redeliver = new ArrayList<>();
+ final Map<Long, Integer> naks = new HashMap<>();
+
+ FakeSubscription(Dispatcher dispatcher) {
+ this.dispatcher = dispatcher;
+ }
+ }
+
+ synchronized void publish(String body) {
+ stream.add(body.getBytes(StandardCharsets.UTF_8));
+ notifyAll();
+ }
+
+ synchronized int refusedSubscribes() {
+ return refusedSubscribes;
+ }
+
+ synchronized int closedDispatchers() {
+ return closed.size();
+ }
+
+ synchronized int activeSubscriptions() {
+ return (int) subscriptions.stream().filter(s -> s.active).count();
+ }
+
+ Connection connection() {
+ JetStreamManagement jsm = proxy(JetStreamManagement.class, (name,
args) -> null);
+ JetStream js = proxy(JetStream.class, (name, args) -> {
+ if ("subscribe".equals(name) && args.length == 6 && args[5]
instanceof PushSubscribeOptions o) {
+ Runnable hook = beforeSubscribe;
+ if (hook != null) {
+ hook.run();
+ }
+ return subscribe(o.getConsumerConfiguration(),
(Dispatcher) args[2], (MessageHandler) args[3]);
+ }
+ throw new UnsupportedOperationException(name);
+ });
+ ServerInfo info = new ServerInfo("INFO
{\"server_id\":\"fake\",\"jetstream\":true}");
+ return proxy(Connection.class, (name, args) -> switch (name) {
+ case "getServerInfo" -> info;
+ case "jetStreamManagement" -> jsm;
+ case "jetStream" -> js;
+ case "createDispatcher" -> createDispatcher();
+ case "closeDispatcher" -> {
+ closeDispatcher((Dispatcher) args[0]);
+ yield null;
+ }
+ case "getStatus" -> Connection.Status.CONNECTED;
+ default -> null;
+ });
+ }
+
+ private Dispatcher createDispatcher() {
+ Dispatcher[] self = new Dispatcher[1];
+ self[0] = proxy(Dispatcher.class, (name, args) -> {
+ synchronized (this) {
+ if (closed.contains(self[0])) {
+ throw new IllegalStateException("Dispatcher is
closed");
+ }
+ }
+ if ("unsubscribe".equals(name) && args[0] instanceof
JetStreamSubscription s) {
+ unsubscribe(s);
+ }
+ // unsubscribe by subject: a push subscription is on its
deliver subject, not on the topic
+ return self[0];
+ });
+ return self[0];
+ }
+
+ private synchronized void unsubscribe(JetStreamSubscription s) {
+ FakeSubscription f = bySubscription.get(s);
+ if (f != null) {
+ f.active = false;
+ notifyAll();
+ }
+ }
+
+ private synchronized void closeDispatcher(Dispatcher d) {
+ if (!closed.add(d)) {
+ throw new IllegalArgumentException("Dispatcher is already
closed.");
+ }
+ for (FakeSubscription f : subscriptions) {
+ if (f.dispatcher == d) {
+ f.active = false;
+ // the thread of the dispatcher is cancelled with an
interrupt
+ f.thread.interrupt();
+ }
+ }
+ notifyAll();
+ }
+
+ private synchronized JetStreamSubscription subscribe(
+ ConsumerConfiguration cc, Dispatcher dispatcher,
MessageHandler handler) {
+ if (closed.contains(dispatcher)) {
+ refusedSubscribes++;
+ throw new IllegalStateException("Dispatcher is closed");
+ }
+ FakeSubscription f = new FakeSubscription(dispatcher);
+ f.next = cc != null && cc.getDeliverPolicy() == DeliverPolicy.New
? stream.size() + 1 : 1;
+ subscriptions.add(f);
+ JetStreamSubscription sub = proxy(JetStreamSubscription.class,
(name, args) -> switch (name) {
+ case "unsubscribe" -> throw new IllegalStateException(
+ "Subscriptions that belong to a dispatcher cannot
respond to unsubscribe directly.");
+ case "isActive" -> {
+ synchronized (this) {
+ yield f.active;
+ }
+ }
+ case "getDispatcher" -> f.dispatcher;
+ default -> null;
+ });
+ bySubscription.put(sub, f);
+ Thread t = new Thread(() -> deliver(f, sub, handler), "fake-push-"
+ subscriptions.size());
+ t.setDaemon(true);
+ f.thread = t;
+ t.start();
+ return sub;
+ }
+
+ private void deliver(FakeSubscription f, JetStreamSubscription sub,
MessageHandler handler) {
+ while (true) {
+ long seq;
+ synchronized (this) {
+ while (f.active && f.redeliver.isEmpty() && f.next >
stream.size()) {
+ try {
+ wait();
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ if (!f.active) {
+ return;
+ }
+ seq = f.redeliver.isEmpty() ? f.next++ :
f.redeliver.remove(0);
+ }
+ try {
+ handler.onMessage(message(f, sub, seq));
+ } catch (InterruptedException e) {
+ return;
+ }
+ }
+ }
+
+ private Message message(FakeSubscription f, JetStreamSubscription sub,
long seq) {
+ byte[] data;
+ synchronized (this) {
+ data = stream.get((int) seq - 1);
+ }
+ // a JetStream message carries its metadata in the reply subject
of the acknowledgement
+ NatsJetStreamMetaData meta = new NatsJetStreamMetaData(
+ new NatsMessage("orders", "$JS.ACK.ORDERS.c.1." + seq +
"." + seq + ".0.0", data) {
+ @Override
+ public boolean isJetStream() {
+ return true;
+ }
+ });
+ return proxy(Message.class, (name, args) -> switch (name) {
+ case "getData" -> data;
+ case "getSubject" -> "orders";
+ case "getSID" -> "1";
+ case "getSubscription" -> sub;
+ case "isJetStream" -> true;
+ case "isStatusMessage" -> false;
+ case "metaData" -> meta;
+ case "nak", "nakWithDelay" -> {
+ synchronized (this) {
+ // like maxDeliver=3: a message nak'ed three times is
not delivered again
+ if (f.naks.merge(seq, 1, Integer::sum) < 3 &&
f.active) {
+ f.redeliver.add(seq);
+ notifyAll();
+ }
+ }
+ yield null;
+ }
+ default -> null;
+ });
+ }
+
+ @FunctionalInterface
+ interface Handler {
+ Object handle(String method, Object[] args) throws Exception;
+ }
+
+ @SuppressWarnings("unchecked")
+ static <T> T proxy(Class<T> type, Handler handler) {
+ return (T)
Proxy.newProxyInstance(FakeServer.class.getClassLoader(), new Class<?>[] { type
},
+ (p, m, a) -> {
+ if (m.getDeclaringClass() == Object.class) {
+ return switch (m.getName()) {
+ case "hashCode" -> System.identityHashCode(p);
+ case "equals" -> p == a[0];
+ default -> type.getSimpleName() + "@fake";
+ };
+ }
+ Object r = handler.handle(m.getName(), a == null ? new
Object[0] : a);
+ if (r == null && m.getReturnType() == boolean.class) {
+ return false;
+ }
+ return r;
+ });
+ }
+ }
+}