From c03fa923d96e6abf46bff4d7b746554f711e60e9 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Thu, 16 Jul 2026 17:33:39 -0500 Subject: [PATCH 1/4] [#1715] Use instanceof for ConsumerInfo check in network bridge advisory handling - serviceRemoteConsumerAdvisory() used an exact class check (data.getClass() == ConsumerInfo.class) which silently drops any ConsumerInfo subclass. Change to instanceof so subclasses are accepted by the advisory processing path. - DemandForwardingBridgeSupport for subscriptions with same name --- .../network/DemandForwardingBridgeSupport.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java index 17eb9c55294..d5a00a669c0 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java +++ b/activemq-broker/src/main/java/org/apache/activemq/network/DemandForwardingBridgeSupport.java @@ -940,7 +940,7 @@ public void run() { private void serviceRemoteConsumerAdvisory(DataStructure data) throws IOException { final int networkTTL = configuration.getConsumerTTL(); - if (data.getClass() == ConsumerInfo.class) { + if (data instanceof ConsumerInfo) { // Create a new local subscription ConsumerInfo info = (ConsumerInfo) data; BrokerId[] path = info.getBrokerPath(); @@ -1652,6 +1652,16 @@ protected final Collection getRegionSubscriptions(ActiveMQDestinat protected DemandSubscription createDemandSubscription(ConsumerInfo info) throws IOException { // add our original id to ourselves info.addNetworkConsumerId(info.getConsumerId()); + // Generate a unique subscription name per remote consumer so that + // multiple consumers sharing the same subscription name (JMS shared + // subscriptions) do not collide as durable subscriptions on the + // local broker. ConduitBridge/DurableConduitBridge override this + // method entirely and use their own destination-based naming. + if (info.getSubscriptionName() != null) { + info.setSubscriptionName(DURABLE_SUB_PREFIX + configuration.getBrokerName() + + "_" + info.getDestination().getPhysicalName() + + "_" + info.getConsumerId()); + } return doCreateDemandSubscription(info); } From 3a58ab7389ee0c7daf3762754451c446a09443c4 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Thu, 16 Jul 2026 17:55:11 -0500 Subject: [PATCH 2/4] [#1715] Prevent KahaDB subscription ack from moving backward When multiple consumers share a durable subscription and ack messages out of order, a blind overwrite of LastAck can move the ack position backward. On restart, the recovery cursor replays already-acked messages. Check the update with a forward-only check so LastAck only advances. Test demonstrates recovery cost when lastAckedSequence regresses due to blind overwrite during out-of-order acks. Two durable subscriptions share a topic; the fast subscription acks 499 of 500 messages in reverse order. Without the forward-only guard in MessageDatabase, lastAck regresses to sequence 1 and recovery scans all 499 entries. --- .../store/kahadb/MessageDatabase.java | 5 +- .../store/kahadb/LastAckMonotonicityTest.java | 204 ++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java diff --git a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java index 08751b9c3bd..769644d875a 100644 --- a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java +++ b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java @@ -1572,7 +1572,10 @@ void updateIndex(Transaction tx, KahaRemoveMessageCommand command, Location ackL if (command.getAck() != UNMATCHED) { sd.orderIndex.get(tx, sequence); byte priority = sd.orderIndex.lastGetPriority(); - sd.subscriptionAcks.put(tx, subscriptionKey, new LastAck(sequence, priority)); + LastAck current = sd.subscriptionAcks.get(tx, subscriptionKey); + if (current == null || sequence > current.lastAckedSequence) { + sd.subscriptionAcks.put(tx, subscriptionKey, new LastAck(sequence, priority)); + } } MessageKeys keys = sd.orderIndex.get(tx, sequence); diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java new file mode 100644 index 00000000000..256650c9f5a --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java @@ -0,0 +1,204 @@ +/** + * 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.activemq.store.kahadb; + +import static org.junit.Assert.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.jms.Connection; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.Topic; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.store.MessageRecoveryListener; +import org.apache.activemq.store.TopicMessageStore; +import org.apache.activemq.util.IOHelper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Demonstrates recovery cost when KahaDB's lastAckedSequence regresses + * due to blind overwrite during out-of-order acks. + * + *

Two durable subscriptions share a topic. The "fast" subscription acks + * 499 of 500 messages in reverse sequence order (worst case for blind + * overwrite). The "slow" subscription never acks, keeping all messages + * in the orderIndex so GC cannot remove them. + * + *

After broker restart, recovery for the fast subscription should find + * its single pending message. With blind overwrite the lastAck cursor + * regresses to sequence 1, forcing recovery to scan all 499 entries. + * With monotonic max-tracking, lastAck stays at 499 and recovery jumps + * directly to the one pending message. + */ +public class LastAckMonotonicityTest { + + private static final String BROKER_NAME = "lastack-test"; + private static final String DATA_DIR = "target/test-data/lastack-monotonicity"; + private static final String TOPIC_NAME = "test.lastack.monotonic"; + private static final String FAST_CLIENT = "fast-client"; + private static final String SLOW_CLIENT = "slow-client"; + private static final String FAST_SUB = "fast-sub"; + private static final String SLOW_SUB = "slow-sub"; + private static final int MESSAGE_COUNT = 500; + + private BrokerService broker; + + @Before + public void setUp() throws Exception { + IOHelper.deleteFile(new File(DATA_DIR)); + } + + @After + public void tearDown() throws Exception { + stopBroker(); + IOHelper.deleteFile(new File(DATA_DIR)); + } + + @Test + public void testOutOfOrderAckRecoveryCost() throws Exception { + startBroker(); + + ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory("vm://" + BROKER_NAME); + Topic topic; + + // Create slow-sub FIRST — it never acks, keeping messages in the orderIndex. + Connection slowConn = factory.createConnection(); + slowConn.setClientID(SLOW_CLIENT); + slowConn.start(); + Session slowSession = slowConn.createSession(false, Session.CLIENT_ACKNOWLEDGE); + topic = slowSession.createTopic(TOPIC_NAME); + MessageConsumer slowConsumer = slowSession.createDurableSubscriber(topic, SLOW_SUB); + + // Create fast-sub with INDIVIDUAL_ACKNOWLEDGE for out-of-order acking. + Connection fastConn = factory.createConnection(); + fastConn.setClientID(FAST_CLIENT); + fastConn.start(); + Session fastSession = fastConn.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE); + topic = fastSession.createTopic(TOPIC_NAME); + MessageConsumer fastConsumer = fastSession.createDurableSubscriber(topic, FAST_SUB); + + // Publish messages — both subscriptions receive all of them. + MessageProducer producer = fastSession.createProducer(topic); + for (int i = 0; i < MESSAGE_COUNT; i++) { + producer.send(fastSession.createTextMessage("msg-" + i)); + } + + // slow-sub: receive but don't ack — messages stay in orderIndex. + for (int i = 0; i < MESSAGE_COUNT; i++) { + Message msg = slowConsumer.receive(5000); + assertNotNull("slow-sub should receive message " + i, msg); + } + + // fast-sub: receive all messages. + List fastReceived = new ArrayList<>(); + for (int i = 0; i < MESSAGE_COUNT; i++) { + Message msg = fastConsumer.receive(5000); + assertNotNull("fast-sub should receive message " + i, msg); + fastReceived.add(msg); + } + + // Ack messages 0..498 in REVERSE order (worst case for blind overwrite). + // Message 499 (the last) stays un-acked — the single pending message. + // With blind overwrite, the last ack (for sequence ~1) regresses lastAckedSequence. + List toAck = new ArrayList<>(fastReceived.subList(0, MESSAGE_COUNT - 1)); + Collections.reverse(toAck); + for (Message msg : toAck) { + msg.acknowledge(); + } + + slowConsumer.close(); + fastConsumer.close(); + slowConn.close(); + fastConn.close(); + + // Restart broker — KahaDB data persists. + stopBroker(); + startBroker(); + + // Recover fast-sub and count how many entries the store yields. + KahaDBPersistenceAdapter adapter = (KahaDBPersistenceAdapter) broker.getPersistenceAdapter(); + TopicMessageStore store = adapter.createTopicMessageStore(new ActiveMQTopic(TOPIC_NAME)); + + CountingRecoveryListener counter = new CountingRecoveryListener(); + store.recoverSubscription(FAST_CLIENT, FAST_SUB, counter); + + int recovered = counter.recovered.get(); + + assertEquals("Should recover exactly the 1 pending message", 1, recovered); + } + + private void startBroker() throws Exception { + broker = new BrokerService(); + broker.setPersistent(true); + broker.setUseJmx(false); + broker.setBrokerName(BROKER_NAME); + broker.setDataDirectory(DATA_DIR); + broker.setDeleteAllMessagesOnStartup(false); + broker.addConnector("vm://" + BROKER_NAME); + broker.start(); + broker.waitUntilStarted(); + } + + private void stopBroker() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + broker = null; + } + } + + static class CountingRecoveryListener implements MessageRecoveryListener { + final AtomicInteger recovered = new AtomicInteger(); + String lastRecoveredId; + + @Override + public boolean recoverMessage(org.apache.activemq.command.Message message) { + recovered.incrementAndGet(); + lastRecoveredId = message.getMessageId().toString(); + return true; + } + + @Override + public boolean recoverMessageReference(MessageId ref) { + return true; + } + + @Override + public boolean hasSpace() { + return true; + } + + @Override + public boolean isDuplicate(MessageId ref) { + return false; + } + } +} From bdab58ec004989a246f8f9da9f1e9de1e9f93695 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Thu, 16 Jul 2026 19:03:03 -0500 Subject: [PATCH 3/4] [#1715] Add OpenWire v13 wire format with shared subscription support Introduces protocol version 13, extending v12 with the fields needed to carry JMS 3.1 shared subscription state across the wire and through KahaDB recovery: ConsumerInfo + shared, durable (via SharedConsumerInfo) SubscriptionInfo + shared (via SharedSubscriptionInfo) ExceptionResponse + errorCode Message + deliveryTime The v13 MarshallerFactory clones the v12 marshaller map and replaces only the ten marshallers affected. OpenWireFormat already resolves the factory reflectively by version, so no version switch is required. Because OpenWire marshals a throwable as type and message only, the JMSException errorCode does not survive the round trip. The separate errorCode field carries it, and ExceptionResponse.getException() reapplies it to the reconstructed exception. Nothing here activates at the default store version: v13 is selected only when a broker sets storeOpenWireVersion to 13. Tests assert that gate directly -- deliveryTime, errorCode and the shared flags all fail to cross a v12 wire, and v12 continues to yield plain ConsumerInfo and SubscriptionInfo instances. ActiveMQErrorCode and SharedSubscriptionKey have no callers yet; they are scaffolding for the shared subscription broker work that follows. Adds 89 tests: round trips for all ten replaced marshallers in tight and loose encodings, the errorCode reconstruction branches, and null-clientId handling in SharedSubscriptionKey. --- .../activemq/util/SharedSubscriptionKey.java | 45 ++++ .../util/SharedSubscriptionKeyTest.java | 127 +++++++++++ .../apache/activemq/ActiveMQErrorCode.java | 71 ++++++ .../activemq/command/ExceptionResponse.java | 39 +++- .../org/apache/activemq/command/Message.java | 11 + .../activemq/command/SharedConsumerInfo.java | 80 +++++++ .../command/SharedSubscriptionInfo.java | 45 ++++ .../v13/ActiveMQBlobMessageMarshaller.java | 94 ++++++++ .../v13/ActiveMQBytesMessageMarshaller.java | 36 +++ .../v13/ActiveMQMapMessageMarshaller.java | 36 +++ .../v13/ActiveMQMessageMarshaller.java | 36 +++ .../v13/ActiveMQObjectMessageMarshaller.java | 36 +++ .../v13/ActiveMQStreamMessageMarshaller.java | 36 +++ .../v13/ActiveMQTextMessageMarshaller.java | 36 +++ .../openwire/v13/ConsumerInfoMarshaller.java | 99 ++++++++ .../v13/ExceptionResponseMarshaller.java | 82 +++++++ .../openwire/v13/MarshallerFactory.java | 59 +++++ .../openwire/v13/MessageMarshaller.java | 76 +++++++ .../v13/SubscriptionInfoMarshaller.java | 92 ++++++++ .../command/ExceptionResponseTest.java | 137 ++++++++++++ .../command/SharedConsumerInfoTest.java | 99 ++++++++ .../command/SharedSubscriptionInfoTest.java | 91 ++++++++ ...eptionResponseMarshallerRoundTripTest.java | 134 +++++++++++ .../openwire/v13/MarshallerRoundTripTest.java | 211 ++++++++++++++++++ .../v13/MessageMarshallerRoundTripTest.java | 208 +++++++++++++++++ 25 files changed, 2015 insertions(+), 1 deletion(-) create mode 100644 activemq-broker/src/main/java/org/apache/activemq/util/SharedSubscriptionKey.java create mode 100644 activemq-broker/src/test/java/org/apache/activemq/util/SharedSubscriptionKeyTest.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/ActiveMQErrorCode.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/command/SharedConsumerInfo.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/command/SharedSubscriptionInfo.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBlobMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBytesMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMapMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQObjectMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQStreamMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQTextMessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ConsumerInfoMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/MarshallerFactory.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/MessageMarshaller.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/openwire/v13/SubscriptionInfoMarshaller.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java diff --git a/activemq-broker/src/main/java/org/apache/activemq/util/SharedSubscriptionKey.java b/activemq-broker/src/main/java/org/apache/activemq/util/SharedSubscriptionKey.java new file mode 100644 index 00000000000..ac5efea2e34 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/util/SharedSubscriptionKey.java @@ -0,0 +1,45 @@ +/** + * 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.activemq.util; + +import org.apache.activemq.command.SubscriptionInfo; + +/** + * A {@link SubscriptionKey} subclass that handles null {@code clientId} safely. + * + *

The parent class NPEs on null {@code clientId} because + * {@code hashCode()} calls {@code clientId.hashCode()} unconditionally. + * This subclass coalesces null to an empty-string sentinel before calling + * {@code super()}, avoiding the NPE while maintaining compatibility with + * all existing {@code Map} usage. + */ +public class SharedSubscriptionKey extends SubscriptionKey { + + public static final String SHARED_CLIENT_ID = ""; + + public SharedSubscriptionKey(String subscriptionName) { + super(SHARED_CLIENT_ID, subscriptionName); + } + + public SharedSubscriptionKey(String clientId, String subscriptionName) { + super(clientId != null ? clientId : SHARED_CLIENT_ID, subscriptionName); + } + + public SharedSubscriptionKey(SubscriptionInfo info) { + this(info.getClientId(), info.getSubscriptionName()); + } +} diff --git a/activemq-broker/src/test/java/org/apache/activemq/util/SharedSubscriptionKeyTest.java b/activemq-broker/src/test/java/org/apache/activemq/util/SharedSubscriptionKeyTest.java new file mode 100644 index 00000000000..519889a3b4d --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/util/SharedSubscriptionKeyTest.java @@ -0,0 +1,127 @@ +/** + * 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.activemq.util; + +import static org.junit.Assert.*; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.activemq.command.SubscriptionInfo; +import org.junit.Test; + +public class SharedSubscriptionKeyTest { + + @Test + public void testNullClientIdDoesNotThrow() { + SharedSubscriptionKey key = new SharedSubscriptionKey(null, "mySub"); + assertEquals("", key.getClientId()); + assertEquals("mySub", key.getSubscriptionName()); + } + + @Test + public void testSubscriptionNameOnlyConstructor() { + SharedSubscriptionKey key = new SharedSubscriptionKey("mySub"); + assertEquals("", key.getClientId()); + assertEquals("mySub", key.getSubscriptionName()); + } + + @Test + public void testWithClientId() { + SharedSubscriptionKey key = new SharedSubscriptionKey("client1", "mySub"); + assertEquals("client1", key.getClientId()); + assertEquals("mySub", key.getSubscriptionName()); + } + + @Test + public void testEqualsWithSameValues() { + SharedSubscriptionKey key1 = new SharedSubscriptionKey("mySub"); + SharedSubscriptionKey key2 = new SharedSubscriptionKey("mySub"); + assertEquals(key1, key2); + assertEquals(key1.hashCode(), key2.hashCode()); + } + + @Test + public void testEqualsWithDifferentSubscriptions() { + SharedSubscriptionKey key1 = new SharedSubscriptionKey("sub1"); + SharedSubscriptionKey key2 = new SharedSubscriptionKey("sub2"); + assertNotEquals(key1, key2); + } + + @Test + public void testEqualsWithParentSubscriptionKey() { + SharedSubscriptionKey shared = new SharedSubscriptionKey("client1", "mySub"); + SubscriptionKey parent = new SubscriptionKey("client1", "mySub"); + assertEquals(shared, parent); + assertEquals(parent, shared); + assertEquals(shared.hashCode(), parent.hashCode()); + } + + @Test + public void testWorksAsMapKey() { + Map map = new HashMap<>(); + SharedSubscriptionKey key = new SharedSubscriptionKey("mySub"); + map.put(key, "value"); + + assertEquals("value", map.get(key)); + assertEquals("value", map.get(new SharedSubscriptionKey("mySub"))); + } + + @Test + public void testPolymorphicMapLookup() { + Map map = new HashMap<>(); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey("client1", "mySub"); + map.put(sharedKey, "shared"); + + SubscriptionKey parentKey = new SubscriptionKey("client1", "mySub"); + assertEquals("shared", map.get(parentKey)); + } + + @Test + public void testToString() { + SharedSubscriptionKey key = new SharedSubscriptionKey("mySub"); + assertEquals(":mySub", key.toString()); + } + + @Test + public void testToStringWithClientId() { + SharedSubscriptionKey key = new SharedSubscriptionKey("client1", "mySub"); + assertEquals("client1:mySub", key.toString()); + } + + @Test + public void testFromSubscriptionInfo() { + SubscriptionInfo info = new SubscriptionInfo("client1", "mySub"); + SharedSubscriptionKey key = new SharedSubscriptionKey(info); + assertEquals("client1", key.getClientId()); + assertEquals("mySub", key.getSubscriptionName()); + } + + @Test + public void testFromSubscriptionInfoNullClientId() { + SubscriptionInfo info = new SubscriptionInfo(null, "mySub"); + SharedSubscriptionKey key = new SharedSubscriptionKey(info); + assertEquals("", key.getClientId()); + assertEquals("mySub", key.getSubscriptionName()); + } + + @Test + public void testIsInstanceOfSubscriptionKey() { + SharedSubscriptionKey key = new SharedSubscriptionKey("mySub"); + assertTrue(key instanceof SubscriptionKey); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/ActiveMQErrorCode.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQErrorCode.java new file mode 100644 index 00000000000..07d65148d0d --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQErrorCode.java @@ -0,0 +1,71 @@ +/** + * 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.activemq; + +/** + * Error code constants carried on {@code ExceptionResponse.errorCode} + * and surfaced as {@link jakarta.jms.JMSException#getErrorCode()}. + * + *

Codes follow the pattern {@code AMQ-NNNNN} where the numeric range + * groups by protocol layer, bottom-up: + *

    + *
  • {@code 10xxx} — transport (TCP/SSL connection, socket errors)
  • + *
  • {@code 20xxx} — wire format (OpenWire framing, marshalling, version negotiation)
  • + *
  • {@code 30xxx} — session (session state, transaction, acknowledge mode)
  • + *
  • {@code 40xxx} — validation (destination, selector, subscription name)
  • + *
  • {@code 50xxx} — consumer/producer/subscription (shared subscription lifecycle, type conflicts, dispatch)
  • + *
+ */ +public final class ActiveMQErrorCode { + + private ActiveMQErrorCode() {} + + // --- Transport (10xxx) --- + + // reserved + + // --- Wire format (20xxx) --- + + /** Message frame exceeds the negotiated maximum frame size. */ + public static final String MAX_FRAME_SIZE_EXCEEDED = "AMQ-20001"; + + // --- Session (30xxx) --- + + // reserved + + // --- Validation (40xxx) --- + + /** Topic destination is null. */ + public static final String INVALID_DESTINATION = "AMQ-40001"; + + /** Subscription name is null or empty. */ + public static final String INVALID_SUBSCRIPTION_NAME = "AMQ-40003"; + + // --- Consumer / producer / subscription (50xxx) --- + + /** Consumer attempted to join a shared subscription with a different selector. */ + public static final String SELECTOR_MISMATCH = "AMQ-50001"; + + /** A shared and unshared durable subscription have the same name and client identifier. */ + public static final String SUBSCRIPTION_TYPE_CONFLICT = "AMQ-50002"; + + /** Unsubscribe attempted while the shared durable subscription has active consumers. */ + public static final String SUBSCRIPTION_IN_USE = "AMQ-50003"; + + /** A shared durable subscription with the same name is already active. */ + public static final String SUBSCRIPTION_ALREADY_EXISTS = "AMQ-50004"; +} diff --git a/activemq-client/src/main/java/org/apache/activemq/command/ExceptionResponse.java b/activemq-client/src/main/java/org/apache/activemq/command/ExceptionResponse.java index 8bcaaf307f9..818e3a88b7d 100644 --- a/activemq-client/src/main/java/org/apache/activemq/command/ExceptionResponse.java +++ b/activemq-client/src/main/java/org/apache/activemq/command/ExceptionResponse.java @@ -16,15 +16,19 @@ */ package org.apache.activemq.command; +import java.lang.reflect.Constructor; + +import jakarta.jms.JMSException; + /** * @openwire:marshaller code="31" - * */ public class ExceptionResponse extends Response { public static final byte DATA_STRUCTURE_TYPE = CommandTypes.EXCEPTION_RESPONSE; Throwable exception; + String errorCode; public ExceptionResponse() { } @@ -41,6 +45,7 @@ public byte getDataStructureType() { * @openwire:property version=1 */ public Throwable getException() { + applyErrorCode(); return exception; } @@ -48,7 +53,39 @@ public void setException(Throwable exception) { this.exception = exception; } + /** + * @openwire:property version=13 + */ + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + public boolean isException() { return true; } + + private void applyErrorCode() { + if (errorCode == null || !(exception instanceof JMSException)) { + return; + } + JMSException original = (JMSException) exception; + if (errorCode.equals(original.getErrorCode())) { + return; + } + try { + Constructor ctor = exception.getClass() + .getConstructor(String.class, String.class); + JMSException replacement = (JMSException) ctor.newInstance( + original.getMessage(), errorCode); + replacement.initCause(original.getCause()); + replacement.setStackTrace(original.getStackTrace()); + replacement.setLinkedException(original.getLinkedException()); + exception = replacement; + } catch (Exception ignored) { + } + } } diff --git a/activemq-client/src/main/java/org/apache/activemq/command/Message.java b/activemq-client/src/main/java/org/apache/activemq/command/Message.java index 06e0aebeea6..cc80dc28283 100644 --- a/activemq-client/src/main/java/org/apache/activemq/command/Message.java +++ b/activemq-client/src/main/java/org/apache/activemq/command/Message.java @@ -824,6 +824,17 @@ public void setJMSXGroupFirstForConsumer(boolean val) { jmsXGroupFirstForConsumer = val; } + /** + * @openwire:property version=13 + */ + public long getDeliveryTime() { + return deliveryTime; + } + + public void setDeliveryTime(long deliveryTime) { + this.deliveryTime = deliveryTime; + } + public void compress() throws IOException { if (!isCompressed()) { storeContent(); diff --git a/activemq-client/src/main/java/org/apache/activemq/command/SharedConsumerInfo.java b/activemq-client/src/main/java/org/apache/activemq/command/SharedConsumerInfo.java new file mode 100644 index 00000000000..3acfbee8eb7 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/command/SharedConsumerInfo.java @@ -0,0 +1,80 @@ +/** + * 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.activemq.command; + +/** + * Extends {@link ConsumerInfo} with JMS 3.1 shared subscription fields. + * + *

The v13 OpenWire marshallers create instances of this class instead of + * {@code ConsumerInfo}, so the {@code shared} and {@code durable} fields + * survive wire serialization and KahaDB persistence round-trips. + * + *

Broker-side code detects shared consumers via + * {@code info instanceof SharedConsumerInfo}. + */ +public class SharedConsumerInfo extends ConsumerInfo { + + protected boolean shared; + protected boolean durable; + protected boolean userSpecifiedClientId; + + public SharedConsumerInfo() { + } + + public SharedConsumerInfo(ConsumerId consumerId) { + super(consumerId); + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } + + @Override + public boolean isDurable() { + return durable; + } + + public void setDurable(boolean durable) { + this.durable = durable; + } + + public boolean isUserSpecifiedClientId() { + return userSpecifiedClientId; + } + + public void setUserSpecifiedClientId(boolean userSpecifiedClientId) { + this.userSpecifiedClientId = userSpecifiedClientId; + } + + @Override + public SharedConsumerInfo copy() { + SharedConsumerInfo info = new SharedConsumerInfo(); + copy(info); + return info; + } + + public void copy(SharedConsumerInfo info) { + super.copy(info); + info.shared = shared; + info.durable = durable; + info.userSpecifiedClientId = userSpecifiedClientId; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/command/SharedSubscriptionInfo.java b/activemq-client/src/main/java/org/apache/activemq/command/SharedSubscriptionInfo.java new file mode 100644 index 00000000000..acf4c855c4e --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/command/SharedSubscriptionInfo.java @@ -0,0 +1,45 @@ +/** + * 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.activemq.command; + +/** + * Extends {@link SubscriptionInfo} with a {@code shared} flag for JMS 3.1 + * shared subscription persistence. + * + *

The v13 OpenWire marshallers create instances of this class, so the + * {@code shared} flag survives KahaDB journal round-trips. Recovery code + * detects shared subscriptions via {@code info instanceof SharedSubscriptionInfo}. + */ +public class SharedSubscriptionInfo extends SubscriptionInfo { + + protected boolean shared; + + public SharedSubscriptionInfo() { + } + + public SharedSubscriptionInfo(String clientId, String subscriptionName) { + super(clientId, subscriptionName); + } + + public boolean isShared() { + return shared; + } + + public void setShared(boolean shared) { + this.shared = shared; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBlobMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBlobMessageMarshaller.java new file mode 100644 index 00000000000..8924497e407 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBlobMessageMarshaller.java @@ -0,0 +1,94 @@ +/** + * 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.activemq.openwire.v13; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import org.apache.activemq.command.ActiveMQBlobMessage; +import org.apache.activemq.command.DataStructure; +import org.apache.activemq.openwire.BooleanStream; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 marshaller for {@link ActiveMQBlobMessage}. + */ +public class ActiveMQBlobMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQBlobMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQBlobMessage(); + } + + @Override + public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { + super.tightUnmarshal(wireFormat, o, dataIn, bs); + + ActiveMQBlobMessage info = (ActiveMQBlobMessage) o; + info.setRemoteBlobUrl(tightUnmarshalString(dataIn, bs)); + info.setMimeType(tightUnmarshalString(dataIn, bs)); + info.setDeletedByBroker(bs.readBoolean()); + } + + @Override + public int tightMarshal1(OpenWireFormat wireFormat, Object o, BooleanStream bs) throws IOException { + ActiveMQBlobMessage info = (ActiveMQBlobMessage) o; + + int rc = super.tightMarshal1(wireFormat, o, bs); + rc += tightMarshalString1(info.getRemoteBlobUrl(), bs); + rc += tightMarshalString1(info.getMimeType(), bs); + bs.writeBoolean(info.isDeletedByBroker()); + + return rc + 0; + } + + @Override + public void tightMarshal2(OpenWireFormat wireFormat, Object o, DataOutput dataOut, BooleanStream bs) throws IOException { + super.tightMarshal2(wireFormat, o, dataOut, bs); + + ActiveMQBlobMessage info = (ActiveMQBlobMessage) o; + tightMarshalString2(info.getRemoteBlobUrl(), dataOut, bs); + tightMarshalString2(info.getMimeType(), dataOut, bs); + bs.readBoolean(); + } + + @Override + public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException { + super.looseUnmarshal(wireFormat, o, dataIn); + + ActiveMQBlobMessage info = (ActiveMQBlobMessage) o; + info.setRemoteBlobUrl(looseUnmarshalString(dataIn)); + info.setMimeType(looseUnmarshalString(dataIn)); + info.setDeletedByBroker(dataIn.readBoolean()); + } + + @Override + public void looseMarshal(OpenWireFormat wireFormat, Object o, DataOutput dataOut) throws IOException { + super.looseMarshal(wireFormat, o, dataOut); + + ActiveMQBlobMessage info = (ActiveMQBlobMessage) o; + looseMarshalString(info.getRemoteBlobUrl(), dataOut); + looseMarshalString(info.getMimeType(), dataOut); + dataOut.writeBoolean(info.isDeletedByBroker()); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBytesMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBytesMessageMarshaller.java new file mode 100644 index 00000000000..e45b1fca01a --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQBytesMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQBytesMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQBytesMessage}. + */ +public class ActiveMQBytesMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQBytesMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQBytesMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMapMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMapMessageMarshaller.java new file mode 100644 index 00000000000..e4ec6989e2b --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMapMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQMapMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQMapMessage}. + */ +public class ActiveMQMapMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQMapMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQMapMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMessageMarshaller.java new file mode 100644 index 00000000000..03e74a9d5e5 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQMessage}. + */ +public class ActiveMQMessageMarshaller extends MessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQObjectMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQObjectMessageMarshaller.java new file mode 100644 index 00000000000..fd84d51a07e --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQObjectMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQObjectMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQObjectMessage}. + */ +public class ActiveMQObjectMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQObjectMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQObjectMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQStreamMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQStreamMessageMarshaller.java new file mode 100644 index 00000000000..1f9829fac1c --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQStreamMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQStreamMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQStreamMessage}. + */ +public class ActiveMQStreamMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQStreamMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQStreamMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQTextMessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQTextMessageMarshaller.java new file mode 100644 index 00000000000..a8d2dc4d0a1 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ActiveMQTextMessageMarshaller.java @@ -0,0 +1,36 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.command.ActiveMQTextMessage; +import org.apache.activemq.command.DataStructure; + +/** + * OpenWire v13 marshaller for {@link ActiveMQTextMessage}. + */ +public class ActiveMQTextMessageMarshaller extends ActiveMQMessageMarshaller { + + @Override + public byte getDataStructureType() { + return ActiveMQTextMessage.DATA_STRUCTURE_TYPE; + } + + @Override + public DataStructure createObject() { + return new ActiveMQTextMessage(); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ConsumerInfoMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ConsumerInfoMarshaller.java new file mode 100644 index 00000000000..841b6b45efb --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ConsumerInfoMarshaller.java @@ -0,0 +1,99 @@ +/** + * 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.activemq.openwire.v13; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.DataStructure; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.openwire.BooleanStream; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 marshaller for {@link ConsumerInfo}. + * + *

Extends the v12 marshaller with two additional boolean fields: + * {@code shared} and {@code durable}. Creates {@link SharedConsumerInfo} + * instances on unmarshal so the broker can detect shared consumers via + * {@code instanceof}. + */ +public class ConsumerInfoMarshaller extends org.apache.activemq.openwire.v12.ConsumerInfoMarshaller { + + @Override + public DataStructure createObject() { + return new SharedConsumerInfo(); + } + + @Override + public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { + super.tightUnmarshal(wireFormat, o, dataIn, bs); + + SharedConsumerInfo info = (SharedConsumerInfo) o; + info.setShared(bs.readBoolean()); + info.setDurable(bs.readBoolean()); + } + + @Override + public int tightMarshal1(OpenWireFormat wireFormat, Object o, BooleanStream bs) throws IOException { + int rc = super.tightMarshal1(wireFormat, o, bs); + + if (o instanceof SharedConsumerInfo) { + SharedConsumerInfo info = (SharedConsumerInfo) o; + bs.writeBoolean(info.isShared()); + bs.writeBoolean(info.isDurable()); + } else { + bs.writeBoolean(false); + bs.writeBoolean(false); + } + + return rc + 0; + } + + @Override + public void tightMarshal2(OpenWireFormat wireFormat, Object o, DataOutput dataOut, BooleanStream bs) throws IOException { + super.tightMarshal2(wireFormat, o, dataOut, bs); + + bs.readBoolean(); + bs.readBoolean(); + } + + @Override + public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException { + super.looseUnmarshal(wireFormat, o, dataIn); + + SharedConsumerInfo info = (SharedConsumerInfo) o; + info.setShared(dataIn.readBoolean()); + info.setDurable(dataIn.readBoolean()); + } + + @Override + public void looseMarshal(OpenWireFormat wireFormat, Object o, DataOutput dataOut) throws IOException { + super.looseMarshal(wireFormat, o, dataOut); + + if (o instanceof SharedConsumerInfo) { + SharedConsumerInfo info = (SharedConsumerInfo) o; + dataOut.writeBoolean(info.isShared()); + dataOut.writeBoolean(info.isDurable()); + } else { + dataOut.writeBoolean(false); + dataOut.writeBoolean(false); + } + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshaller.java new file mode 100644 index 00000000000..d6b2bcace42 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshaller.java @@ -0,0 +1,82 @@ +/** + * 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.activemq.openwire.v13; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import org.apache.activemq.command.DataStructure; +import org.apache.activemq.command.ExceptionResponse; +import org.apache.activemq.openwire.BooleanStream; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 marshaller for {@link ExceptionResponse}. + * + *

Extends the v12 marshaller with one additional string field: + * {@code errorCode}. + */ +public class ExceptionResponseMarshaller extends org.apache.activemq.openwire.v12.ExceptionResponseMarshaller { + + @Override + public DataStructure createObject() { + return new ExceptionResponse(); + } + + @Override + public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { + super.tightUnmarshal(wireFormat, o, dataIn, bs); + + ExceptionResponse info = (ExceptionResponse) o; + info.setErrorCode(tightUnmarshalString(dataIn, bs)); + } + + @Override + public int tightMarshal1(OpenWireFormat wireFormat, Object o, BooleanStream bs) throws IOException { + ExceptionResponse info = (ExceptionResponse) o; + + int rc = super.tightMarshal1(wireFormat, o, bs); + rc += tightMarshalString1(info.getErrorCode(), bs); + + return rc + 0; + } + + @Override + public void tightMarshal2(OpenWireFormat wireFormat, Object o, DataOutput dataOut, BooleanStream bs) throws IOException { + super.tightMarshal2(wireFormat, o, dataOut, bs); + + ExceptionResponse info = (ExceptionResponse) o; + tightMarshalString2(info.getErrorCode(), dataOut, bs); + } + + @Override + public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException { + super.looseUnmarshal(wireFormat, o, dataIn); + + ExceptionResponse info = (ExceptionResponse) o; + info.setErrorCode(looseUnmarshalString(dataIn)); + } + + @Override + public void looseMarshal(OpenWireFormat wireFormat, Object o, DataOutput dataOut) throws IOException { + super.looseMarshal(wireFormat, o, dataOut); + + ExceptionResponse info = (ExceptionResponse) o; + looseMarshalString(info.getErrorCode(), dataOut); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MarshallerFactory.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MarshallerFactory.java new file mode 100644 index 00000000000..ad8e2617dad --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MarshallerFactory.java @@ -0,0 +1,59 @@ +/** + * 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.activemq.openwire.v13; + +import org.apache.activemq.openwire.DataStreamMarshaller; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 MarshallerFactory. + * + *

Delegates to the v12 marshaller set and replaces only the marshallers + * that changed in v13: {@code ConsumerInfoMarshaller} (adds {@code shared} + * and {@code durable} fields), {@code SubscriptionInfoMarshaller} + * (adds {@code shared} field), {@code ExceptionResponseMarshaller} + * (adds {@code errorCode} field), and the message marshallers + * (add {@code deliveryTime} field). + */ +public class MarshallerFactory { + + static final private DataStreamMarshaller[] marshaller; + + static { + DataStreamMarshaller[] v12 = + org.apache.activemq.openwire.v12.MarshallerFactory.createMarshallerMap(null); + marshaller = v12.clone(); + add(new ConsumerInfoMarshaller()); + add(new SubscriptionInfoMarshaller()); + add(new ExceptionResponseMarshaller()); + add(new ActiveMQMessageMarshaller()); + add(new ActiveMQTextMessageMarshaller()); + add(new ActiveMQBytesMessageMarshaller()); + add(new ActiveMQMapMessageMarshaller()); + add(new ActiveMQStreamMessageMarshaller()); + add(new ActiveMQObjectMessageMarshaller()); + add(new ActiveMQBlobMessageMarshaller()); + } + + static private void add(DataStreamMarshaller dsm) { + marshaller[dsm.getDataStructureType() & 0xFF] = dsm; + } + + static public DataStreamMarshaller[] createMarshallerMap(OpenWireFormat wireFormat) { + return marshaller; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MessageMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MessageMarshaller.java new file mode 100644 index 00000000000..1d3c411b3cc --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/MessageMarshaller.java @@ -0,0 +1,76 @@ +/** + * 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.activemq.openwire.v13; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import org.apache.activemq.command.Message; +import org.apache.activemq.openwire.BooleanStream; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 marshaller for {@link Message}. + * + *

Extends the v12 marshaller with one additional long field: + * {@code deliveryTime}. + */ +public abstract class MessageMarshaller extends org.apache.activemq.openwire.v12.MessageMarshaller { + + @Override + public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { + super.tightUnmarshal(wireFormat, o, dataIn, bs); + + Message info = (Message) o; + info.setDeliveryTime(tightUnmarshalLong(wireFormat, dataIn, bs)); + } + + @Override + public int tightMarshal1(OpenWireFormat wireFormat, Object o, BooleanStream bs) throws IOException { + Message info = (Message) o; + + int rc = super.tightMarshal1(wireFormat, o, bs); + rc += tightMarshalLong1(wireFormat, info.getDeliveryTime(), bs); + + return rc + 0; + } + + @Override + public void tightMarshal2(OpenWireFormat wireFormat, Object o, DataOutput dataOut, BooleanStream bs) throws IOException { + super.tightMarshal2(wireFormat, o, dataOut, bs); + + Message info = (Message) o; + tightMarshalLong2(wireFormat, info.getDeliveryTime(), dataOut, bs); + } + + @Override + public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException { + super.looseUnmarshal(wireFormat, o, dataIn); + + Message info = (Message) o; + info.setDeliveryTime(looseUnmarshalLong(wireFormat, dataIn)); + } + + @Override + public void looseMarshal(OpenWireFormat wireFormat, Object o, DataOutput dataOut) throws IOException { + super.looseMarshal(wireFormat, o, dataOut); + + Message info = (Message) o; + looseMarshalLong(wireFormat, info.getDeliveryTime(), dataOut); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/openwire/v13/SubscriptionInfoMarshaller.java b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/SubscriptionInfoMarshaller.java new file mode 100644 index 00000000000..d28dd6edbb3 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/openwire/v13/SubscriptionInfoMarshaller.java @@ -0,0 +1,92 @@ +/** + * 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.activemq.openwire.v13; + +import java.io.DataInput; +import java.io.DataOutput; +import java.io.IOException; + +import org.apache.activemq.command.DataStructure; +import org.apache.activemq.command.SharedSubscriptionInfo; +import org.apache.activemq.command.SubscriptionInfo; +import org.apache.activemq.openwire.BooleanStream; +import org.apache.activemq.openwire.OpenWireFormat; + +/** + * OpenWire v13 marshaller for {@link SubscriptionInfo}. + * + *

Extends the v12 marshaller with one additional boolean field: + * {@code shared}. Creates {@link SharedSubscriptionInfo} instances on + * unmarshal so recovery code can detect shared subscriptions via + * {@code instanceof}. + */ +public class SubscriptionInfoMarshaller extends org.apache.activemq.openwire.v12.SubscriptionInfoMarshaller { + + @Override + public DataStructure createObject() { + return new SharedSubscriptionInfo(); + } + + @Override + public void tightUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn, BooleanStream bs) throws IOException { + super.tightUnmarshal(wireFormat, o, dataIn, bs); + + SharedSubscriptionInfo info = (SharedSubscriptionInfo) o; + info.setShared(bs.readBoolean()); + } + + @Override + public int tightMarshal1(OpenWireFormat wireFormat, Object o, BooleanStream bs) throws IOException { + int rc = super.tightMarshal1(wireFormat, o, bs); + + if (o instanceof SharedSubscriptionInfo) { + SharedSubscriptionInfo info = (SharedSubscriptionInfo) o; + bs.writeBoolean(info.isShared()); + } else { + bs.writeBoolean(false); + } + + return rc + 0; + } + + @Override + public void tightMarshal2(OpenWireFormat wireFormat, Object o, DataOutput dataOut, BooleanStream bs) throws IOException { + super.tightMarshal2(wireFormat, o, dataOut, bs); + + bs.readBoolean(); + } + + @Override + public void looseUnmarshal(OpenWireFormat wireFormat, Object o, DataInput dataIn) throws IOException { + super.looseUnmarshal(wireFormat, o, dataIn); + + SharedSubscriptionInfo info = (SharedSubscriptionInfo) o; + info.setShared(dataIn.readBoolean()); + } + + @Override + public void looseMarshal(OpenWireFormat wireFormat, Object o, DataOutput dataOut) throws IOException { + super.looseMarshal(wireFormat, o, dataOut); + + if (o instanceof SharedSubscriptionInfo) { + SharedSubscriptionInfo info = (SharedSubscriptionInfo) o; + dataOut.writeBoolean(info.isShared()); + } else { + dataOut.writeBoolean(false); + } + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java b/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java new file mode 100644 index 00000000000..e703ee4fe24 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java @@ -0,0 +1,137 @@ +/** + * 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.activemq.command; + +import static org.junit.Assert.*; + +import jakarta.jms.InvalidDestinationException; +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.junit.Test; + +public class ExceptionResponseTest { + + @Test + public void testNullErrorCodeLeavesExceptionUntouched() { + JMSException original = new JMSException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + + assertSame(original, response.getException()); + assertNull(response.getErrorCode()); + } + + @Test + public void testErrorCodeAppliedToJMSException() { + ExceptionResponse response = new ExceptionResponse(new JMSException("boom")); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + Throwable result = response.getException(); + assertTrue(result instanceof JMSException); + assertEquals(ActiveMQErrorCode.SELECTOR_MISMATCH, ((JMSException) result).getErrorCode()); + assertEquals("boom", result.getMessage()); + } + + @Test + public void testErrorCodeAppliedToJMSExceptionSubclass() { + ExceptionResponse response = + new ExceptionResponse(new InvalidDestinationException("bad dest")); + response.setErrorCode(ActiveMQErrorCode.INVALID_DESTINATION); + + Throwable result = response.getException(); + assertTrue("Subclass type must be preserved", result instanceof InvalidDestinationException); + assertEquals(ActiveMQErrorCode.INVALID_DESTINATION, + ((JMSException) result).getErrorCode()); + } + + @Test + public void testMatchingErrorCodeDoesNotReconstruct() { + JMSException original = new JMSException("boom", ActiveMQErrorCode.SELECTOR_MISMATCH); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame("Already-correct errorCode must skip reflection", original, + response.getException()); + } + + @Test + public void testNonJMSExceptionIgnoresErrorCode() { + RuntimeException original = new RuntimeException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame(original, response.getException()); + } + + @Test + public void testExceptionWithoutTwoArgConstructorFallsBackToOriginal() { + JMSException original = new NoTwoArgCtorException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame("Missing (String,String) ctor must leave the exception intact", + original, response.getException()); + } + + @Test + public void testReconstructionPreservesCauseAndLinkedException() { + Throwable cause = new IllegalStateException("root cause"); + Exception linked = new Exception("linked"); + JMSException original = new JMSException("boom"); + original.initCause(cause); + original.setLinkedException(linked); + + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_IN_USE); + + JMSException result = (JMSException) response.getException(); + assertEquals(ActiveMQErrorCode.SUBSCRIPTION_IN_USE, result.getErrorCode()); + assertSame(cause, result.getCause()); + assertSame(linked, result.getLinkedException()); + assertArrayEquals(original.getStackTrace(), result.getStackTrace()); + } + + @Test + public void testGetExceptionIsIdempotent() { + ExceptionResponse response = new ExceptionResponse(new JMSException("boom")); + response.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_TYPE_CONFLICT); + + Throwable first = response.getException(); + Throwable second = response.getException(); + assertSame("Second call must not reconstruct again", first, second); + } + + @Test + public void testIsException() { + assertTrue(new ExceptionResponse(new JMSException("boom")).isException()); + } + + @Test + public void testDataStructureType() { + assertEquals(CommandTypes.EXCEPTION_RESPONSE, + new ExceptionResponse().getDataStructureType()); + } + + /** A JMSException subclass lacking the (String message, String errorCode) constructor. */ + private static class NoTwoArgCtorException extends JMSException { + private static final long serialVersionUID = 1L; + + NoTwoArgCtorException(String reason) { + super(reason); + } + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java b/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java new file mode 100644 index 00000000000..f9e27e55176 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java @@ -0,0 +1,99 @@ +/** + * 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.activemq.command; + +import static org.junit.Assert.*; + +import org.junit.Test; + +public class SharedConsumerInfoTest { + + @Test + public void testDefaultsToNonSharedNonDurable() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertFalse(info.isShared()); + assertFalse(info.isDurable()); + } + + @Test + public void testSharedFlag() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setShared(true); + assertTrue(info.isShared()); + } + + @Test + public void testDurableFlag() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setDurable(true); + assertTrue(info.isDurable()); + } + + @Test + public void testDurableOverridesParentInference() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setSubscriptionName("mySub"); + assertFalse(info.isDurable()); + + info.setDurable(true); + assertTrue(info.isDurable()); + } + + @Test + public void testIsInstanceOfConsumerInfo() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertTrue(info instanceof ConsumerInfo); + } + + @Test + public void testDataStructureType() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertEquals(ConsumerInfo.DATA_STRUCTURE_TYPE, info.getDataStructureType()); + } + + @Test + public void testCopyPreservesSharedFields() { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setShared(true); + original.setDurable(true); + original.setSubscriptionName("mySub"); + + SharedConsumerInfo copy = original.copy(); + assertTrue(copy.isShared()); + assertTrue(copy.isDurable()); + assertEquals("mySub", copy.getSubscriptionName()); + } + + @Test + public void testCopyToParentConsumerInfo() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setShared(true); + info.setSubscriptionName("mySub"); + + ConsumerInfo parentCopy = new ConsumerInfo(); + info.copy(parentCopy); + assertEquals("mySub", parentCopy.getSubscriptionName()); + } + + @Test + public void testConstructorWithConsumerId() { + ConsumerId cid = new ConsumerId(new SessionId(new ConnectionId("conn"), 1), 1); + SharedConsumerInfo info = new SharedConsumerInfo(cid); + assertEquals(cid, info.getConsumerId()); + assertFalse(info.isShared()); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java b/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java new file mode 100644 index 00000000000..136a1af207f --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java @@ -0,0 +1,91 @@ +/** + * 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.activemq.command; + +import static org.junit.Assert.*; + +import org.junit.Test; + +public class SharedSubscriptionInfoTest { + + @Test + public void testDefaultsToNonShared() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertFalse(info.isShared()); + } + + @Test + public void testSharedFlag() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + info.setShared(true); + assertTrue(info.isShared()); + } + + @Test + public void testIsInstanceOfSubscriptionInfo() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertTrue(info instanceof SubscriptionInfo); + } + + @Test + public void testDataStructureType() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertEquals(SubscriptionInfo.DATA_STRUCTURE_TYPE, info.getDataStructureType()); + } + + @Test + public void testConstructorWithClientIdAndName() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo("client1", "mySub"); + assertEquals("client1", info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + assertFalse(info.isShared()); + } + + @Test + public void testNullClientId() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(null, "mySub"); + assertNull(info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + } + + @Test + public void testEqualsWithParent() { + SharedSubscriptionInfo shared = new SharedSubscriptionInfo("client1", "mySub"); + shared.setShared(true); + SubscriptionInfo plain = new SubscriptionInfo("client1", "mySub"); + + assertTrue(plain.equals(shared)); + assertTrue(shared.equals(plain)); + } + + @Test + public void testInheritsAllParentFields() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + info.setClientId("client1"); + info.setSubscriptionName("mySub"); + info.setSelector("color = 'blue'"); + info.setDestination(new ActiveMQTopic("test.topic")); + info.setNoLocal(true); + info.setShared(true); + + assertEquals("client1", info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + assertEquals("color = 'blue'", info.getSelector()); + assertTrue(info.isNoLocal()); + assertTrue(info.isShared()); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java new file mode 100644 index 00000000000..6558c1bb801 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java @@ -0,0 +1,134 @@ +/** + * 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.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.apache.activemq.command.ExceptionResponse; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Test; + +/** + * Round-trips the v13 {@code errorCode} field on {@link ExceptionResponse} + * in both tight and loose encodings. + */ +public class ExceptionResponseMarshallerRoundTripTest { + + @Test + public void testErrorCodeSurvivesTightRoundTrip() throws Exception { + assertErrorCodeRoundTrip(true); + } + + @Test + public void testErrorCodeSurvivesLooseRoundTrip() throws Exception { + assertErrorCodeRoundTrip(false); + } + + @Test + public void testNullErrorCodeSurvivesRoundTrip() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertNull(restored.getErrorCode()); + } + + /** + * OpenWire marshals a throwable as type plus message only, dropping + * {@code JMSException.errorCode}. The separate v13 errorCode field is what + * carries it across, and {@code getException()} reapplies it on the far side. + */ + @Test + public void testErrorCodeReappliedToExceptionAfterUnmarshal() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_ALREADY_EXISTS); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + Throwable exception = restored.getException(); + assertTrue(exception instanceof JMSException); + assertEquals(ActiveMQErrorCode.SUBSCRIPTION_ALREADY_EXISTS, + ((JMSException) exception).getErrorCode()); + assertEquals("boom", exception.getMessage()); + } + + /** + * The v13 errorCode must not reach the v12 wire, leaving a v12 peer with + * exactly the exception it would have received before this field existed. + */ + @Test + public void testErrorCodeIsNotWrittenAtV12() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + wireFormat.setVersion(12); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertNull("errorCode must not cross the v12 wire", restored.getErrorCode()); + assertTrue(restored.getException() instanceof JMSException); + assertEquals("boom", restored.getException().getMessage()); + } + + private void assertErrorCodeRoundTrip(boolean tightEncoding) throws Exception { + OpenWireFormat wireFormat = wireFormat(tightEncoding); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(ActiveMQErrorCode.SELECTOR_MISMATCH, restored.getErrorCode()); + assertTrue(restored.isException()); + } + + private static OpenWireFormat wireFormat(boolean tightEncoding) { + OpenWireFormat wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + wireFormat.setCacheEnabled(false); + wireFormat.setTightEncodingEnabled(tightEncoding); + return wireFormat; + } + + private static byte[] marshal(OpenWireFormat wireFormat, Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private static Object unmarshal(OpenWireFormat wireFormat, byte[] bytes) throws Exception { + return wireFormat.unmarshal(new DataInputStream(new ByteArrayInputStream(bytes))); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java new file mode 100644 index 00000000000..4cea1bbbd92 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java @@ -0,0 +1,211 @@ +/** + * 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.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.command.SharedSubscriptionInfo; +import org.apache.activemq.command.SubscriptionInfo; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Before; +import org.junit.Test; + +public class MarshallerRoundTripTest { + + private OpenWireFormat wireFormat; + + @Before + public void setUp() { + wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + } + + @Test + public void testVersionThirteenLoads() { + assertEquals(13, wireFormat.getVersion()); + } + + @Test + public void testSharedConsumerInfoRoundTrip() throws Exception { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 1)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("mySub"); + original.setShared(true); + original.setDurable(true); + original.setPrefetchSize(100); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue("Should unmarshal as SharedConsumerInfo", result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertTrue(restored.isShared()); + assertTrue(restored.isDurable()); + assertEquals("mySub", restored.getSubscriptionName()); + assertEquals(100, restored.getPrefetchSize()); + assertEquals(new ActiveMQTopic("test.topic"), restored.getDestination()); + } + + @Test + public void testSharedConsumerInfoNonSharedRoundTrip() throws Exception { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 2)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(false); + original.setDurable(false); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertFalse(restored.isShared()); + assertFalse(restored.isDurable()); + } + + @Test + public void testSharedSubscriptionInfoRoundTrip() throws Exception { + SharedSubscriptionInfo original = new SharedSubscriptionInfo(); + original.setClientId("client1"); + original.setSubscriptionName("mySub"); + original.setSelector("color = 'blue'"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setNoLocal(false); + original.setShared(true); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue("Should unmarshal as SharedSubscriptionInfo", + result instanceof SharedSubscriptionInfo); + SharedSubscriptionInfo restored = (SharedSubscriptionInfo) result; + assertTrue(restored.isShared()); + assertEquals("client1", restored.getClientId()); + assertEquals("mySub", restored.getSubscriptionName()); + assertEquals("color = 'blue'", restored.getSelector()); + } + + @Test + public void testSharedSubscriptionInfoNullClientId() throws Exception { + SharedSubscriptionInfo original = new SharedSubscriptionInfo(); + original.setSubscriptionName("sharedSub"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(true); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedSubscriptionInfo); + SharedSubscriptionInfo restored = (SharedSubscriptionInfo) result; + assertNull(restored.getClientId()); + assertTrue(restored.isShared()); + } + + @Test + public void testPlainConsumerInfoMarshalledAsNonShared() throws Exception { + ConsumerInfo original = new ConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 3)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("durableSub"); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertFalse(restored.isShared()); + assertFalse(restored.isDurable()); + assertEquals("durableSub", restored.getSubscriptionName()); + } + + @Test + public void testMarshallerFactoryReplacesOnlyTwoMarshallers() { + org.apache.activemq.openwire.DataStreamMarshaller[] map = + MarshallerFactory.createMarshallerMap(wireFormat); + + assertTrue(map[ConsumerInfo.DATA_STRUCTURE_TYPE & 0xFF] + instanceof ConsumerInfoMarshaller); + assertTrue(map[SubscriptionInfo.DATA_STRUCTURE_TYPE & 0xFF] + instanceof SubscriptionInfoMarshaller); + } + + /** + * A v12 peer must see an ordinary {@link ConsumerInfo} with none of the + * shared fields, so a broker left at the default store version behaves + * exactly as it did before v13 existed. + */ + @Test + public void testSharedFlagsAreNotWrittenAtV12() throws Exception { + wireFormat.setVersion(12); + + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 4)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("mySub"); + original.setShared(true); + original.setDurable(true); + + Object result = unmarshal(marshal(original)); + + assertFalse("v12 must not produce a SharedConsumerInfo", + result instanceof SharedConsumerInfo); + assertTrue(result instanceof ConsumerInfo); + assertEquals("mySub", ((ConsumerInfo) result).getSubscriptionName()); + } + + @Test + public void testSharedSubscriptionFlagIsNotWrittenAtV12() throws Exception { + wireFormat.setVersion(12); + + SharedSubscriptionInfo original = new SharedSubscriptionInfo("client1", "mySub"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(true); + + Object result = unmarshal(marshal(original)); + + assertFalse("v12 must not produce a SharedSubscriptionInfo", + result instanceof SharedSubscriptionInfo); + assertTrue(result instanceof SubscriptionInfo); + assertEquals("mySub", ((SubscriptionInfo) result).getSubscriptionName()); + } + + private byte[] marshal(Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private Object unmarshal(byte[] bytes) throws Exception { + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + DataInputStream in = new DataInputStream(bais); + return wireFormat.unmarshal(in); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java new file mode 100644 index 00000000000..d500d069fdb --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java @@ -0,0 +1,208 @@ +/** + * 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.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.Arrays; +import java.util.Collection; + +import jakarta.jms.JMSException; + +import org.apache.activemq.command.ActiveMQBlobMessage; +import org.apache.activemq.command.ActiveMQBytesMessage; +import org.apache.activemq.command.ActiveMQMapMessage; +import org.apache.activemq.command.ActiveMQMessage; +import org.apache.activemq.command.ActiveMQObjectMessage; +import org.apache.activemq.command.ActiveMQQueue; +import org.apache.activemq.command.ActiveMQStreamMessage; +import org.apache.activemq.command.ActiveMQTextMessage; +import org.apache.activemq.command.Message; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.command.ProducerId; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * Round-trips every concrete v13 message marshaller in both tight and loose + * encodings, verifying the {@code deliveryTime} field added in v13 survives + * the wire and that type-specific fields following it stay correctly aligned. + */ +@RunWith(Parameterized.class) +public class MessageMarshallerRoundTripTest { + + private static final long DELIVERY_TIME = 1234567890123L; + + private final String label; + private final Message message; + + public MessageMarshallerRoundTripTest(String label, Message message) { + this.label = label; + this.message = message; + } + + @Parameters(name = "{0}") + public static Collection messageTypes() throws Exception { + ActiveMQTextMessage text = new ActiveMQTextMessage(); + text.setText("hello world"); + + ActiveMQBytesMessage bytes = new ActiveMQBytesMessage(); + bytes.writeBytes(new byte[] {1, 2, 3, 4}); + bytes.storeContent(); + + ActiveMQMapMessage map = new ActiveMQMapMessage(); + map.setString("key", "value"); + map.storeContent(); + + ActiveMQStreamMessage stream = new ActiveMQStreamMessage(); + stream.writeString("streamed"); + stream.storeContent(); + + ActiveMQObjectMessage object = new ActiveMQObjectMessage(); + object.setObject("payload"); + object.storeContent(); + + ActiveMQBlobMessage blob = new ActiveMQBlobMessage(); + blob.setRemoteBlobUrl("http://localhost/blob/1"); + blob.setMimeType("application/octet-stream"); + blob.setDeletedByBroker(true); + + return Arrays.asList(new Object[][] { + {"ActiveMQMessage", new ActiveMQMessage()}, + {"ActiveMQTextMessage", text}, + {"ActiveMQBytesMessage", bytes}, + {"ActiveMQMapMessage", map}, + {"ActiveMQStreamMessage", stream}, + {"ActiveMQObjectMessage", object}, + {"ActiveMQBlobMessage", blob}, + }); + } + + @Test + public void testDeliveryTimeSurvivesTightRoundTrip() throws Exception { + assertDeliveryTimeRoundTrip(true); + } + + @Test + public void testDeliveryTimeSurvivesLooseRoundTrip() throws Exception { + assertDeliveryTimeRoundTrip(false); + } + + private void assertDeliveryTimeRoundTrip(boolean tightEncoding) throws Exception { + OpenWireFormat wireFormat = wireFormat(tightEncoding); + + Message original = prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(label + " must round-trip as the same type", + original.getClass(), restored.getClass()); + assertEquals(label + " deliveryTime must survive the v13 wire", + DELIVERY_TIME, restored.getDeliveryTime()); + assertEquals(original.getMessageId(), restored.getMessageId()); + assertEquals(original.getDestination(), restored.getDestination()); + } + + @Test + public void testDeliveryTimeDefaultsToZero() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + + Message original = prepare(message); + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(0L, restored.getDeliveryTime()); + } + + /** + * The v13 field must not reach the v12 wire. This pins the version gate -- + * a broker left at the default store version neither writes nor reads + * {@code deliveryTime} -- and confirms the round-trip assertions above owe + * their result to the v13 marshaller rather than to incidental state. + */ + @Test + public void testDeliveryTimeIsNotWrittenAtV12() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + wireFormat.setVersion(12); + + Message original = prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(label + " must not carry deliveryTime on the v12 wire", + 0L, restored.getDeliveryTime()); + } + + /** + * The blob marshaller inserts {@code deliveryTime} ahead of its own fields, + * so a misaligned offset would corrupt the blob payload rather than the + * timestamp. Assert both together. + */ + @Test + public void testBlobFieldsStayAlignedAfterDeliveryTime() throws Exception { + if (!(message instanceof ActiveMQBlobMessage)) { + return; + } + OpenWireFormat wireFormat = wireFormat(true); + + ActiveMQBlobMessage original = (ActiveMQBlobMessage) prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + ActiveMQBlobMessage restored = + (ActiveMQBlobMessage) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(DELIVERY_TIME, restored.getDeliveryTime()); + assertEquals("http://localhost/blob/1", restored.getRemoteBlobUrl()); + assertEquals("application/octet-stream", restored.getMimeType()); + assertTrue(restored.isDeletedByBroker()); + } + + private static Message prepare(Message message) throws JMSException { + Message copy = message.copy(); + copy.setMessageId(new MessageId(new ProducerId("id:localhost:1:1:1"), 1)); + copy.setDestination(new ActiveMQQueue("TEST.QUEUE")); + return copy; + } + + private static OpenWireFormat wireFormat(boolean tightEncoding) { + OpenWireFormat wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + wireFormat.setCacheEnabled(false); + wireFormat.setTightEncodingEnabled(tightEncoding); + return wireFormat; + } + + private static byte[] marshal(OpenWireFormat wireFormat, Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private static Object unmarshal(OpenWireFormat wireFormat, byte[] bytes) throws Exception { + return wireFormat.unmarshal(new DataInputStream(new ByteArrayInputStream(bytes))); + } +} From 7b08f2cc365565db097cc7a4ad63022ce3d49e17 Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Thu, 16 Jul 2026 20:21:47 -0500 Subject: [PATCH 4/4] [#1715] Shared Topic Subscription feature - SharedTopicBrokerService (server-side entry point) - SharedTopicConnectionFactory (client-side entry point) --- activemq-broker/pom.xml | 5 + .../broker/SharedTopicBrokerService.java | 167 ++++ .../broker/jmx/ManagedSharedTopicRegion.java | 93 ++ .../jmx/SharedDurableSubscriptionView.java | 56 ++ .../SharedDurableSubscriptionViewMBean.java | 34 + .../broker/jmx/SharedSubscriptionView.java | 52 ++ .../jmx/SharedSubscriptionViewMBean.java | 32 + .../SharedDurableTopicSubscription.java | 242 ++++++ .../broker/region/SharedTopicRegion.java | 532 ++++++++++++ .../region/SharedTopicSubscription.java | 262 ++++++ .../jmx/ManagedSharedTopicRegionTest.java | 139 +++ .../SharedDurableTopicSubscriptionTest.java | 312 +++++++ .../broker/region/SharedTopicRegionTest.java | 351 ++++++++ .../region/SharedTopicSubscriptionTest.java | 301 +++++++ .../activemq/SharedTopicConnection.java | 61 ++ .../SharedTopicConnectionFactory.java | 66 ++ .../apache/activemq/SharedTopicSession.java | 137 +++ .../SharedTopicConnectionFactoryTest.java | 95 +++ .../activemq/SharedTopicConnectionTest.java | 83 ++ .../activemq/SharedTopicSessionTest.java | 196 +++++ .../org/apache/activemq/StubTransport.java | 92 ++ .../SharedTopicBrokerServiceXBeanTest.java | 113 +++ .../resources/spring/shared-topic-broker.xml | 45 + .../usecases/SharedSubscriptionJmsTest.java | 793 ++++++++++++++++++ .../TopicBridgeDemandForwardingTest.java | 261 ++++++ .../usecases/TopicConsumerAdvisoryTest.java | 206 +++++ 26 files changed, 4726 insertions(+) create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java create mode 100644 activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicSubscription.java create mode 100644 activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegionTest.java create mode 100644 activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedDurableTopicSubscriptionTest.java create mode 100644 activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicRegionTest.java create mode 100644 activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicSubscriptionTest.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/SharedTopicConnection.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/SharedTopicConnectionFactory.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/SharedTopicSession.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionFactoryTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/SharedTopicSessionTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/StubTransport.java create mode 100644 activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java create mode 100644 activemq-spring/src/test/resources/spring/shared-topic-broker.xml create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicConsumerAdvisoryTest.java diff --git a/activemq-broker/pom.xml b/activemq-broker/pom.xml index f52ba1edd6e..faf89546605 100644 --- a/activemq-broker/pom.xml +++ b/activemq-broker/pom.xml @@ -75,6 +75,11 @@ junit test + + org.mockito + mockito-core + test + org.apache.logging.log4j log4j-core diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java b/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java new file mode 100644 index 00000000000..a37c989b0b5 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java @@ -0,0 +1,167 @@ +/** + * 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.activemq.broker; + +import java.io.IOException; + +import javax.management.ObjectName; +import javax.management.MalformedObjectNameException; + +import org.apache.activemq.annotation.Experimental; +import org.apache.activemq.broker.jmx.ManagedSharedTopicRegion; +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.apache.activemq.broker.Broker; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.Region; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; + +/** + * Extends {@link BrokerService} to install a {@link SharedTopicRegion} + * that supports JMS 3.1 shared topic subscriptions. + * + *

Use this class in place of {@code BrokerService} in XML configuration + * to enable shared subscription support. In Spring/XBean configuration this + * is the {@code } element of the + * {@code http://activemq.apache.org/schema/core} namespace, and it accepts + * every attribute {@code } does. + * + * @org.apache.xbean.XBean + * + */ +@Experimental("Tech Preview for JMS 3.1 shared topic subscriptions") +public class SharedTopicBrokerService extends BrokerService { + + private static final Logger LOG = LoggerFactory.getLogger(SharedTopicBrokerService.class); + private static final int SHARED_STORE_OPENWIRE_VERSION = 13; + + private boolean topicSubscriptionConversionEnabled; + + public SharedTopicBrokerService() { + setStoreOpenWireVersion(SHARED_STORE_OPENWIRE_VERSION); + } + + public boolean isTopicSubscriptionConversionEnabled() { + return topicSubscriptionConversionEnabled; + } + + public void setTopicSubscriptionConversionEnabled(boolean topicSubscriptionConversionEnabled) { + this.topicSubscriptionConversionEnabled = topicSubscriptionConversionEnabled; + } + + @Override + protected Broker createRegionBroker( + org.apache.activemq.broker.region.DestinationInterceptor destinationInterceptor) + throws IOException { + + RegionBroker regionBroker; + if (isUseJmx()) { + try { + regionBroker = new ManagedRegionBroker(this, getManagementContext(), + getBrokerObjectName(), getTaskRunnerFactory(), getConsumerSystemUsage(), + destinationFactory, destinationInterceptor, getScheduler(), + getExecutor()) { + @Override + protected Region createTopicRegion(SystemUsage memoryManager, + TaskRunnerFactory taskRunnerFactory, + DestinationFactory df) { + ManagedSharedTopicRegion region = new ManagedSharedTopicRegion( + this, destinationStatistics, memoryManager, + taskRunnerFactory, df); + region.setTopicSubscriptionConversionEnabled( + topicSubscriptionConversionEnabled); + return region; + } + + @Override + public void removeConsumer(ConnectionContext context, + ConsumerInfo info) throws Exception { + if (info instanceof SharedConsumerInfo + && ((SharedConsumerInfo) info).isShared()) { + ActiveMQDestination dest = info.getDestination(); + Region region = getRegion(dest); + Subscription sub = null; + if (region instanceof org.apache.activemq.broker.region.AbstractRegion) { + sub = ((org.apache.activemq.broker.region.AbstractRegion) region) + .getSubscriptions().get(info.getConsumerId()); + } + region.removeConsumer(context, info); + if (sub != null && sub instanceof DurableTopicSubscription + && !((DurableTopicSubscription) sub).isActive()) { + ObjectName name = sub.getObjectName(); + if (name != null) { + unregisterSubscription(name, true); + } + } + } else { + super.removeConsumer(context, info); + } + } + + @Override + public void unregisterSubscription(Subscription sub) { + ObjectName name = sub.getObjectName(); + if (name != null) { + try { + unregisterSubscription(name, false); + } catch (Exception e) { + LOG.warn("Failed to unregister shared subscription MBean: {}", + e.getMessage(), e); + } + } + super.unregisterSubscription(sub); + } + }; + } catch (MalformedObjectNameException me) { + LOG.warn("Cannot create ManagedRegionBroker due {}", me.getMessage(), me); + throw new IOException(me); + } + } else { + regionBroker = new RegionBroker(this, getTaskRunnerFactory(), + getConsumerSystemUsage(), destinationFactory, destinationInterceptor, + getScheduler(), getExecutor()) { + @Override + protected Region createTopicRegion(SystemUsage memoryManager, + TaskRunnerFactory taskRunnerFactory, + DestinationFactory df) { + SharedTopicRegion region = new SharedTopicRegion(this, + destinationStatistics, memoryManager, taskRunnerFactory, df); + region.setTopicSubscriptionConversionEnabled( + topicSubscriptionConversionEnabled); + return region; + } + }; + } + + destinationFactory.setRegionBroker(regionBroker); + regionBroker.setKeepDurableSubsActive(isKeepDurableSubsActive()); + regionBroker.getDestinationStatistics().setEnabled(isEnableStatistics()); + regionBroker.setAllowTempAutoCreationOnSend(isAllowTempAutoCreationOnSend()); + return regionBroker; + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java new file mode 100644 index 00000000000..2f31a9435d9 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java @@ -0,0 +1,93 @@ +/** + * 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.activemq.broker.jmx; + +import jakarta.jms.JMSException; +import javax.management.ObjectName; + +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DestinationStatistics; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; + +/** + * JMX-aware variant of {@link SharedTopicRegion}. Registers subscription + * and destination MBeans with the {@link ManagedRegionBroker}. + * + *

Mirrors the upstream {@code ManagedTopicRegion} pattern — used when + * JMX is enabled, while the base {@code SharedTopicRegion} is used when + * JMX is disabled. + */ +public class ManagedSharedTopicRegion extends SharedTopicRegion { + + private final ManagedRegionBroker regionBroker; + + public ManagedSharedTopicRegion(ManagedRegionBroker broker, + DestinationStatistics destinationStatistics, + SystemUsage memoryManager, TaskRunnerFactory taskRunnerFactory, + DestinationFactory destinationFactory) { + super(broker, destinationStatistics, memoryManager, taskRunnerFactory, destinationFactory); + this.regionBroker = broker; + } + + @Override + protected Subscription createSubscription(ConnectionContext context, ConsumerInfo info) + throws JMSException { + Subscription sub = super.createSubscription(context, info); + ObjectName name = regionBroker.registerSubscription(context, sub); + sub.setObjectName(name); + return sub; + } + + @Override + protected void destroySubscription(Subscription sub) { + regionBroker.unregisterSubscription(sub); + super.destroySubscription(sub); + } + + @Override + protected void onSharedDurableReactivated(ConnectionContext context, Subscription sub) { + regionBroker.registerSubscription(context, sub); + } + + @Override + protected void onSharedNonDurableDestroyed(Subscription sub) { + regionBroker.unregisterSubscription(sub); + } + + @Override + protected Destination createDestination(ConnectionContext context, + ActiveMQDestination destination) throws Exception { + Destination rc = super.createDestination(context, destination); + regionBroker.register(destination, rc); + return rc; + } + + @Override + public void removeDestination(ConnectionContext context, + ActiveMQDestination destination, long timeout) throws Exception { + super.removeDestination(context, destination, timeout); + regionBroker.unregister(destination); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java new file mode 100644 index 00000000000..307c423e694 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java @@ -0,0 +1,56 @@ +/** + * 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.activemq.broker.jmx; + +import org.apache.activemq.broker.region.SharedDurableTopicSubscription; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.jmx.DurableSubscriptionView; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.broker.region.Subscription; + +/** + * JMX view for shared durable topic subscriptions. Delegates + * shared-specific attributes to the underlying + * {@link SharedDurableTopicSubscription}. + */ +public class SharedDurableSubscriptionView extends DurableSubscriptionView + implements SharedDurableSubscriptionViewMBean { + + private final SharedDurableTopicSubscription sharedSub; + + public SharedDurableSubscriptionView(ManagedRegionBroker broker, + BrokerService brokerService, String clientId, String userName, + Subscription sub) { + super(broker, brokerService, clientId, userName, sub); + this.sharedSub = (SharedDurableTopicSubscription) sub; + } + + @Override + public boolean isShared() { + return true; + } + + @Override + public int getConsumerCount() { + return sharedSub.getConsumerCount(); + } + + @Override + public String toString() { + return "SharedDurableSubscriptionView: " + getClientId() + ":" + getSubscriptionName(); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java new file mode 100644 index 00000000000..09f0efd794a --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java @@ -0,0 +1,34 @@ +/** + * 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.activemq.broker.jmx; + +import org.apache.activemq.broker.jmx.DurableSubscriptionViewMBean; +import org.apache.activemq.broker.jmx.MBeanInfo; + +/** + * JMX MBean interface for shared durable topic subscriptions. + * Extends the standard durable subscription view with shared-specific + * attributes. + */ +public interface SharedDurableSubscriptionViewMBean extends DurableSubscriptionViewMBean { + + @MBeanInfo("Whether this is a JMS 3.1 shared subscription") + boolean isShared(); + + @MBeanInfo("Number of consumers currently sharing this subscription") + int getConsumerCount(); +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java new file mode 100644 index 00000000000..f5b04386546 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java @@ -0,0 +1,52 @@ +/** + * 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.activemq.broker.jmx; + +import org.apache.activemq.broker.region.SharedTopicSubscription; +import org.apache.activemq.broker.jmx.SubscriptionView; +import org.apache.activemq.broker.region.Subscription; + +/** + * JMX view for shared non-durable topic subscriptions. Delegates + * shared-specific attributes to the underlying + * {@link SharedTopicSubscription}. + */ +public class SharedSubscriptionView extends SubscriptionView + implements SharedSubscriptionViewMBean { + + private final SharedTopicSubscription sharedSub; + + public SharedSubscriptionView(String clientId, String userName, Subscription sub) { + super(clientId, userName, sub); + this.sharedSub = (SharedTopicSubscription) sub; + } + + @Override + public boolean isShared() { + return true; + } + + @Override + public int getConsumerCount() { + return sharedSub.getConsumerCount(); + } + + @Override + public String toString() { + return "SharedSubscriptionView: " + getClientId() + ":" + getSubscriptionName(); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java new file mode 100644 index 00000000000..c2d08aa26fc --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java @@ -0,0 +1,32 @@ +/** + * 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.activemq.broker.jmx; + +import org.apache.activemq.broker.jmx.MBeanInfo; +import org.apache.activemq.broker.jmx.SubscriptionViewMBean; + +/** + * JMX MBean interface for shared non-durable topic subscriptions. + */ +public interface SharedSubscriptionViewMBean extends SubscriptionViewMBean { + + @MBeanInfo("Whether this is a JMS 3.1 shared subscription") + boolean isShared(); + + @MBeanInfo("Number of consumers currently sharing this subscription") + int getConsumerCount(); +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java new file mode 100644 index 00000000000..c31173289ac --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java @@ -0,0 +1,242 @@ +/** + * 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.activemq.broker.region; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.jms.JMSException; + +import org.apache.activemq.broker.Broker; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.MessageReference; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.MessageAck; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.usage.SystemUsage; +import org.apache.activemq.util.SubscriptionKey; + +/** + * Durable topic subscription that dispatches messages queue-style across + * multiple consumers sharing the same subscription name. + * + *

The parent {@link DurableTopicSubscription} assumes a single consumer. + * This subclass maintains a list of consumers and overrides {@code dispatch()} + * to round-robin among non-full consumers, giving each message to exactly + * one consumer — queue semantics within a topic subscription. + */ +public class SharedDurableTopicSubscription extends DurableTopicSubscription { + + private final SubscriptionKey storedKey; + private final CopyOnWriteArrayList consumers = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap dispatchedTo = new ConcurrentHashMap<>(); + private int nextConsumerIndex; + + public SharedDurableTopicSubscription(Broker broker, SystemUsage usageManager, + ConnectionContext context, ConsumerInfo info, boolean keepDurableSubsActive) + throws JMSException { + super(broker, usageManager, context, info, keepDurableSubsActive); + consumers.add(new ConsumerState(context, info)); + String clientId = context.getClientId() != null ? context.getClientId() : ""; + this.storedKey = new SubscriptionKey(clientId, info.getSubscriptionName()); + } + + @Override + public SubscriptionKey getSubscriptionKey() { + return storedKey; + } + + public void addConsumer(ConnectionContext ctx, ConsumerInfo consumerInfo) throws IOException { + if (!isActive() && !consumers.isEmpty()) { + consumers.clear(); + } + consumers.add(new ConsumerState(ctx, consumerInfo)); + dispatchPending(); + } + + public void removeConsumer(ConsumerId consumerId) throws Exception { + ConsumerState removed = null; + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(consumerId)) { + removed = cs; + consumers.remove(cs); + break; + } + } + if (removed != null) { + requeueDispatchedTo(removed); + if (!consumers.isEmpty()) { + ConsumerState first = consumers.get(0); + this.context = first.context; + this.info = first.info; + dispatchPending(); + } + } + } + + public int getConsumerCount() { + return consumers.size(); + } + + public boolean hasConsumers() { + return !consumers.isEmpty(); + } + + // [dispatch override] queue-style among consumers + + @Override + protected boolean dispatch(MessageReference node) throws IOException { + ConsumerState target = selectConsumer(); + if (target == null) { + return false; + } + + ConnectionContext savedContext = this.context; + ConsumerInfo savedInfo = this.info; + this.context = target.context; + this.info = target.info; + try { + boolean result = super.dispatch(node); + if (result) { + dispatchedTo.put(node.getMessageId(), target.info.getConsumerId()); + target.dispatched++; + } + return result; + } finally { + this.context = savedContext; + this.info = savedInfo; + } + } + + @Override + public boolean isFull() { + if (!isActive()) { + return true; + } + List snapshot = consumers; + if (snapshot.isEmpty()) { + return true; + } + for (ConsumerState cs : snapshot) { + if (!cs.isFull()) { + return false; + } + } + return true; + } + + @Override + public int countBeforeFull() { + int total = 0; + for (ConsumerState cs : consumers) { + total += cs.countBeforeFull(); + } + return total; + } + + // [ack routing] decrement the correct consumer's dispatch count + + @Override + protected void acknowledge(ConnectionContext ctx, MessageAck ack, + MessageReference node) throws IOException { + ConsumerId cid = dispatchedTo.remove(node.getMessageId()); + if (cid != null) { + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(cid)) { + cs.dispatched--; + break; + } + } + } + super.acknowledge(ctx, ack, node); + } + + // [consumer selection] round-robin, skip full + + ConsumerState selectConsumer() { + List snapshot = consumers; + int size = snapshot.size(); + if (size == 0) { + return null; + } + for (int i = 0; i < size; i++) { + int idx = (nextConsumerIndex + i) % size; + ConsumerState cs = snapshot.get(idx); + if (!cs.isFull()) { + nextConsumerIndex = (idx + 1) % size; + return cs; + } + } + return null; + } + + private void requeueDispatchedTo(ConsumerState removed) throws Exception { + ConsumerId cid = removed.info.getConsumerId(); + List toRequeue = new ArrayList<>(); + + synchronized (dispatchLock) { + for (MessageReference ref : dispatched) { + ConsumerId owner = dispatchedTo.get(ref.getMessageId()); + if (cid.equals(owner)) { + toRequeue.add(ref); + } + } + for (MessageReference ref : toRequeue) { + dispatched.remove(ref); + dispatchedTo.remove(ref.getMessageId()); + } + } + + if (!toRequeue.isEmpty()) { + Collections.reverse(toRequeue); + synchronized (pendingLock) { + for (MessageReference ref : toRequeue) { + ref.incrementRedeliveryCounter(); + pending.addMessageFirst(ref); + } + } + } + } + + // [per-consumer state] + + static class ConsumerState { + final ConnectionContext context; + final ConsumerInfo info; + int dispatched; + + ConsumerState(ConnectionContext context, ConsumerInfo info) { + this.context = context; + this.info = info; + } + + boolean isFull() { + return info.getPrefetchSize() > 0 && dispatched >= info.getPrefetchSize(); + } + + int countBeforeFull() { + return Math.max(0, info.getPrefetchSize() - dispatched); + } + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java new file mode 100644 index 00000000000..8bff667f043 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java @@ -0,0 +1,532 @@ +/** + * 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.activemq.broker.region; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DestinationStatistics; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.broker.region.Topic; +import org.apache.activemq.broker.region.TopicRegion; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.RemoveSubscriptionInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.command.SharedSubscriptionInfo; +import org.apache.activemq.command.SubscriptionInfo; +import org.apache.activemq.store.TopicMessageStore; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; +import org.apache.activemq.util.SharedSubscriptionKey; +import org.apache.activemq.util.SubscriptionKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Extends {@link TopicRegion} to route {@link SharedConsumerInfo} commands + * to shared subscription instances. Multiple consumers with the same + * subscription name join a single subscription rather than creating + * separate ones. + * + *

Persists the shared flag via {@link SharedSubscriptionInfo} so + * shared durable subscriptions are correctly restored after broker restart. + * + *

Enforces JMS 3.1 type-conflict rules: shared and unshared durable + * subscriptions may not have the same name and client identifier. Set + * {@link #setTopicSubscriptionConversionEnabled(boolean)} to {@code true} + * to allow automatic promotion/demotion instead of throwing. + */ +public class SharedTopicRegion extends TopicRegion { + + private static final Logger LOG = LoggerFactory.getLogger(SharedTopicRegion.class); + + private final ConcurrentHashMap sharedDurableSubs = + new ConcurrentHashMap<>(); + private final ConcurrentHashMap sharedNonDurableSubs = + new ConcurrentHashMap<>(); + private final ConcurrentHashMap consumerToSharedKey = + new ConcurrentHashMap<>(); + + private boolean topicSubscriptionConversionEnabled; + + public SharedTopicRegion(RegionBroker broker, DestinationStatistics destinationStatistics, + SystemUsage memoryManager, TaskRunnerFactory taskRunnerFactory, + DestinationFactory destinationFactory) { + super(broker, destinationStatistics, memoryManager, taskRunnerFactory, destinationFactory); + } + + public boolean isTopicSubscriptionConversionEnabled() { + return topicSubscriptionConversionEnabled; + } + + public void setTopicSubscriptionConversionEnabled(boolean topicSubscriptionConversionEnabled) { + this.topicSubscriptionConversionEnabled = topicSubscriptionConversionEnabled; + } + + // [Restore path] + + @Override + public ConsumerInfo createInactiveConsumerInfo(SubscriptionInfo info) { + ConsumerInfo base = super.createInactiveConsumerInfo(info); + if (info instanceof SharedSubscriptionInfo && ((SharedSubscriptionInfo) info).isShared()) { + SharedConsumerInfo shared = new SharedConsumerInfo(base.getConsumerId()); + shared.setSelector(base.getSelector()); + shared.setSubscriptionName(base.getSubscriptionName()); + shared.setDestination(base.getDestination()); + shared.setNoLocal(base.isNoLocal()); + shared.setShared(true); + shared.setDurable(true); + return shared; + } + return base; + } + + @Override + protected List addSubscriptionsForDestination(ConnectionContext context, + Destination dest) throws Exception { + List result = super.addSubscriptionsForDestination(context, dest); + + for (Subscription sub : result) { + if (sub instanceof SharedDurableTopicSubscription) { + SharedDurableTopicSubscription sharedSub = (SharedDurableTopicSubscription) sub; + String clientId = sharedSub.getSubscriptionKey().getClientId(); + String subName = sharedSub.getSubscriptionKey().getSubscriptionName(); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey(clientId, subName); + sharedDurableSubs.putIfAbsent(sharedKey, sharedSub); + LOG.debug("Restored shared durable subscription '{}'", sharedKey); + } + } + + return result; + } + + // [Consumer routing] + + @Override + public Subscription addConsumer(ConnectionContext context, ConsumerInfo info) throws Exception { + if (!(info instanceof SharedConsumerInfo) || !((SharedConsumerInfo) info).isShared() + || !info.getNetworkConsumerIds().isEmpty()) { + if (info.isDurable()) { + checkUnsharedToSharedConflict(context, info); + } + return super.addConsumer(context, info); + } + + SharedConsumerInfo sharedInfo = (SharedConsumerInfo) info; + String effectiveClientId = effectiveClientId(context, sharedInfo); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey( + effectiveClientId, info.getSubscriptionName()); + + if (sharedInfo.isDurable()) { + return addSharedDurableConsumer(context, sharedInfo, sharedKey); + } else { + return addSharedNonDurableConsumer(context, sharedInfo, sharedKey); + } + } + + private Subscription addSharedDurableConsumer(ConnectionContext context, + SharedConsumerInfo info, SharedSubscriptionKey sharedKey) throws Exception { + normalizeClientId(context); + + SharedDurableTopicSubscription existing = sharedDurableSubs.get(sharedKey); + if (existing != null) { + validateSelectorMatch(info, existing.getConsumerInfo()); + existing.addConsumer(context, info); + subscriptions.put(info.getConsumerId(), existing); + consumerToSharedKey.put(info.getConsumerId(), sharedKey); + if (!existing.isActive()) { + existing.activate(usageManager, context, info, (RegionBroker) broker); + onSharedDurableReactivated(context, existing); + } + LOG.debug("Consumer {} joined shared durable subscription '{}'", + info.getConsumerId(), sharedKey); + return existing; + } + + checkSharedToUnsharedConflict(context, info); + + LOG.debug("Consumer {} creating new shared durable subscription '{}'", + info.getConsumerId(), sharedKey); + Subscription sub = super.addConsumer(context, info); + consumerToSharedKey.put(info.getConsumerId(), sharedKey); + + persistSharedFlag(context, info); + + return sub; + } + + private Subscription addSharedNonDurableConsumer(ConnectionContext context, + SharedConsumerInfo info, SharedSubscriptionKey sharedKey) throws Exception { + normalizeClientId(context); + + SharedTopicSubscription existing = sharedNonDurableSubs.get(sharedKey); + if (existing != null) { + validateSelectorMatch(info, existing.getConsumerInfo()); + existing.addConsumer(context, info); + subscriptions.put(info.getConsumerId(), existing); + consumerToSharedKey.put(info.getConsumerId(), sharedKey); + LOG.debug("Consumer {} joined shared non-durable subscription '{}'", + info.getConsumerId(), sharedKey); + return existing; + } + + Subscription sub = super.addConsumer(context, info); + consumerToSharedKey.put(info.getConsumerId(), sharedKey); + return sub; + } + + @Override + public void removeConsumer(ConnectionContext context, ConsumerInfo info) throws Exception { + SharedSubscriptionKey sharedKey = consumerToSharedKey.remove(info.getConsumerId()); + if (sharedKey == null) { + super.removeConsumer(context, info); + return; + } + + if (info.isDurable()) { + removeSharedDurableConsumer(context, info, sharedKey); + } else { + removeSharedNonDurableConsumer(context, info, sharedKey); + } + } + + private void removeSharedDurableConsumer(ConnectionContext context, + ConsumerInfo info, SharedSubscriptionKey sharedKey) throws Exception { + + SharedDurableTopicSubscription sub = sharedDurableSubs.get(sharedKey); + if (sub == null) { + super.removeConsumer(context, info); + return; + } + + subscriptions.remove(info.getConsumerId()); + sub.removeConsumer(info.getConsumerId()); + LOG.debug("Consumer {} left shared durable subscription '{}', {} remaining", + info.getConsumerId(), sharedKey, sub.getConsumerCount()); + + if (!sub.hasConsumers()) { + sub.deactivate(isKeepDurableSubsActive(), info.getLastDeliveredSequenceId()); + } + } + + private void removeSharedNonDurableConsumer(ConnectionContext context, + ConsumerInfo info, SharedSubscriptionKey sharedKey) throws Exception { + + SharedTopicSubscription sub = sharedNonDurableSubs.get(sharedKey); + if (sub == null) { + super.removeConsumer(context, info); + return; + } + + subscriptions.remove(info.getConsumerId()); + sub.removeConsumer(info.getConsumerId()); + + if (!sub.hasConsumers()) { + sharedNonDurableSubs.remove(sharedKey); + removeSubscriptionFromDestinations(context, sub, info); + onSharedNonDurableDestroyed(sub); + sub.destroy(); + } + } + + // [Unsubscribe] + + @Override + public void removeSubscription(ConnectionContext context, RemoveSubscriptionInfo info) throws Exception { + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey("", info.getSubscriptionName()); + SharedDurableTopicSubscription sharedSub = sharedDurableSubs.get(sharedKey); + + if (sharedSub == null && info.getClientId() != null) { + sharedKey = new SharedSubscriptionKey(info.getClientId(), info.getSubscriptionName()); + sharedSub = sharedDurableSubs.get(sharedKey); + } + + if (sharedSub == null) { + super.removeSubscription(context, info); + return; + } + + if (sharedSub.isActive()) { + throw new JMSException("Shared durable consumer is in use", + ActiveMQErrorCode.SUBSCRIPTION_IN_USE); + } + + sharedDurableSubs.remove(sharedKey); + + SubscriptionKey durKey = null; + for (Map.Entry entry : durableSubscriptions.entrySet()) { + if (entry.getValue() == sharedSub) { + durKey = entry.getKey(); + break; + } + } + if (durKey != null) { + durableSubscriptions.remove(durKey); + destinationsLock.readLock().lock(); + try { + @SuppressWarnings("unchecked") + Set dests = destinationMap.unsynchronizedGet( + sharedSub.getConsumerInfo().getDestination()); + if (dests != null) { + for (Destination dest : dests) { + if (dest instanceof Topic) { + ((Topic) dest).deleteSubscription(context, durKey); + } + } + } + } finally { + destinationsLock.readLock().unlock(); + } + } + + destroySubscription(sharedSub); + } + + // [Subscription factory] + + @Override + protected Subscription createSubscription(ConnectionContext context, ConsumerInfo info) + throws JMSException { + if (!(info instanceof SharedConsumerInfo) || !((SharedConsumerInfo) info).isShared()) { + return super.createSubscription(context, info); + } + + SharedConsumerInfo sharedInfo = (SharedConsumerInfo) info; + ActiveMQDestination destination = info.getDestination(); + + if (sharedInfo.isDurable()) { + return createSharedDurableSubscription(context, sharedInfo, destination); + } else { + return createSharedNonDurableSubscription(context, sharedInfo, destination); + } + } + + private Subscription createSharedDurableSubscription(ConnectionContext context, + SharedConsumerInfo info, ActiveMQDestination destination) throws JMSException { + + String ecid = effectiveClientId(context, info); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey( + ecid, info.getSubscriptionName()); + + String contextClientId = context.getClientId() != null ? context.getClientId() : ""; + SubscriptionKey subsKey = new SubscriptionKey( + contextClientId, info.getSubscriptionName()); + + if (durableSubscriptions.containsKey(subsKey)) { + throw new JMSException("Shared durable subscription is already active: " + + info.getSubscriptionName(), ActiveMQErrorCode.SUBSCRIPTION_ALREADY_EXISTS); + } + + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usageManager, context, info, isKeepDurableSubsActive()); + + applyPolicy(destination, sub); + durableSubscriptions.put(subsKey, sub); + sharedDurableSubs.put(sharedKey, sub); + return sub; + } + + private Subscription createSharedNonDurableSubscription(ConnectionContext context, + SharedConsumerInfo info, ActiveMQDestination destination) throws JMSException { + try { + String ecid = effectiveClientId(context, info); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey( + ecid, info.getSubscriptionName()); + + SharedTopicSubscription sub = new SharedTopicSubscription( + broker, usageManager, context, info); + + applyPolicy(destination, sub); + sharedNonDurableSubs.put(sharedKey, sub); + return sub; + } catch (Exception e) { + JMSException jmsEx = new JMSException("Couldn't create shared TopicSubscription"); + jmsEx.setLinkedException(e); + throw jmsEx; + } + } + + // [Store path]: persist shared flag + + private void persistSharedFlag(ConnectionContext context, SharedConsumerInfo info) { + ActiveMQDestination destination = info.getDestination(); + if (destination == null || destination.isPattern()) { + return; + } + destinationsLock.readLock().lock(); + try { + @SuppressWarnings("unchecked") + Set dests = destinationMap.unsynchronizedGet(destination); + if (dests != null) { + for (Destination dest : dests) { + if (dest instanceof Topic) { + TopicMessageStore store = (TopicMessageStore) dest.getMessageStore(); + if (store != null) { + SharedSubscriptionInfo sinfo = new SharedSubscriptionInfo(); + sinfo.setClientId(context.getClientId()); + sinfo.setSubscriptionName(info.getSubscriptionName()); + sinfo.setSelector(info.getSelector()); + sinfo.setDestination(dest.getActiveMQDestination()); + sinfo.setSubscribedDestination(destination); + sinfo.setNoLocal(info.isNoLocal()); + sinfo.setShared(true); + store.addSubscription(sinfo, info.isRetroactive()); + } + } + } + } + } catch (Exception e) { + LOG.warn("Failed to persist shared flag for subscription '{}': {}", + info.getSubscriptionName(), e.getMessage(), e); + } finally { + destinationsLock.readLock().unlock(); + } + } + + // [Type-conflict guards] + + private void checkSharedToUnsharedConflict(ConnectionContext context, + SharedConsumerInfo info) throws JMSException { + SubscriptionKey subsKey = new SubscriptionKey( + context.getClientId(), info.getSubscriptionName()); + DurableTopicSubscription existing = durableSubscriptions.get(subsKey); + if (existing != null && !(existing instanceof SharedDurableTopicSubscription)) { + if (!topicSubscriptionConversionEnabled) { + throw new JMSException( + "A shared durable subscription and an unshared durable subscription " + + "may not have the same name and client identifier. " + + "Subscription '" + info.getSubscriptionName() + + "' exists as an unshared durable subscription.", + ActiveMQErrorCode.SUBSCRIPTION_TYPE_CONFLICT); + } + LOG.warn("Converting unshared durable subscription '{}' to shared " + + "(topicSubscriptionConversionEnabled=true)", info.getSubscriptionName()); + durableSubscriptions.remove(subsKey); + } + } + + private void checkUnsharedToSharedConflict(ConnectionContext context, + ConsumerInfo info) throws JMSException { + normalizeClientId(context); + SubscriptionKey subsKey = new SubscriptionKey( + context.getClientId(), info.getSubscriptionName()); + DurableTopicSubscription existing = durableSubscriptions.get(subsKey); + if (existing instanceof SharedDurableTopicSubscription) { + if (!topicSubscriptionConversionEnabled) { + throw new JMSException( + "A shared durable subscription and an unshared durable subscription " + + "may not have the same name and client identifier. " + + "Subscription '" + info.getSubscriptionName() + + "' exists as a shared durable subscription.", + ActiveMQErrorCode.SUBSCRIPTION_TYPE_CONFLICT); + } + LOG.warn("Converting shared durable subscription '{}' to unshared " + + "(topicSubscriptionConversionEnabled=true)", info.getSubscriptionName()); + SharedSubscriptionKey sharedKey = new SharedSubscriptionKey( + context.getClientId(), info.getSubscriptionName()); + sharedDurableSubs.remove(sharedKey); + durableSubscriptions.remove(subsKey); + } + } + + // [Policy] + + private void applyPolicy(ActiveMQDestination destination, + SharedDurableTopicSubscription sub) { + if (destination != null && broker.getDestinationPolicy() != null) { + org.apache.activemq.broker.region.policy.PolicyEntry entry = + broker.getDestinationPolicy().getEntryFor(destination); + if (entry != null) { + entry.configure(broker, usageManager, sub); + } + } + } + + private void applyPolicy(ActiveMQDestination destination, + SharedTopicSubscription sub) { + if (destination != null && broker.getDestinationPolicy() != null) { + org.apache.activemq.broker.region.policy.PolicyEntry entry = + broker.getDestinationPolicy().getEntryFor(destination); + if (entry != null) { + entry.configurePrefetch(sub); + } + } + } + + // [JMX hook] overridden by ManagedSharedTopicRegion + + protected void onSharedDurableReactivated(ConnectionContext context, Subscription sub) { + } + + protected void onSharedNonDurableDestroyed(Subscription sub) { + } + + @SuppressWarnings("unchecked") + private void removeSubscriptionFromDestinations(ConnectionContext context, + Subscription sub, ConsumerInfo info) throws Exception { + destinationsLock.readLock().lock(); + try { + for (Destination dest : (Set) destinationMap.unsynchronizedGet( + info.getDestination())) { + dest.removeSubscription(context, sub, info.getLastDeliveredSequenceId()); + } + } finally { + destinationsLock.readLock().unlock(); + } + } + + private void validateSelectorMatch(ConsumerInfo incoming, ConsumerInfo existing) + throws JMSException { + String incomingSel = incoming.getSelector(); + String existingSel = existing.getSelector(); + if (incomingSel == null && existingSel == null) { + return; + } + if (incomingSel == null || !incomingSel.equals(existingSel)) { + throw new JMSException( + "Selector mismatch for shared subscription '" + + incoming.getSubscriptionName() + + "': existing='" + existingSel + "', incoming='" + incomingSel + "'", + ActiveMQErrorCode.SELECTOR_MISMATCH); + } + } + + private static void normalizeClientId(ConnectionContext context) { + if (context.getClientId() == null) { + context.setClientId(""); + } + } + + private static String effectiveClientId(ConnectionContext context, SharedConsumerInfo info) { + if (!info.isUserSpecifiedClientId()) { + return ""; + } + return context.getClientId() != null ? context.getClientId() : ""; + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicSubscription.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicSubscription.java new file mode 100644 index 00000000000..7b080912023 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicSubscription.java @@ -0,0 +1,262 @@ +/** + * 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.activemq.broker.region; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.jms.JMSException; + +import org.apache.activemq.broker.Broker; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.MessageReference; +import org.apache.activemq.broker.region.PrefetchSubscription; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.MessageAck; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.usage.SystemUsage; + +/** + * Non-durable topic subscription that dispatches messages queue-style across + * multiple consumers sharing the same subscription name. + * + *

Uses an in-memory cursor (no store persistence). The subscription is + * removed when the last consumer detaches. + */ +public class SharedTopicSubscription extends PrefetchSubscription { + + private final CopyOnWriteArrayList consumers = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap dispatchedTo = new ConcurrentHashMap<>(); + private int nextConsumerIndex; + + public SharedTopicSubscription(Broker broker, SystemUsage usageManager, + ConnectionContext context, ConsumerInfo info) throws JMSException { + super(broker, usageManager, context, info); + consumers.add(new ConsumerState(context, info)); + } + + public void addConsumer(ConnectionContext ctx, ConsumerInfo consumerInfo) throws IOException { + consumers.add(new ConsumerState(ctx, consumerInfo)); + dispatchPending(); + } + + public void removeConsumer(ConsumerId consumerId) throws Exception { + ConsumerState removed = null; + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(consumerId)) { + removed = cs; + consumers.remove(cs); + break; + } + } + if (removed != null) { + requeueDispatchedTo(removed); + if (!consumers.isEmpty()) { + ConsumerState first = consumers.get(0); + this.context = first.context; + this.info = first.info; + dispatchPending(); + } + } + } + + public int getConsumerCount() { + return consumers.size(); + } + + public boolean hasConsumers() { + return !consumers.isEmpty(); + } + + // [dispatch override] queue-style among consumers + + @Override + protected boolean dispatch(MessageReference node) throws IOException { + ConsumerState target = selectConsumer(); + if (target == null) { + return false; + } + + ConnectionContext savedContext = this.context; + ConsumerInfo savedInfo = this.info; + this.context = target.context; + this.info = target.info; + try { + boolean result = super.dispatch(node); + if (result) { + dispatchedTo.put(node.getMessageId(), target.info.getConsumerId()); + target.dispatched++; + } + return result; + } finally { + this.context = savedContext; + this.info = savedInfo; + } + } + + @Override + public boolean isFull() { + List snapshot = consumers; + if (snapshot.isEmpty()) { + return true; + } + for (ConsumerState cs : snapshot) { + if (!cs.isFull()) { + return false; + } + } + return true; + } + + @Override + public int countBeforeFull() { + int total = 0; + for (ConsumerState cs : consumers) { + total += cs.countBeforeFull(); + } + return total; + } + + @Override + protected boolean canDispatch(MessageReference node) throws IOException { + return true; + } + + @Override + protected boolean isDropped(MessageReference node) { + return false; + } + + // [ack routing] decrement the correct consumer's dispatch count + + @Override + protected void acknowledge(ConnectionContext ctx, MessageAck ack, + MessageReference node) throws IOException { + ConsumerId cid = dispatchedTo.remove(node.getMessageId()); + if (cid != null) { + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(cid)) { + cs.dispatched--; + break; + } + } + } + this.setTimeOfLastMessageAck(System.currentTimeMillis()); + Destination regionDestination = (Destination) node.getRegionDestination(); + regionDestination.acknowledge(ctx, this, ack, node); + node.decrementReferenceCount(); + } + + @Override + public void destroy() { + synchronized (pendingLock) { + try { + pending.reset(); + while (pending.hasNext()) { + MessageReference node = pending.next(); + node.decrementReferenceCount(); + } + } finally { + pending.release(); + pending.clear(); + } + } + synchronized (dispatchLock) { + for (MessageReference node : dispatched) { + node.decrementReferenceCount(); + } + dispatched.clear(); + } + dispatchedTo.clear(); + consumers.clear(); + setSlowConsumer(false); + } + + // [consumer selection] round-robin, skip full + + ConsumerState selectConsumer() { + List snapshot = consumers; + int size = snapshot.size(); + if (size == 0) { + return null; + } + for (int i = 0; i < size; i++) { + int idx = (nextConsumerIndex + i) % size; + ConsumerState cs = snapshot.get(idx); + if (!cs.isFull()) { + nextConsumerIndex = (idx + 1) % size; + return cs; + } + } + return null; + } + + private void requeueDispatchedTo(ConsumerState removed) throws Exception { + ConsumerId cid = removed.info.getConsumerId(); + List toRequeue = new ArrayList<>(); + + synchronized (dispatchLock) { + for (MessageReference ref : dispatched) { + ConsumerId owner = dispatchedTo.get(ref.getMessageId()); + if (cid.equals(owner)) { + toRequeue.add(ref); + } + } + for (MessageReference ref : toRequeue) { + dispatched.remove(ref); + dispatchedTo.remove(ref.getMessageId()); + } + } + + if (!toRequeue.isEmpty()) { + Collections.reverse(toRequeue); + synchronized (pendingLock) { + for (MessageReference ref : toRequeue) { + ref.incrementRedeliveryCounter(); + pending.addMessageFirst(ref); + } + } + } + } + + // [per-consumer state] shared with SharedDurableTopicSubscription + + static class ConsumerState { + final ConnectionContext context; + final ConsumerInfo info; + int dispatched; + + ConsumerState(ConnectionContext context, ConsumerInfo info) { + this.context = context; + this.info = info; + } + + boolean isFull() { + return info.getPrefetchSize() > 0 && dispatched >= info.getPrefetchSize(); + } + + int countBeforeFull() { + return Math.max(0, info.getPrefetchSize() - dispatched); + } + } +} diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegionTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegionTest.java new file mode 100644 index 00000000000..b828c3fdc59 --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegionTest.java @@ -0,0 +1,139 @@ +/** + * 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.activemq.broker.jmx; + +import static org.junit.Assert.*; + +import javax.management.ObjectName; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.broker.region.SharedDurableTopicSubscription; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.command.SharedConsumerInfo; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class ManagedSharedTopicRegionTest { + + private SharedTopicBrokerService brokerService; + + @Before + public void setUp() throws Exception { + brokerService = new SharedTopicBrokerService(); + brokerService.setPersistent(false); + brokerService.setUseJmx(true); + brokerService.setBrokerName("jmx-shared-test"); + brokerService.start(); + brokerService.waitUntilStarted(); + } + + @After + public void tearDown() throws Exception { + if (brokerService != null) { + brokerService.stop(); + brokerService.waitUntilStopped(); + } + } + + @Test + public void testSharedDurableSubscriptionRegisteredInJmx() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "jmxDurSub", true); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertTrue(sub instanceof SharedDurableTopicSubscription); + assertNotNull("Subscription should have JMX ObjectName", sub.getObjectName()); + } + + @Test + public void testSharedNonDurableSubscriptionRegisteredInJmx() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "jmxNonDurSub", false); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertNotNull("Subscription should have JMX ObjectName", sub.getObjectName()); + } + + @Test + public void testJoiningConsumerReusesMBean() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "jmxJoinSub", true); + Subscription sub1 = brokerService.getBroker().addConsumer(ctx1, info1); + ObjectName name1 = sub1.getObjectName(); + assertNotNull(name1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "jmxJoinSub", true); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + + assertSame("Second consumer should join same subscription", sub1, sub2); + assertEquals("ObjectName should be the same MBean", name1, sub2.getObjectName()); + } + + @Test + public void testNonSharedDurableRegisteredInJmx() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createDurableConsumerInfo("conn-1", 1, 1, "plainDurSub"); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertNotNull("Non-shared durable should also have JMX ObjectName", + sub.getObjectName()); + } + + private ConnectionContext createContext(String clientId) throws Exception { + ConnectionContext ctx = new ConnectionContext(); + ctx.setClientId(clientId); + ctx.setBroker(brokerService.getBroker()); + return ctx; + } + + private SharedConsumerInfo createSharedConsumerInfo(String connId, int session, + int consumer, String subName, boolean durable) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, session); + ConsumerId consumerId = new ConsumerId(sid, consumer); + SharedConsumerInfo info = new SharedConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.jmx.topic")); + info.setPrefetchSize(10); + info.setSubscriptionName(subName); + info.setShared(true); + info.setDurable(durable); + return info; + } + + private ConsumerInfo createDurableConsumerInfo(String connId, int session, + int consumer, String subName) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, session); + ConsumerId consumerId = new ConsumerId(sid, consumer); + ConsumerInfo info = new ConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.jmx.topic")); + info.setPrefetchSize(10); + info.setSubscriptionName(subName); + return info; + } +} diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedDurableTopicSubscriptionTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedDurableTopicSubscriptionTest.java new file mode 100644 index 00000000000..902584ac22b --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedDurableTopicSubscriptionTest.java @@ -0,0 +1,312 @@ +/** + * 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.activemq.broker.region; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.EmptyBroker; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.usage.SystemUsage; +import org.junit.Before; +import org.junit.Test; + +public class SharedDurableTopicSubscriptionTest { + + private EmptyBroker broker; + private SystemUsage usage; + + @Before + public void setUp() throws Exception { + BrokerService brokerService = new BrokerService(); + brokerService.setPersistent(false); + broker = new EmptyBroker() { + @Override + public BrokerService getBrokerService() { + return brokerService; + } + }; + usage = new SystemUsage(); + } + + private ConnectionContext createContext(String clientId) { + ConnectionContext ctx = new ConnectionContext(); + ctx.setClientId(clientId); + return ctx; + } + + /** + * Builds a subscription in the state SharedTopicRegion leaves it in for a + * newly created shared durable sub: constructed and activated. + * + *

Activation matters for any test that adds a second consumer. + * {@code addConsumer()} clears the consumer list when the subscription is + * inactive, which is how it discards the null-connection placeholder left + * by broker restore. The region only reaches that path on reactivation; a + * new sub is activated by {@code super.addConsumer()} before a second + * consumer can join. Constructing the sub directly would leave it inactive + * and make every {@code addConsumer()} look like a restore, silently + * dropping the initial consumer. + * + *

The mocked {@link RegionBroker} returns a null destination policy, + * which is the only thing {@code activate()} asks it for here. + */ + private SharedDurableTopicSubscription createActiveSub(ConnectionContext ctx, + ConsumerInfo info) throws Exception { + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + sub.activate(usage, ctx, info, mock(RegionBroker.class)); + assertTrue("fixture must start active", sub.isActive()); + return sub; + } + + private ConsumerInfo createConsumerInfo(String connId, int sessionNum, int consumerNum, + int prefetch) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, sessionNum); + ConsumerId consumerId = new ConsumerId(sid, consumerNum); + ConsumerInfo info = new ConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.topic")); + info.setPrefetchSize(prefetch); + info.setSubscriptionName("sharedDurSub"); + return info; + } + + @Test + public void testConstructorAddsSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + assertEquals(1, sub.getConsumerCount()); + assertTrue(sub.hasConsumers()); + } + + @Test + public void testAddConsumerIncrementsCount() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = createActiveSub(ctx1, info1); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + assertEquals(2, sub.getConsumerCount()); + } + + @Test + public void testRemoveConsumerDecrementsCount() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = createActiveSub(ctx1, info1); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + assertEquals(2, sub.getConsumerCount()); + + sub.removeConsumer(info2.getConsumerId()); + assertEquals(1, sub.getConsumerCount()); + } + + @Test + public void testRemoveLastConsumerLeavesEmpty() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + + sub.removeConsumer(info.getConsumerId()); + assertEquals(0, sub.getConsumerCount()); + assertFalse(sub.hasConsumers()); + } + + @Test + public void testIsFullWhenInactiveAndNoConsumers() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + + // Not activated, so isFull should delegate to isActive() check + assertTrue("Inactive durable sub should report full", sub.isFull()); + } + + @Test + public void testCountBeforeFullSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + + assertEquals(10, sub.countBeforeFull()); + } + + @Test + public void testCountBeforeFullMultipleConsumers() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = createActiveSub(ctx1, info1); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 5); + sub.addConsumer(ctx2, info2); + + assertEquals(15, sub.countBeforeFull()); + } + + @Test + public void testConsumerStateIsFullWhenDispatchedEqualsPrefetch() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 5); + SharedDurableTopicSubscription.ConsumerState cs = + new SharedDurableTopicSubscription.ConsumerState(createContext("c1"), info); + + assertFalse(cs.isFull()); + + cs.dispatched = 5; + assertTrue(cs.isFull()); + } + + @Test + public void testConsumerStateNeverFullWithZeroPrefetch() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 0); + SharedDurableTopicSubscription.ConsumerState cs = + new SharedDurableTopicSubscription.ConsumerState(createContext("c1"), info); + + cs.dispatched = 100; + assertFalse("Zero prefetch means unlimited", cs.isFull()); + } + + @Test + public void testConsumerStateCountBeforeFull() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 10); + SharedDurableTopicSubscription.ConsumerState cs = + new SharedDurableTopicSubscription.ConsumerState(createContext("c1"), info); + + assertEquals(10, cs.countBeforeFull()); + + cs.dispatched = 7; + assertEquals(3, cs.countBeforeFull()); + + cs.dispatched = 10; + assertEquals(0, cs.countBeforeFull()); + } + + @Test + public void testSelectConsumerSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + + SharedDurableTopicSubscription.ConsumerState selected = sub.selectConsumer(); + assertNotNull(selected); + assertEquals(info.getConsumerId(), selected.info.getConsumerId()); + } + + @Test + public void testSelectConsumerReturnsNullWhenEmpty() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + sub.removeConsumer(info.getConsumerId()); + + assertNull(sub.selectConsumer()); + } + + @Test + public void testSelectConsumerRoundRobin() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = createActiveSub(ctx1, info1); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + SharedDurableTopicSubscription.ConsumerState first = sub.selectConsumer(); + SharedDurableTopicSubscription.ConsumerState second = sub.selectConsumer(); + SharedDurableTopicSubscription.ConsumerState third = sub.selectConsumer(); + + assertNotEquals("Should round-robin between consumers", + first.info.getConsumerId(), second.info.getConsumerId()); + assertEquals("Should wrap around to first consumer", + first.info.getConsumerId(), third.info.getConsumerId()); + } + + @Test + public void testSelectConsumerSkipsFull() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 2); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx1, info1, false); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + // Fill consumer 1 + SharedDurableTopicSubscription.ConsumerState s1 = sub.selectConsumer(); + s1.dispatched = 2; + + // Next select should skip full consumer 1 + SharedDurableTopicSubscription.ConsumerState selected = sub.selectConsumer(); + assertNotNull(selected); + assertEquals(info2.getConsumerId(), selected.info.getConsumerId()); + } + + @Test + public void testSelectConsumerReturnsNullWhenAllFull() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 1); + SharedDurableTopicSubscription sub = createActiveSub(ctx1, info1); + + ConnectionContext ctx2 = createContext("client-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 1); + sub.addConsumer(ctx2, info2); + + // Fill both consumers + SharedDurableTopicSubscription.ConsumerState s1 = sub.selectConsumer(); + s1.dispatched = 1; + SharedDurableTopicSubscription.ConsumerState s2 = sub.selectConsumer(); + s2.dispatched = 1; + + assertNull("All full: should return null", sub.selectConsumer()); + } + + @Test + public void testRemoveNonExistentConsumerIsNoOp() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedDurableTopicSubscription sub = new SharedDurableTopicSubscription( + broker, usage, ctx, info, false); + + ConsumerId bogus = new ConsumerId(new SessionId(new ConnectionId("fake"), 1), 99); + sub.removeConsumer(bogus); + + assertEquals("Count unchanged", 1, sub.getConsumerCount()); + } +} diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicRegionTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicRegionTest.java new file mode 100644 index 00000000000..1281ad9565d --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicRegionTest.java @@ -0,0 +1,351 @@ +/** + * 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.activemq.broker.region; + +import static org.junit.Assert.*; + +import jakarta.jms.JMSException; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.command.SharedConsumerInfo; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SharedTopicRegionTest { + + private BrokerService brokerService; + + @Before + public void setUp() throws Exception { + brokerService = new SharedTopicBrokerService(); + brokerService.setPersistent(false); + brokerService.setUseJmx(false); + brokerService.setBrokerName("shared-test"); + brokerService.start(); + brokerService.waitUntilStarted(); + } + + @After + public void tearDown() throws Exception { + if (brokerService != null) { + brokerService.stop(); + brokerService.waitUntilStopped(); + } + } + + private ConnectionContext createContext(String clientId) throws Exception { + ConnectionContext ctx = new ConnectionContext(); + ctx.setClientId(clientId); + ctx.setBroker(brokerService.getBroker()); + return ctx; + } + + private SharedConsumerInfo createSharedConsumerInfo(String connId, int session, int consumer, + String subName, boolean durable) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, session); + ConsumerId consumerId = new ConsumerId(sid, consumer); + SharedConsumerInfo info = new SharedConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.shared.topic")); + info.setPrefetchSize(10); + info.setSubscriptionName(subName); + info.setShared(true); + info.setDurable(durable); + return info; + } + + private ConsumerInfo createNormalConsumerInfo(String connId, int session, int consumer) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, session); + ConsumerId consumerId = new ConsumerId(sid, consumer); + ConsumerInfo info = new ConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.normal.topic")); + info.setPrefetchSize(10); + return info; + } + + @Test + public void testSharedDurableCreatesCorrectType() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "durSub", true); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertTrue("Should create SharedDurableTopicSubscription", + sub instanceof SharedDurableTopicSubscription); + } + + @Test + public void testSharedDurableSecondConsumerJoins() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "durSub", true); + Subscription sub1 = brokerService.getBroker().addConsumer(ctx1, info1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "durSub", true); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + + assertSame("Second consumer should join same subscription", sub1, sub2); + assertEquals(2, ((SharedDurableTopicSubscription) sub1).getConsumerCount()); + } + + @Test + public void testSharedDurableRemoveOneConsumer() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "durSub", true); + Subscription sub = brokerService.getBroker().addConsumer(ctx1, info1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "durSub", true); + brokerService.getBroker().addConsumer(ctx2, info2); + + brokerService.getBroker().removeConsumer(ctx2, info2); + + SharedDurableTopicSubscription shared = (SharedDurableTopicSubscription) sub; + assertEquals("One consumer should remain", 1, shared.getConsumerCount()); + assertTrue(shared.hasConsumers()); + } + + @Test + public void testSharedDurableRemoveAllConsumersPreserves() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "durSub", true); + brokerService.getBroker().addConsumer(ctx, info); + + brokerService.getBroker().removeConsumer(ctx, info); + + // Durable subscription should still exist (deactivated, not destroyed) + // A new consumer with the same name should be able to join + ConnectionContext ctx2 = createContext("client-2"); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "durSub", true); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + assertNotNull(sub2); + assertTrue(sub2 instanceof SharedDurableTopicSubscription); + } + + @Test + public void testSharedNonDurableCreatesCorrectType() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "nonDurSub", false); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertTrue("Should create SharedTopicSubscription", + sub instanceof SharedTopicSubscription); + } + + @Test + public void testSharedNonDurableSecondConsumerJoins() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "nonDurSub", false); + Subscription sub1 = brokerService.getBroker().addConsumer(ctx1, info1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "nonDurSub", false); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + + assertSame("Second consumer should join same subscription", sub1, sub2); + assertEquals(2, ((SharedTopicSubscription) sub1).getConsumerCount()); + } + + @Test + public void testSharedNonDurableRemoveLastConsumerDestroys() throws Exception { + ConnectionContext ctx = createContext("client-1"); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "nonDurSub", false); + brokerService.getBroker().addConsumer(ctx, info); + + brokerService.getBroker().removeConsumer(ctx, info); + + // Non-durable should be destroyed — a new consumer creates a fresh subscription + ConnectionContext ctx2 = createContext("client-2"); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "nonDurSub", false); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + assertNotNull(sub2); + assertTrue(sub2 instanceof SharedTopicSubscription); + } + + @Test + public void testNormalConsumerPassesThrough() throws Exception { + ConnectionContext ctx = createContext("client-1"); + ConsumerInfo info = createNormalConsumerInfo("conn-1", 1, 1); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertFalse("Normal consumer should NOT create shared subscription", + sub instanceof SharedTopicSubscription); + assertFalse(sub instanceof SharedDurableTopicSubscription); + } + + @Test(expected = JMSException.class) + public void testSelectorMismatchOnJoinThrows() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "selSub", true); + info1.setSelector("color = 'red'"); + brokerService.getBroker().addConsumer(ctx1, info1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "selSub", true); + info2.setSelector("color = 'blue'"); + brokerService.getBroker().addConsumer(ctx2, info2); + } + + @Test + public void testMatchingSelectorAllowsJoin() throws Exception { + ConnectionContext ctx1 = createContext(null); + SharedConsumerInfo info1 = createSharedConsumerInfo("conn-1", 1, 1, "selSub", true); + info1.setSelector("color = 'red'"); + Subscription sub1 = brokerService.getBroker().addConsumer(ctx1, info1); + + ConnectionContext ctx2 = createContext(null); + SharedConsumerInfo info2 = createSharedConsumerInfo("conn-2", 1, 2, "selSub", true); + info2.setSelector("color = 'red'"); + Subscription sub2 = brokerService.getBroker().addConsumer(ctx2, info2); + + assertSame(sub1, sub2); + } + + @Test + public void testSharedDurableWithNullClientId() throws Exception { + ConnectionContext ctx = createContext(null); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "noClientSub", true); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertTrue(sub instanceof SharedDurableTopicSubscription); + } + + @Test + public void testSharedNonDurableWithNullClientId() throws Exception { + ConnectionContext ctx = createContext(null); + SharedConsumerInfo info = createSharedConsumerInfo("conn-1", 1, 1, "noClientSub", false); + + Subscription sub = brokerService.getBroker().addConsumer(ctx, info); + assertNotNull(sub); + assertTrue(sub instanceof SharedTopicSubscription); + } + + @Test(expected = JMSException.class) + public void testSharedToUnsharedConflictThrows() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + SharedConsumerInfo sharedInfo = createSharedConsumerInfo("conn-1", 1, 1, "conflictSub", true); + brokerService.getBroker().addConsumer(ctx1, sharedInfo); + + ConnectionContext ctx2 = createContext("client-1"); + ConsumerInfo unsharedInfo = createDurableConsumerInfo("conn-2", 1, 2, "conflictSub"); + brokerService.getBroker().addConsumer(ctx2, unsharedInfo); + } + + @Test(expected = JMSException.class) + public void testUnsharedToSharedConflictThrows() throws Exception { + ConnectionContext ctx1 = createContext("client-1"); + ConsumerInfo unsharedInfo = createDurableConsumerInfo("conn-1", 1, 1, "conflictSub"); + brokerService.getBroker().addConsumer(ctx1, unsharedInfo); + + ConnectionContext ctx2 = createContext("client-1"); + SharedConsumerInfo sharedInfo = createSharedConsumerInfo("conn-2", 1, 2, "conflictSub", true); + brokerService.getBroker().addConsumer(ctx2, sharedInfo); + } + + @Test + public void testConversionEnabledAllowsSharedToUnshared() throws Exception { + brokerService.stop(); + brokerService.waitUntilStopped(); + + SharedTopicBrokerService convBroker = new SharedTopicBrokerService(); + convBroker.setTopicSubscriptionConversionEnabled(true); + convBroker.setPersistent(false); + convBroker.setUseJmx(false); + convBroker.setBrokerName("conv-test-1"); + convBroker.start(); + convBroker.waitUntilStarted(); + try { + ConnectionContext ctx1 = new ConnectionContext(); + ctx1.setClientId("client-1"); + ctx1.setBroker(convBroker.getBroker()); + SharedConsumerInfo sharedInfo = createSharedConsumerInfo("conn-1", 1, 1, "convertSub", true); + convBroker.getBroker().addConsumer(ctx1, sharedInfo); + convBroker.getBroker().removeConsumer(ctx1, sharedInfo); + + ConnectionContext ctx2 = new ConnectionContext(); + ctx2.setClientId("client-1"); + ctx2.setBroker(convBroker.getBroker()); + ConsumerInfo unsharedInfo = createDurableConsumerInfo("conn-2", 1, 2, "convertSub"); + Subscription sub = convBroker.getBroker().addConsumer(ctx2, unsharedInfo); + assertNotNull(sub); + assertFalse("Should be unshared after conversion", + sub instanceof SharedDurableTopicSubscription); + } finally { + convBroker.stop(); + convBroker.waitUntilStopped(); + } + } + + @Test + public void testConversionEnabledAllowsUnsharedToShared() throws Exception { + brokerService.stop(); + brokerService.waitUntilStopped(); + + SharedTopicBrokerService convBroker = new SharedTopicBrokerService(); + convBroker.setTopicSubscriptionConversionEnabled(true); + convBroker.setPersistent(false); + convBroker.setUseJmx(false); + convBroker.setBrokerName("conv-test-2"); + convBroker.start(); + convBroker.waitUntilStarted(); + try { + ConnectionContext ctx1 = new ConnectionContext(); + ctx1.setClientId("client-1"); + ctx1.setBroker(convBroker.getBroker()); + ConsumerInfo unsharedInfo = createDurableConsumerInfo("conn-1", 1, 1, "convertSub"); + convBroker.getBroker().addConsumer(ctx1, unsharedInfo); + convBroker.getBroker().removeConsumer(ctx1, unsharedInfo); + + ConnectionContext ctx2 = new ConnectionContext(); + ctx2.setClientId("client-1"); + ctx2.setBroker(convBroker.getBroker()); + SharedConsumerInfo sharedInfo = createSharedConsumerInfo("conn-2", 1, 2, "convertSub", true); + Subscription sub = convBroker.getBroker().addConsumer(ctx2, sharedInfo); + assertNotNull(sub); + assertTrue("Should be shared after conversion", + sub instanceof SharedDurableTopicSubscription); + } finally { + convBroker.stop(); + convBroker.waitUntilStopped(); + } + } + + private ConsumerInfo createDurableConsumerInfo(String connId, int session, int consumer, + String subName) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, session); + ConsumerId consumerId = new ConsumerId(sid, consumer); + ConsumerInfo info = new ConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.shared.topic")); + info.setPrefetchSize(10); + info.setSubscriptionName(subName); + return info; + } +} diff --git a/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicSubscriptionTest.java b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicSubscriptionTest.java new file mode 100644 index 00000000000..d4d2cd7f735 --- /dev/null +++ b/activemq-broker/src/test/java/org/apache/activemq/broker/region/SharedTopicSubscriptionTest.java @@ -0,0 +1,301 @@ +/** + * 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.activemq.broker.region; + +import static org.junit.Assert.*; + +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.EmptyBroker; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.usage.SystemUsage; +import org.junit.Before; +import org.junit.Test; + +public class SharedTopicSubscriptionTest { + + private EmptyBroker broker; + private SystemUsage usage; + + @Before + public void setUp() throws Exception { + BrokerService brokerService = new BrokerService(); + brokerService.setPersistent(false); + broker = new EmptyBroker() { + @Override + public BrokerService getBrokerService() { + return brokerService; + } + }; + usage = new SystemUsage(); + } + + private ConnectionContext createContext(String connId) { + ConnectionContext ctx = new ConnectionContext(); + ctx.setClientId(connId); + return ctx; + } + + private ConsumerInfo createConsumerInfo(String connId, int sessionNum, int consumerNum, + int prefetch) { + ConnectionId cid = new ConnectionId(connId); + SessionId sid = new SessionId(cid, sessionNum); + ConsumerId consumerId = new ConsumerId(sid, consumerNum); + ConsumerInfo info = new ConsumerInfo(consumerId); + info.setDestination(new ActiveMQTopic("test.topic")); + info.setPrefetchSize(prefetch); + info.setSubscriptionName("sharedSub"); + return info; + } + + @Test + public void testConstructorAddsSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + assertEquals(1, sub.getConsumerCount()); + assertTrue(sub.hasConsumers()); + } + + @Test + public void testAddConsumerIncrementsCount() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + assertEquals(2, sub.getConsumerCount()); + } + + @Test + public void testRemoveConsumerDecrementsCount() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + assertEquals(2, sub.getConsumerCount()); + + sub.removeConsumer(info2.getConsumerId()); + assertEquals(1, sub.getConsumerCount()); + assertTrue(sub.hasConsumers()); + } + + @Test + public void testRemoveLastConsumerLeavesEmpty() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + sub.removeConsumer(info.getConsumerId()); + assertEquals(0, sub.getConsumerCount()); + assertFalse(sub.hasConsumers()); + } + + @Test + public void testIsFullWhenNoConsumers() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + sub.removeConsumer(info.getConsumerId()); + + assertTrue("Should be full when no consumers", sub.isFull()); + } + + @Test + public void testIsNotFullWithFreshConsumer() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + assertFalse("Fresh consumer should not be full", sub.isFull()); + } + + @Test + public void testCountBeforeFullSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + assertEquals(10, sub.countBeforeFull()); + } + + @Test + public void testCountBeforeFullMultipleConsumers() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 5); + sub.addConsumer(ctx2, info2); + + assertEquals(15, sub.countBeforeFull()); + } + + @Test + public void testConsumerStateIsFullWhenDispatchedEqualsPrefetch() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 5); + SharedTopicSubscription.ConsumerState cs = + new SharedTopicSubscription.ConsumerState(createContext("c1"), info); + + assertFalse(cs.isFull()); + + cs.dispatched = 5; + assertTrue(cs.isFull()); + } + + @Test + public void testConsumerStateNeverFullWithZeroPrefetch() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 0); + SharedTopicSubscription.ConsumerState cs = + new SharedTopicSubscription.ConsumerState(createContext("c1"), info); + + cs.dispatched = 100; + assertFalse("Zero prefetch means unlimited", cs.isFull()); + } + + @Test + public void testConsumerStateCountBeforeFull() { + ConsumerInfo info = createConsumerInfo("c1", 1, 1, 10); + SharedTopicSubscription.ConsumerState cs = + new SharedTopicSubscription.ConsumerState(createContext("c1"), info); + + assertEquals(10, cs.countBeforeFull()); + + cs.dispatched = 7; + assertEquals(3, cs.countBeforeFull()); + + cs.dispatched = 10; + assertEquals(0, cs.countBeforeFull()); + } + + @Test + public void testSelectConsumerSingleConsumer() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + SharedTopicSubscription.ConsumerState selected = sub.selectConsumer(); + assertNotNull(selected); + assertEquals(info.getConsumerId(), selected.info.getConsumerId()); + } + + @Test + public void testSelectConsumerReturnsNullWhenEmpty() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + sub.removeConsumer(info.getConsumerId()); + + assertNull(sub.selectConsumer()); + } + + @Test + public void testSelectConsumerRoundRobin() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + SharedTopicSubscription.ConsumerState first = sub.selectConsumer(); + SharedTopicSubscription.ConsumerState second = sub.selectConsumer(); + SharedTopicSubscription.ConsumerState third = sub.selectConsumer(); + + assertNotEquals("Should round-robin between consumers", + first.info.getConsumerId(), second.info.getConsumerId()); + assertEquals("Should wrap around to first consumer", + first.info.getConsumerId(), third.info.getConsumerId()); + } + + @Test + public void testSelectConsumerSkipsFull() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 2); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 10); + sub.addConsumer(ctx2, info2); + + // Fill consumer 1 + SharedTopicSubscription.ConsumerState s1 = sub.selectConsumer(); + s1.dispatched = 2; + + // Next select should skip full consumer 1 and return consumer 2 + SharedTopicSubscription.ConsumerState selected = sub.selectConsumer(); + assertNotNull(selected); + assertEquals(info2.getConsumerId(), selected.info.getConsumerId()); + } + + @Test + public void testSelectConsumerReturnsNullWhenAllFull() throws Exception { + ConnectionContext ctx1 = createContext("conn-1"); + ConsumerInfo info1 = createConsumerInfo("conn-1", 1, 1, 1); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx1, info1); + + ConnectionContext ctx2 = createContext("conn-2"); + ConsumerInfo info2 = createConsumerInfo("conn-2", 1, 2, 1); + sub.addConsumer(ctx2, info2); + + // Fill both consumers + SharedTopicSubscription.ConsumerState s1 = sub.selectConsumer(); + s1.dispatched = 1; + SharedTopicSubscription.ConsumerState s2 = sub.selectConsumer(); + s2.dispatched = 1; + + assertNull("All full: should return null", sub.selectConsumer()); + } + + @Test + public void testRemoveNonExistentConsumerIsNoOp() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + ConsumerId bogus = new ConsumerId(new SessionId(new ConnectionId("fake"), 1), 99); + sub.removeConsumer(bogus); + + assertEquals("Count unchanged", 1, sub.getConsumerCount()); + } + + @Test + public void testDestroyClears() throws Exception { + ConnectionContext ctx = createContext("conn-1"); + ConsumerInfo info = createConsumerInfo("conn-1", 1, 1, 10); + SharedTopicSubscription sub = new SharedTopicSubscription(broker, usage, ctx, info); + + sub.addConsumer(createContext("conn-2"), createConsumerInfo("conn-2", 1, 2, 10)); + assertEquals(2, sub.getConsumerCount()); + + sub.destroy(); + assertEquals(0, sub.getConsumerCount()); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnection.java b/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnection.java new file mode 100644 index 00000000000..e5efeaf5de1 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnection.java @@ -0,0 +1,61 @@ +/** + * 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.activemq; + +import jakarta.jms.JMSException; +import jakarta.jms.Session; + +import org.apache.activemq.ActiveMQConnection; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.management.JMSStatsImpl; +import org.apache.activemq.transport.Transport; +import org.apache.activemq.util.IdGenerator; + +/** + * Connection extension that creates {@link SharedTopicSession} instances + * instead of plain {@link ActiveMQSession}, enabling JMS 3.1 shared + * topic subscription support. + */ +public class SharedTopicConnection extends ActiveMQConnection { + + protected SharedTopicConnection(Transport transport, IdGenerator clientIdGenerator, + IdGenerator connectionIdGenerator, JMSStatsImpl factoryStats) throws Exception { + super(transport, clientIdGenerator, connectionIdGenerator, factoryStats); + } + + @Override + public Session createSession(boolean transacted, int acknowledgeMode) throws JMSException { + checkClosedOrFailed(); + ensureConnectionInfoSent(); + if (!transacted) { + if (acknowledgeMode == Session.SESSION_TRANSACTED) { + throw new JMSException( + "acknowledgeMode SESSION_TRANSACTED cannot be used for an non-transacted Session"); + } else if (acknowledgeMode < Session.SESSION_TRANSACTED + || acknowledgeMode > ActiveMQSession.MAX_ACK_CONSTANT) { + throw new JMSException("invalid acknowledgeMode: " + acknowledgeMode + + ". Valid values are Session.AUTO_ACKNOWLEDGE (1), " + + "Session.CLIENT_ACKNOWLEDGE (2), Session.DUPS_OK_ACKNOWLEDGE (3), " + + "ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE (4) or for transacted sessions " + + "Session.SESSION_TRANSACTED (0)"); + } + } + return new SharedTopicSession(this, getNextSessionId(), + transacted ? Session.SESSION_TRANSACTED : acknowledgeMode, + isDispatchAsync(), isAlwaysSessionAsync()); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnectionFactory.java b/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnectionFactory.java new file mode 100644 index 00000000000..4dc78f5f68f --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnectionFactory.java @@ -0,0 +1,66 @@ +/** + * 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.activemq; + +import java.net.URI; + +import org.apache.activemq.ActiveMQConnection; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.annotation.Experimental; +import org.apache.activemq.management.JMSStatsImpl; +import org.apache.activemq.transport.Transport; + +/** + * Connection factory that creates {@link SharedTopicConnection} instances, + * enabling JMS 3.1 shared topic subscription support throughout the + * connection → session → consumer chain. + * + *

Requires a broker running {@code SharedTopicBrokerService}; shared + * subscriptions negotiate OpenWire v13, which a default broker does not enable. + */ +@Experimental("Tech Preview for JMS 3.1 shared topic subscriptions") +public class SharedTopicConnectionFactory extends ActiveMQConnectionFactory { + + public SharedTopicConnectionFactory() { + super(); + } + + public SharedTopicConnectionFactory(String brokerURL) { + super(brokerURL); + } + + public SharedTopicConnectionFactory(URI brokerURL) { + super(brokerURL); + } + + public SharedTopicConnectionFactory(String userName, String password, URI brokerURL) { + super(userName, password, brokerURL); + } + + public SharedTopicConnectionFactory(String userName, String password, String brokerURL) { + super(userName, password, brokerURL); + } + + @Override + protected ActiveMQConnection createActiveMQConnection(Transport transport, + JMSStatsImpl stats) throws Exception { + SharedTopicConnection connection = new SharedTopicConnection(transport, + getClientIdGenerator(), getConnectionIdGenerator(), stats); + connection.setStrictCompliance(isStrictCompliance()); + return connection; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/SharedTopicSession.java b/activemq-client/src/main/java/org/apache/activemq/SharedTopicSession.java new file mode 100644 index 00000000000..56b408ebbb6 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/SharedTopicSession.java @@ -0,0 +1,137 @@ +/** + * 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.activemq; + +import jakarta.jms.InvalidDestinationException; +import jakarta.jms.JMSException; +import jakarta.jms.MessageConsumer; +import jakarta.jms.Topic; + +import org.apache.activemq.ActiveMQConnection; +import org.apache.activemq.ActiveMQMessageConsumer; +import org.apache.activemq.ActiveMQMessageTransformation; +import org.apache.activemq.ActiveMQPrefetchPolicy; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.Command; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.Response; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.command.SharedConsumerInfo; + +/** + * Session extension that implements JMS 3.1 shared topic subscriptions. + * + *

Overrides the four {@code createSharedConsumer} / {@code createSharedDurableConsumer} + * methods (which upstream throws {@code UnsupportedOperationException} for) and + * intercepts {@code syncSendPacket} to swap the {@link ConsumerInfo} command with + * a {@link SharedConsumerInfo} before it reaches the broker. + */ +public class SharedTopicSession extends ActiveMQSession { + + private Boolean pendingSharedDurable; + + protected SharedTopicSession(ActiveMQConnection connection, SessionId sessionId, + int acknowledgeMode, boolean asyncDispatch, boolean sessionAsyncDispatch) + throws JMSException { + super(connection, sessionId, acknowledgeMode, asyncDispatch, sessionAsyncDispatch); + } + + @Override + public MessageConsumer createSharedConsumer(Topic topic, String sharedSubscriptionName) + throws JMSException { + return createSharedConsumer(topic, sharedSubscriptionName, null); + } + + @Override + public MessageConsumer createSharedConsumer(Topic topic, String sharedSubscriptionName, + String messageSelector) throws JMSException { + checkClosed(); + validateSharedArgs(topic, sharedSubscriptionName); + return createSharedMessageConsumer(topic, sharedSubscriptionName, messageSelector, false); + } + + @Override + public MessageConsumer createSharedDurableConsumer(Topic topic, String name) + throws JMSException { + return createSharedDurableConsumer(topic, name, null); + } + + @Override + public MessageConsumer createSharedDurableConsumer(Topic topic, String name, + String messageSelector) throws JMSException { + checkClosed(); + validateSharedArgs(topic, name); + return createSharedMessageConsumer(topic, name, messageSelector, true); + } + + private void validateSharedArgs(Topic topic, String subscriptionName) + throws JMSException { + if (topic == null) { + throw new InvalidDestinationException("Topic cannot be null", + ActiveMQErrorCode.INVALID_DESTINATION); + } + if (subscriptionName == null || subscriptionName.isEmpty()) { + throw new JMSException("Shared subscription name cannot be null or empty", + ActiveMQErrorCode.INVALID_SUBSCRIPTION_NAME); + } + } + + private MessageConsumer createSharedMessageConsumer(Topic topic, String name, + String messageSelector, boolean durable) throws JMSException { + ActiveMQPrefetchPolicy prefetchPolicy = connection.getPrefetchPolicy(); + int prefetch; + if (durable) { + prefetch = isAutoAcknowledge() && connection.isOptimizedMessageDispatch() + ? prefetchPolicy.getOptimizeDurableTopicPrefetch() + : prefetchPolicy.getDurableTopicPrefetch(); + } else { + prefetch = prefetchPolicy.getTopicPrefetch(); + } + int maxPendingLimit = prefetchPolicy.getMaximumPendingMessageLimit(); + ActiveMQDestination dest = ActiveMQMessageTransformation.transformDestination(topic); + + pendingSharedDurable = durable; + try { + return new ActiveMQMessageConsumer(this, getNextConsumerId(), dest, name, + messageSelector, prefetch, maxPendingLimit, false, false, + isAsyncDispatch(), null); + } finally { + pendingSharedDurable = null; + } + } + + @Override + public Response syncSendPacket(Command command) throws JMSException { + if (pendingSharedDurable != null && command instanceof ConsumerInfo + && !(command instanceof SharedConsumerInfo)) { + SharedConsumerInfo shared = toSharedConsumerInfo( + (ConsumerInfo) command, pendingSharedDurable); + shared.setUserSpecifiedClientId(connection.isUserSpecifiedClientID()); + return super.syncSendPacket(shared); + } + return super.syncSendPacket(command); + } + + static SharedConsumerInfo toSharedConsumerInfo(ConsumerInfo original, boolean durable) { + SharedConsumerInfo shared = new SharedConsumerInfo(); + original.copy(shared); + shared.setShared(true); + shared.setDurable(durable); + return shared; + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionFactoryTest.java b/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionFactoryTest.java new file mode 100644 index 00000000000..3cd34956023 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionFactoryTest.java @@ -0,0 +1,95 @@ +/** + * 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.activemq; + +import static org.junit.Assert.*; + +import java.net.URI; + +import jakarta.jms.Connection; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.transport.Transport; +import org.junit.Test; + +public class SharedTopicConnectionFactoryTest { + + @Test + public void testIsInstanceOfActiveMQConnectionFactory() { + SharedTopicConnectionFactory factory = new SharedTopicConnectionFactory(); + assertTrue(factory instanceof ActiveMQConnectionFactory); + } + + @Test + public void testStringConstructor() { + SharedTopicConnectionFactory factory = + new SharedTopicConnectionFactory("tcp://localhost:61616"); + assertEquals("tcp://localhost:61616", factory.getBrokerURL()); + } + + @Test + public void testUriConstructor() throws Exception { + SharedTopicConnectionFactory factory = + new SharedTopicConnectionFactory(new URI("tcp://localhost:61616")); + assertEquals("tcp://localhost:61616", factory.getBrokerURL()); + } + + @Test + public void testCredentialConstructors() throws Exception { + SharedTopicConnectionFactory f1 = + new SharedTopicConnectionFactory("admin", "secret", + new URI("tcp://localhost:61616")); + assertEquals("admin", f1.getUserName()); + + SharedTopicConnectionFactory f2 = + new SharedTopicConnectionFactory("admin", "secret", + "tcp://localhost:61616"); + assertEquals("admin", f2.getUserName()); + } + + @Test + public void testCreatesSharedTopicConnection() throws Exception { + SharedTopicConnectionFactory factory = createStubFactory(); + Connection conn = factory.createConnection(); + try { + assertTrue("Factory should create SharedTopicConnection", + conn instanceof SharedTopicConnection); + } finally { + conn.close(); + } + } + + @Test + public void testCreatesSharedTopicConnectionWithCredentials() throws Exception { + SharedTopicConnectionFactory factory = createStubFactory(); + Connection conn = factory.createConnection("user", "pass"); + try { + assertTrue(conn instanceof SharedTopicConnection); + } finally { + conn.close(); + } + } + + static SharedTopicConnectionFactory createStubFactory() { + return new SharedTopicConnectionFactory("tcp://localhost:61616") { + @Override + protected Transport createTransport() { + return new StubTransport(); + } + }; + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionTest.java b/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionTest.java new file mode 100644 index 00000000000..0d6d25a96fc --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/SharedTopicConnectionTest.java @@ -0,0 +1,83 @@ +/** + * 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.activemq; + +import static org.junit.Assert.*; + +import jakarta.jms.Connection; +import jakarta.jms.Session; + +import org.apache.activemq.ActiveMQConnection; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SharedTopicConnectionTest { + + private Connection connection; + + @Before + public void setUp() throws Exception { + SharedTopicConnectionFactory factory = + SharedTopicConnectionFactoryTest.createStubFactory(); + connection = factory.createConnection(); + } + + @After + public void tearDown() throws Exception { + if (connection != null) { + connection.close(); + } + } + + @Test + public void testIsInstanceOfActiveMQConnection() { + assertTrue(connection instanceof ActiveMQConnection); + assertTrue(connection instanceof SharedTopicConnection); + } + + @Test + public void testCreateSessionReturnsSharedTopicSession() throws Exception { + Session session = connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + assertTrue("createSession should return SharedTopicSession", + session instanceof SharedTopicSession); + } + + @Test + public void testCreateTransactedSession() throws Exception { + Session session = connection.createSession(true, Session.SESSION_TRANSACTED); + assertTrue(session instanceof SharedTopicSession); + } + + @Test + public void testCreateSessionNoArgs() throws Exception { + Session session = connection.createSession(); + assertTrue("No-arg createSession should also return SharedTopicSession", + session instanceof SharedTopicSession); + } + + @Test + public void testCreateSessionSingleArg() throws Exception { + Session session = connection.createSession(Session.CLIENT_ACKNOWLEDGE); + assertTrue(session instanceof SharedTopicSession); + } + + @Test(expected = jakarta.jms.JMSException.class) + public void testInvalidAcknowledgeMode() throws Exception { + connection.createSession(false, Session.SESSION_TRANSACTED); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/SharedTopicSessionTest.java b/activemq-client/src/test/java/org/apache/activemq/SharedTopicSessionTest.java new file mode 100644 index 00000000000..ff0c1e2614f --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/SharedTopicSessionTest.java @@ -0,0 +1,196 @@ +/** + * 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.activemq; + +import static org.junit.Assert.*; + +import java.util.Queue; + +import jakarta.jms.Connection; +import jakarta.jms.InvalidDestinationException; +import jakarta.jms.JMSException; +import jakarta.jms.MessageConsumer; +import jakarta.jms.Session; + +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SharedTopicSessionTest { + + private Connection connection; + private SharedTopicSession session; + private StubTransport transport; + + @Before + public void setUp() throws Exception { + transport = new StubTransport(); + SharedTopicConnectionFactory factory = + new SharedTopicConnectionFactory("tcp://localhost:61616") { + @Override + protected org.apache.activemq.transport.Transport createTransport() { + return transport; + } + }; + connection = factory.createConnection(); + session = (SharedTopicSession) connection.createSession(false, Session.AUTO_ACKNOWLEDGE); + } + + @After + public void tearDown() throws Exception { + if (connection != null) { + connection.close(); + } + } + + @Test + public void testIsInstanceOfActiveMQSession() { + assertTrue(session instanceof ActiveMQSession); + } + + @Test + public void testCreateSharedConsumer() throws Exception { + ActiveMQTopic topic = new ActiveMQTopic("test.topic"); + MessageConsumer consumer = session.createSharedConsumer(topic, "mySub"); + assertNotNull(consumer); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull("Should have sent SharedConsumerInfo to broker", sent); + assertTrue(sent.isShared()); + assertFalse("Non-durable shared consumer", sent.isDurable()); + assertEquals("mySub", sent.getSubscriptionName()); + } + + @Test + public void testCreateSharedConsumerWithSelector() throws Exception { + ActiveMQTopic topic = new ActiveMQTopic("test.topic"); + MessageConsumer consumer = session.createSharedConsumer(topic, "mySub", "color = 'blue'"); + assertNotNull(consumer); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertTrue(sent.isShared()); + assertFalse(sent.isDurable()); + assertEquals("color = 'blue'", sent.getSelector()); + } + + @Test(expected = InvalidDestinationException.class) + public void testCreateSharedConsumerNullTopic() throws Exception { + session.createSharedConsumer(null, "mySub"); + } + + @Test(expected = JMSException.class) + public void testCreateSharedConsumerNullName() throws Exception { + session.createSharedConsumer(new ActiveMQTopic("t"), null); + } + + @Test(expected = JMSException.class) + public void testCreateSharedConsumerEmptyName() throws Exception { + session.createSharedConsumer(new ActiveMQTopic("t"), ""); + } + + @Test + public void testCreateSharedDurableConsumer() throws Exception { + ActiveMQTopic topic = new ActiveMQTopic("test.topic"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "durSub"); + assertNotNull(consumer); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull("Should have sent SharedConsumerInfo to broker", sent); + assertTrue(sent.isShared()); + assertTrue("Should be durable", sent.isDurable()); + assertEquals("durSub", sent.getSubscriptionName()); + } + + @Test + public void testCreateSharedDurableConsumerWithSelector() throws Exception { + ActiveMQTopic topic = new ActiveMQTopic("test.topic"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "durSub", "price > 10"); + assertNotNull(consumer); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertTrue(sent.isShared()); + assertTrue(sent.isDurable()); + assertEquals("price > 10", sent.getSelector()); + } + + @Test(expected = InvalidDestinationException.class) + public void testCreateSharedDurableConsumerNullTopic() throws Exception { + session.createSharedDurableConsumer(null, "durSub"); + } + + @Test(expected = JMSException.class) + public void testCreateSharedDurableConsumerNullName() throws Exception { + session.createSharedDurableConsumer(new ActiveMQTopic("t"), null); + } + + @Test + public void testNonSharedConsumerInfoPassedThrough() throws Exception { + ActiveMQTopic topic = new ActiveMQTopic("test.topic"); + session.createConsumer(topic); + + SharedConsumerInfo shared = findSharedConsumerInfo(); + assertNull("Regular createConsumer should NOT produce SharedConsumerInfo", shared); + + ConsumerInfo regular = findConsumerInfo(); + assertNotNull("Regular ConsumerInfo should be sent", regular); + } + + @Test + public void testToSharedConsumerInfoCopiesFields() { + ConsumerInfo original = new ConsumerInfo(); + original.setSubscriptionName("sub1"); + original.setPrefetchSize(50); + original.setSelector("x = 1"); + + SharedConsumerInfo result = SharedTopicSession.toSharedConsumerInfo(original, true); + assertTrue(result.isShared()); + assertTrue(result.isDurable()); + assertEquals("sub1", result.getSubscriptionName()); + assertEquals(50, result.getPrefetchSize()); + assertEquals("x = 1", result.getSelector()); + } + + @Test + public void testToSharedConsumerInfoNonDurable() { + ConsumerInfo original = new ConsumerInfo(); + SharedConsumerInfo result = SharedTopicSession.toSharedConsumerInfo(original, false); + assertTrue(result.isShared()); + assertFalse(result.isDurable()); + } + + private SharedConsumerInfo findSharedConsumerInfo() { + for (Object cmd : transport.getSent()) { + if (cmd instanceof SharedConsumerInfo) { + return (SharedConsumerInfo) cmd; + } + } + return null; + } + + private ConsumerInfo findConsumerInfo() { + for (Object cmd : transport.getSent()) { + if (cmd instanceof ConsumerInfo && !(cmd instanceof SharedConsumerInfo)) { + return (ConsumerInfo) cmd; + } + } + return null; + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/StubTransport.java b/activemq-client/src/test/java/org/apache/activemq/StubTransport.java new file mode 100644 index 00000000000..1e41e5f9d0f --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/StubTransport.java @@ -0,0 +1,92 @@ +/** + * 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.activemq; + +import java.io.IOException; +import java.security.cert.X509Certificate; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.apache.activemq.command.Response; +import org.apache.activemq.transport.TransportSupport; +import org.apache.activemq.util.ServiceStopper; +import org.apache.activemq.wireformat.WireFormat; + +/** + * Minimal transport stub for unit tests. Records all commands sent via + * {@code oneway} and {@code request}, returning a valid {@link Response} + * for synchronous requests so that consumer registration succeeds. + */ +class StubTransport extends TransportSupport { + + private final Queue sent = new ConcurrentLinkedQueue<>(); + private int receiveCounter; + + @Override + public void oneway(Object command) throws IOException { + receiveCounter++; + sent.add(command); + } + + @Override + public Object request(Object command) throws IOException { + receiveCounter++; + sent.add(command); + return new Response(); + } + + @Override + public Object request(Object command, int timeout) throws IOException { + return request(command); + } + + Queue getSent() { + return sent; + } + + @Override + public String getRemoteAddress() { + return "stub://localhost"; + } + + @Override + public int getReceiveCounter() { + return receiveCounter; + } + + @Override + public X509Certificate[] getPeerCertificates() { + return null; + } + + @Override + public void setPeerCertificates(X509Certificate[] certificates) { + } + + @Override + public WireFormat getWireFormat() { + return null; + } + + @Override + protected void doStart() throws Exception { + } + + @Override + protected void doStop(ServiceStopper stopper) throws Exception { + } +} diff --git a/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java b/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java new file mode 100644 index 00000000000..6a6cdd0a829 --- /dev/null +++ b/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java @@ -0,0 +1,113 @@ +/* + * 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.activemq.xbean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.apache.activemq.broker.BrokerFactory; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.junit.After; +import org.junit.Test; + +/** + * Shows how to wire a {@link SharedTopicBrokerService} from Spring XML via the + * {@code } XBean element, and verifies the element + * actually produces a shared-topic-capable broker rather than a plain one. + * + * @see "src/test/resources/spring/shared-topic-broker.xml" + */ +public class SharedTopicBrokerServiceXBeanTest { + + private BrokerService broker; + + @After + public void tearDown() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + broker = null; + } + } + + @Test + public void testXBeanElementCreatesSharedTopicBrokerService() throws Exception { + broker = createBroker(); + + assertTrue(" must yield a SharedTopicBrokerService, not a " + + "plain BrokerService; got " + broker.getClass().getName(), + broker instanceof SharedTopicBrokerService); + assertEquals("sharedTopicXBeanBroker", broker.getBrokerName()); + } + + /** + * The whole point of the element: without v13 the shared flag is lost on + * restart, so a broker wired this way must not be left at the default + * store version. + */ + @Test + public void testStoreOpenWireVersionIsThirteen() throws Exception { + broker = createBroker(); + + assertEquals("shared subs require OpenWire v13 for KahaDB persistence", + 13, broker.getStoreOpenWireVersion()); + assertTrue("v13 must be an upgrade over the plain broker default", + 13 > new BrokerService().getStoreOpenWireVersion()); + } + + @Test + public void testXmlAttributeIsAppliedToTheBroker() throws Exception { + broker = createBroker(); + + assertTrue("topicSubscriptionConversionEnabled=\"true\" in the XML must reach the bean", + ((SharedTopicBrokerService) broker).isTopicSubscriptionConversionEnabled()); + } + + /** + * Attributes inherited from {@code } must still work on the + * subclass element, since XBean maps setters from the whole hierarchy. + */ + @Test + public void testInheritedBrokerAttributesStillApply() throws Exception { + broker = createBroker(); + + assertEquals(false, broker.isUseJmx()); + assertEquals(false, broker.isPersistent()); + assertNotNull("transportConnector from the XML should be present", + broker.getTransportConnectorByScheme("tcp")); + } + + @Test + public void testSharedTopicRegionIsInstalled() throws Exception { + broker = createBroker(); + broker.start(); + broker.waitUntilStarted(); + + RegionBroker regionBroker = (RegionBroker) broker.getRegionBroker(); + assertTrue("broker must install SharedTopicRegion as its topic region; got " + + regionBroker.getTopicRegion().getClass().getName(), + regionBroker.getTopicRegion() instanceof SharedTopicRegion); + } + + private BrokerService createBroker() throws Exception { + return BrokerFactory.createBroker("xbean:spring/shared-topic-broker.xml"); + } +} diff --git a/activemq-spring/src/test/resources/spring/shared-topic-broker.xml b/activemq-spring/src/test/resources/spring/shared-topic-broker.xml new file mode 100644 index 00000000000..fda20139ca2 --- /dev/null +++ b/activemq-spring/src/test/resources/spring/shared-topic-broker.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java new file mode 100644 index 00000000000..907eb50dc71 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java @@ -0,0 +1,793 @@ +/** + * 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.activemq.usecases; + +import static org.junit.Assert.*; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.jms.Connection; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageListener; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import jakarta.jms.Topic; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; +import org.apache.activemq.broker.jmx.BrokerView; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.activemq.test.annotations.ParallelTest; + +/** + * End-to-end JMS integration tests for shared topic subscriptions. + * Uses {@link SharedTopicConnectionFactory} (client) and + * {@link SharedTopicBrokerService} (broker) to exercise the full stack. + */ +@Category(ParallelTest.class) +public class SharedSubscriptionJmsTest { + + private static final int WAIT_TIMEOUT = 10_000; + private static final int WAIT_INTERVAL = 10; + + private SharedTopicBrokerService broker; + private SharedTopicConnectionFactory factory; + private Connection connection1; + private Connection connection2; + + @Before + public void setUp() throws Exception { + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-test"); + broker.addConnector("vm://shared-jms-test"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-test"); + } + + @After + public void tearDown() throws Exception { + closeQuietly(connection1); + closeQuietly(connection2); + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + } + } + + @Test + public void testSharedDurableCreateAndReceive() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-1"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.durable"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "durSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Durable subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("hello-durable")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message", msg); + assertEquals("hello-durable", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableMultipleConsumersRoundRobin() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.roundrobin"); + + int messageCount = 20; + CountDownLatch latch = new CountDownLatch(messageCount); + ConcurrentLinkedQueue consumer1Msgs = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue consumer2Msgs = new ConcurrentLinkedQueue<>(); + + MessageConsumer c1 = session1.createSharedDurableConsumer(topic, "rrSub"); + c1.setMessageListener(msg -> { + try { + consumer1Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + MessageConsumer c2 = session2.createSharedDurableConsumer(topic, "rrSub"); + c2.setMessageListener(msg -> { + try { + consumer2Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Durable subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session producerSession = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageProducer producer = producerSession.createProducer(topic); + for (int i = 0; i < messageCount; i++) { + producer.send(producerSession.createTextMessage("msg-" + i)); + } + + assertTrue("All messages should be received", + latch.await(WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); + + assertEquals("Total messages should match", + messageCount, consumer1Msgs.size() + consumer2Msgs.size()); + assertFalse("Consumer 1 should receive some messages", consumer1Msgs.isEmpty()); + assertFalse("Consumer 2 should receive some messages", consumer2Msgs.isEmpty()); + + Set allReceived = new HashSet<>(consumer1Msgs); + allReceived.addAll(consumer2Msgs); + assertEquals("No duplicates — each message to exactly one consumer", + messageCount, allReceived.size()); + } + + @Test + public void testSharedDurableReconnectReceivesBuffered() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.reconnect"); + MessageConsumer consumer = session1.createSharedDurableConsumer(topic, "reconnSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length == 0 + && adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session1.createProducer(topic); + producer.send(session1.createTextMessage("buffered-msg")); + + connection2 = factory.createConnection(); + connection2.start(); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.reconnect"); + MessageConsumer consumer2 = session2.createSharedDurableConsumer(topic2, "reconnSub"); + + assertTrue("Subscriber should reactivate", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Message msg = consumer2.receive(WAIT_TIMEOUT); + assertNotNull("Should receive buffered message after reconnect", msg); + assertEquals("buffered-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableWithSelector() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-sel"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.selector"); + MessageConsumer consumer = session.createSharedDurableConsumer( + topic, "selSub", "color = 'red'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + + TextMessage red = session.createTextMessage("red-msg"); + red.setStringProperty("color", "red"); + producer.send(red); + + TextMessage blue = session.createTextMessage("blue-msg"); + blue.setStringProperty("color", "blue"); + producer.send(blue); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive red message", msg); + assertEquals("red-msg", ((TextMessage) msg).getText()); + + Message noMsg = consumer.receiveNoWait(); + assertNull("Should NOT receive blue message", noMsg); + } + + @Test + public void testSharedNonDurableCreateAndReceive() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable"); + MessageConsumer consumer = session.createSharedConsumer(topic, "nonDurSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("hello-nondurable")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message", msg); + assertEquals("hello-nondurable", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedNonDurableCleanupOnDisconnect() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + BrokerView adminView = broker.getAdminView(); + final int baselineCount = adminView.getTopicSubscribers().length; + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.cleanup"); + MessageConsumer consumer = session.createSharedConsumer(topic, "cleanupSub"); + + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length > baselineCount, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Topic subscriber should be removed after close", + Wait.waitFor(() -> adminView.getTopicSubscribers().length <= baselineCount, + WAIT_TIMEOUT, WAIT_INTERVAL)); + } + + @Test + public void testSharedNonDurableMultipleConsumersRoundRobin() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.nondurable.rr"); + + int messageCount = 20; + CountDownLatch latch = new CountDownLatch(messageCount); + ConcurrentLinkedQueue consumer1Msgs = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue consumer2Msgs = new ConcurrentLinkedQueue<>(); + + MessageConsumer c1 = session1.createSharedConsumer(topic, "ndRRSub"); + c1.setMessageListener(msg -> { + try { + consumer1Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + MessageConsumer c2 = session2.createSharedConsumer(topic, "ndRRSub"); + c2.setMessageListener(msg -> { + try { + consumer2Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session producerSession = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageProducer producer = producerSession.createProducer(topic); + for (int i = 0; i < messageCount; i++) { + producer.send(producerSession.createTextMessage("nd-msg-" + i)); + } + + assertTrue("All messages should be received", + latch.await(WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); + + assertEquals("Total messages should match", + messageCount, consumer1Msgs.size() + consumer2Msgs.size()); + assertFalse("Consumer 1 should receive some messages", consumer1Msgs.isEmpty()); + assertFalse("Consumer 2 should receive some messages", consumer2Msgs.isEmpty()); + + Set allReceived = new HashSet<>(consumer1Msgs); + allReceived.addAll(consumer2Msgs); + assertEquals("No duplicates — each message to exactly one consumer", + messageCount, allReceived.size()); + } + + @Test + public void testSharedNonDurableWithSelector() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable.sel"); + MessageConsumer consumer = session.createSharedConsumer( + topic, "ndSelSub", "color = 'green'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + + TextMessage green = session.createTextMessage("green-msg"); + green.setStringProperty("color", "green"); + producer.send(green); + + TextMessage yellow = session.createTextMessage("yellow-msg"); + yellow.setStringProperty("color", "yellow"); + producer.send(yellow); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive green message", msg); + assertEquals("green-msg", ((TextMessage) msg).getText()); + + Message noMsg = consumer.receiveNoWait(); + assertNull("Should NOT receive yellow message", noMsg); + } + + @Test + public void testSharedNonDurableConsumerLeaveRebalance() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.nondurable.rebalance"); + + MessageConsumer c1 = session1.createSharedConsumer(topic, "rebalSub"); + MessageConsumer c2 = session2.createSharedConsumer(topic, "rebalSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + c2.close(); + + MessageProducer producer = session1.createProducer(topic); + for (int i = 0; i < 5; i++) { + producer.send(session1.createTextMessage("solo-" + i)); + } + + for (int i = 0; i < 5; i++) { + Message msg = c1.receive(WAIT_TIMEOUT); + assertNotNull("Remaining consumer should receive all messages, msg " + i, msg); + assertEquals("solo-" + i, ((TextMessage) msg).getText()); + } + } + + @Test(expected = JMSException.class) + public void testSelectorMismatchOnJoinThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.selmismatch"); + session1.createSharedDurableConsumer(topic, "selMisSub", "color = 'red'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("First subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.selmismatch"); + session2.createSharedDurableConsumer(topic2, "selMisSub", "color = 'blue'"); + } + + @Test(expected = JMSException.class) + public void testSharedToUnsharedTypeConflictThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-conflict"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conflict"); + session.createSharedDurableConsumer(topic, "conflictSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Shared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.createDurableSubscriber(topic, "conflictSub"); + } + + @Test(expected = JMSException.class) + public void testUnsharedToSharedTypeConflictThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-conflict2"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conflict2"); + session.createDurableSubscriber(topic, "conflictSub2"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Unshared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.createSharedDurableConsumer(topic, "conflictSub2"); + } + + @Test + public void testSharedDurableWithoutClientId() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nocid"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "noCidSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register without clientId", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("no-cid-msg")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message without clientId", msg); + assertEquals("no-cid-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableUnsubscribeWhenInactive() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.unsub"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "unsubSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length == 0 + && adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.unsubscribe("unsubSub"); + assertTrue("Inactive subscriber should be removed after unsubscribe", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length == 0, + WAIT_TIMEOUT, WAIT_INTERVAL)); + } + + @Test(expected = JMSException.class) + public void testSharedDurableUnsubscribeWithActiveConsumerThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.unsub.active"); + session.createSharedDurableConsumer(topic, "unsubActiveSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.unsubscribe("unsubActiveSub"); + } + + @Test + public void testSharedDurableClientAcknowledge() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.CLIENT_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.clientack"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "clientAckSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("ack-me")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message with CLIENT_ACKNOWLEDGE", msg); + assertEquals("ack-me", ((TextMessage) msg).getText()); + msg.acknowledge(); + } + + @Test + public void testSharedDurableDupsOkAcknowledge() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.DUPS_OK_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.dupsok"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "dupsOkSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("dup-ok-msg")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message with DUPS_OK_ACKNOWLEDGE", msg); + assertEquals("dup-ok-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testConversionUnsharedToSharedWhenEnabled() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-conv1"); + broker.setTopicSubscriptionConversionEnabled(true); + broker.addConnector("vm://shared-jms-conv1"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-conv1"); + + connection1 = factory.createConnection(); + connection1.setClientID("client-conv1"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conv1"); + + MessageConsumer unshared = session.createDurableSubscriber(topic, "convSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Unshared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + unshared.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageConsumer shared = session.createSharedDurableConsumer(topic, "convSub1"); + + assertTrue("Shared subscriber should register after conversion", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("converted-msg")); + + Message msg = shared.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message after unshared-to-shared conversion", msg); + assertEquals("converted-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testConversionSharedToUnsharedWhenEnabled() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-conv2"); + broker.setTopicSubscriptionConversionEnabled(true); + broker.addConnector("vm://shared-jms-conv2"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-conv2"); + + connection1 = factory.createConnection(); + connection1.setClientID("client-conv2"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conv2"); + + MessageConsumer shared = session.createSharedDurableConsumer(topic, "convSub2"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Shared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + shared.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageConsumer unshared = session.createDurableSubscriber(topic, "convSub2"); + + assertTrue("Unshared subscriber should register after conversion", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("converted-msg-2")); + + Message msg = unshared.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message after shared-to-unshared conversion", msg); + assertEquals("converted-msg-2", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurablePersistsAcrossRestart() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + String brokerName = "shared-persist-test"; + broker = createPersistentBroker(brokerName, true); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.persist"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "persistSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("before-restart")); + Message msg1 = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message before restart", msg1); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + producer.send(session.createTextMessage("buffered-across-restart")); + + connection1.close(); + connection1 = null; + broker.stop(); + broker.waitUntilStopped(); + + // Restart broker — preserve data + broker = createPersistentBroker(brokerName, false); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + BrokerView adminView2 = broker.getAdminView(); + assertTrue("Subscription should be restored as inactive after restart", + Wait.waitFor(() -> adminView2.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + connection1 = factory.createConnection(); + connection1.start(); + Session session2 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.persist"); + MessageConsumer consumer2 = session2.createSharedDurableConsumer(topic2, "persistSub"); + + assertTrue("Subscriber should reactivate after restart", + Wait.waitFor(() -> adminView2.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Message buffered = consumer2.receive(WAIT_TIMEOUT); + assertNotNull("Should receive buffered message after restart", buffered); + assertEquals("buffered-across-restart", ((TextMessage) buffered).getText()); + } + + @Test + public void testSharedNonDurableDoesNotPersistAcrossRestart() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + String brokerName = "shared-nondurable-persist"; + broker = createPersistentBroker(brokerName, true); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable.persist"); + MessageConsumer consumer = session.createSharedConsumer(topic, "nonDurPersistSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("ephemeral-msg")); + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message before restart", msg); + + connection1.close(); + connection1 = null; + broker.stop(); + broker.waitUntilStopped(); + + // Restart broker — preserve data + broker = createPersistentBroker(brokerName, false); + + BrokerView adminView = broker.getAdminView(); + assertEquals("No durable subscriptions should exist after restart", + 0, adminView.getDurableTopicSubscribers().length); + assertEquals("No inactive durable subscriptions should exist after restart", + 0, adminView.getInactiveDurableTopicSubscribers().length); + } + + private SharedTopicBrokerService createPersistentBroker(String name, boolean deleteOnStartup) + throws Exception { + SharedTopicBrokerService b = new SharedTopicBrokerService(); + b.setPersistent(true); + b.setUseJmx(true); + b.setBrokerName(name); + b.setDataDirectory("target/test-data/" + name); + b.setDeleteAllMessagesOnStartup(deleteOnStartup); + b.addConnector("vm://" + name); + b.start(); + b.waitUntilStarted(); + return b; + } + + private static void closeQuietly(Connection connection) { + if (connection != null) { + try { + connection.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java new file mode 100644 index 00000000000..a9c1e20b290 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java @@ -0,0 +1,261 @@ +/** + * 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.activemq.usecases; + +import static org.junit.Assert.*; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.jms.Connection; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.Topic; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.network.DiscoveryNetworkConnector; +import org.apache.activemq.network.NetworkConnector; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.activemq.test.annotations.ParallelTest; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parameterized test validating that DemandForwardingBridge correctly + * propagates topic consumer demand across a two-broker network for all + * combinations of subscription type. + * + *

Broker A (local/producer) bridges to broker B (remote/consumer). + * Consumers created on B generate demand that the bridge forwards to A. + * Messages published on A are forwarded to B's consumers. + * + *

With {@code conduitSubscriptions=true}, multiple remote consumers + * are condensed into a single network subscription on the local broker. + * With {@code conduitSubscriptions=false}, each remote consumer gets its + * own demand subscription regardless of shared type — each consumer has + * its own prefetch and should be treated individually. + */ +@RunWith(Parameterized.class) +@Category(ParallelTest.class) +public class TopicBridgeDemandForwardingTest { + + private static final Logger LOG = LoggerFactory.getLogger(TopicBridgeDemandForwardingTest.class); + + private static final AtomicInteger BROKER_SEQ = new AtomicInteger(); + private static final String TOPIC_NAME = "test.bridge.demand"; + private static final int MESSAGE_COUNT = 10; + + @Parameters(name = "durable={0}, shared={1}, consumers={2}, conduit={3}, expectedDemand={4}") + public static Collection data() { + List params = new ArrayList<>(); + for (boolean durable : new boolean[]{true, false}) { + for (boolean shared : new boolean[]{true, false}) { + for (int consumers : new int[]{1, 2}) { + for (boolean conduit : new boolean[]{true, false}) { + // Conduit merges all remote consumers into one demand sub. + // Without conduit, each consumer should get its own demand + // regardless of shared type — each has its own prefetch. + int expected = conduit ? 1 : consumers; + params.add(new Object[]{durable, shared, consumers, conduit, expected}); + } + } + } + } + return params; + } + + private final boolean durable; + private final boolean shared; + private final int remoteConsumerCount; + private final boolean conduit; + private final int expectedDemand; + + private SharedTopicBrokerService brokerA; + private SharedTopicBrokerService brokerB; + private NetworkConnector networkConnector; + private final List connections = new ArrayList<>(); + + public TopicBridgeDemandForwardingTest(boolean durable, boolean shared, + int remoteConsumerCount, boolean conduit, int expectedDemand) { + this.durable = durable; + this.shared = shared; + this.remoteConsumerCount = remoteConsumerCount; + this.conduit = conduit; + this.expectedDemand = expectedDemand; + } + + @Before + public void setUp() throws Exception { + int seq = BROKER_SEQ.incrementAndGet(); + + brokerB = createBroker("B-" + seq); + brokerB.start(); + brokerB.waitUntilStarted(); + + brokerA = createBroker("A-" + seq); + networkConnector = bridgeBrokers(brokerA, brokerB); + brokerA.start(); + brokerA.waitUntilStarted(); + + assertTrue("Bridge should activate within 30s", + Wait.waitFor(() -> !networkConnector.activeBridges().isEmpty(), 30_000)); + } + + @After + public void tearDown() throws Exception { + for (Connection c : connections) { + try { c.close(); } catch (Exception ignored) {} + } + connections.clear(); + if (brokerA != null) { + brokerA.stop(); + brokerA.waitUntilStopped(); + } + if (brokerB != null) { + brokerB.stop(); + brokerB.waitUntilStopped(); + } + } + + @Test + public void testDemandForwarding() throws Exception { + ActiveMQTopic dest = new ActiveMQTopic(TOPIC_NAME); + String brokerBUrl = brokerB.getTransportConnectorByScheme("tcp") + .getPublishableConnectString() + "?jms.watchTopicAdvisories=false"; + + List consumers = createRemoteConsumers(brokerBUrl); + + // Wait for demand to propagate from B to A. + Destination destOnA = brokerA.getDestination(dest); + assertNotNull("Topic should exist on broker A", destOnA); + assertTrue("Demand should propagate within 30s (expected " + expectedDemand + " demand subs)", + Wait.waitFor(() -> ((Topic) destOnA).getConsumers().size() == expectedDemand, + 30_000, 200)); + + int demandCount = ((Topic) destOnA).getConsumers().size(); + LOG.info("Demand on broker A: {} (expected {}), conduit={}, shared={}, durable={}, remoteConsumers={}", + demandCount, expectedDemand, conduit, shared, durable, remoteConsumerCount); + assertEquals("Demand subscription count on broker A", expectedDemand, demandCount); + + // Publish on A, verify messages are forwarded to B. + String brokerAUrl = brokerA.getTransportConnectorByScheme("tcp") + .getPublishableConnectString() + "?jms.watchTopicAdvisories=false"; + ActiveMQConnectionFactory producerFactory = new ActiveMQConnectionFactory(brokerAUrl); + Connection prodConn = producerFactory.createConnection(); + connections.add(prodConn); + prodConn.start(); + Session prodSession = prodConn.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageProducer producer = prodSession.createProducer(prodSession.createTopic(TOPIC_NAME)); + + for (int i = 0; i < MESSAGE_COUNT; i++) { + producer.send(prodSession.createTextMessage("msg-" + i)); + } + + // Verify at least 1 consumer receives at least 1 message, proving + // the bridge forwarded messages from A to B. + int totalReceived = 0; + for (MessageConsumer consumer : consumers) { + while (true) { + Message msg = consumer.receive(3000); + if (msg == null) break; + totalReceived++; + } + } + + assertTrue("At least one message should be forwarded from broker A to broker B", + totalReceived >= MESSAGE_COUNT); + + LOG.info("Messages received on broker B: {} (published {})", totalReceived, MESSAGE_COUNT); + } + + private List createRemoteConsumers(String brokerBUrl) throws Exception { + List consumers = new ArrayList<>(); + + if (shared) { + SharedTopicConnectionFactory sharedFactory = new SharedTopicConnectionFactory(brokerBUrl); + for (int i = 0; i < remoteConsumerCount; i++) { + Connection conn = sharedFactory.createConnection(); + connections.add(conn); + conn.start(); + Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE); + jakarta.jms.Topic topic = session.createTopic(TOPIC_NAME); + if (durable) { + consumers.add(session.createSharedDurableConsumer(topic, "bridgeSub")); + } else { + consumers.add(session.createSharedConsumer(topic, "bridgeSub")); + } + } + } else { + ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(brokerBUrl); + for (int i = 0; i < remoteConsumerCount; i++) { + Connection conn = factory.createConnection(); + connections.add(conn); + if (durable) { + conn.setClientID("remote-client-" + i); + } + conn.start(); + Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE); + jakarta.jms.Topic topic = session.createTopic(TOPIC_NAME); + if (durable) { + consumers.add(session.createDurableSubscriber(topic, "sub-" + i)); + } else { + consumers.add(session.createConsumer(topic)); + } + } + } + + return consumers; + } + + private SharedTopicBrokerService createBroker(String name) throws Exception { + SharedTopicBrokerService broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(false); + broker.setBrokerName(name); + broker.addConnector("tcp://0.0.0.0:0"); + return broker; + } + + private NetworkConnector bridgeBrokers(SharedTopicBrokerService local, + SharedTopicBrokerService remote) throws Exception { + String uri = "static:(" + remote.getTransportConnectorByScheme("tcp") + .getPublishableConnectString() + ")"; + DiscoveryNetworkConnector connector = new DiscoveryNetworkConnector(new URI(uri)); + connector.setName(local.getBrokerName() + "-to-" + remote.getBrokerName()); + connector.setConduitSubscriptions(conduit); + connector.setDynamicOnly(true); + local.addNetworkConnector(connector); + return connector; + } +} diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicConsumerAdvisoryTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicConsumerAdvisoryTest.java new file mode 100644 index 00000000000..905865fd794 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicConsumerAdvisoryTest.java @@ -0,0 +1,206 @@ +/** + * 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.activemq.usecases; + +import static org.junit.Assert.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.jms.Connection; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.Session; +import jakarta.jms.Topic; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.advisory.AdvisorySupport; +import org.apache.activemq.command.ActiveMQTopic; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.activemq.test.annotations.ParallelTest; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * Parameterized test validating that topic consumer advisories fire correctly + * across all combinations of broker advisory support, consumer type + * (durable/non-durable, shared/non-shared), and single vs multiple consumers. + * + *

Consumer add/remove advisories are gated only by the broker-wide + * {@code advisorySupport} flag. There is no per-destination policy entry + * that controls consumer lifecycle advisories. + */ +@RunWith(Parameterized.class) +@Category(ParallelTest.class) +public class TopicConsumerAdvisoryTest { + + private static final AtomicInteger BROKER_SEQ = new AtomicInteger(); + private static final String TOPIC_NAME = "test.advisory.topic"; + + @Parameters(name = "brokerAdv={0}, durable={1}, shared={2}, multiple={3}, expected={4}") + public static Collection data() { + List params = new ArrayList<>(); + for (boolean broker : new boolean[]{true, false}) { + for (boolean durable : new boolean[]{true, false}) { + for (boolean shared : new boolean[]{true, false}) { + for (boolean multiple : new boolean[]{true, false}) { + boolean expected = broker; + params.add(new Object[]{broker, durable, shared, multiple, expected}); + } + } + } + } + return params; + } + + private final boolean brokerAdvisory; + private final boolean durable; + private final boolean shared; + private final boolean multiple; + private final boolean expected; + + private SharedTopicBrokerService broker; + private String brokerUrl; + private final List connections = new ArrayList<>(); + + public TopicConsumerAdvisoryTest(boolean brokerAdvisory, + boolean durable, boolean shared, boolean multiple, boolean expected) { + this.brokerAdvisory = brokerAdvisory; + this.durable = durable; + this.shared = shared; + this.multiple = multiple; + this.expected = expected; + } + + @Before + public void setUp() throws Exception { + String brokerName = "adv-test-" + BROKER_SEQ.incrementAndGet(); + brokerUrl = "vm://" + brokerName; + + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(false); + broker.setBrokerName(brokerName); + broker.setAdvisorySupport(brokerAdvisory); + + broker.addConnector(brokerUrl); + broker.start(); + broker.waitUntilStarted(); + } + + @After + public void tearDown() throws Exception { + for (Connection c : connections) { + try { c.close(); } catch (Exception ignored) {} + } + connections.clear(); + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + } + } + + @Test + public void testConsumerAdvisory() throws Exception { + ActiveMQTopic dest = new ActiveMQTopic(TOPIC_NAME); + ActiveMQTopic advisoryDest = AdvisorySupport.getConsumerAdvisoryTopic(dest); + + ActiveMQConnectionFactory advFactory = new ActiveMQConnectionFactory(brokerUrl); + Connection advConn = advFactory.createConnection(); + connections.add(advConn); + advConn.start(); + Session advSession = advConn.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageConsumer advConsumer = advSession.createConsumer(advisoryDest); + + Thread.sleep(100); + + int consumerCount = multiple ? 2 : 1; + createTestConsumers(consumerCount); + + int expectedCount = expected ? consumerCount : 0; + + List received = new ArrayList<>(); + for (int i = 0; i < expectedCount; i++) { + Message msg = advConsumer.receive(5000); + if (msg != null) { + received.add(msg); + } + } + + Message extra = advConsumer.receive(500); + assertNull("Should not receive more than " + expectedCount + " advisory message(s)", extra); + + assertEquals("Advisory message count", expectedCount, received.size()); + + for (Message msg : received) { + assertTrue("Advisory should carry consumerCount property", + msg.propertyExists(AdvisorySupport.MSG_PROPERTY_CONSUMER_COUNT)); + } + } + + private void createTestConsumers(int count) throws Exception { + if (shared) { + createSharedConsumers(count); + } else { + createNonSharedConsumers(count); + } + } + + private void createSharedConsumers(int count) throws Exception { + SharedTopicConnectionFactory sharedFactory = new SharedTopicConnectionFactory(brokerUrl); + for (int i = 0; i < count; i++) { + Connection conn = sharedFactory.createConnection(); + connections.add(conn); + conn.start(); + Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic(TOPIC_NAME); + if (durable) { + session.createSharedDurableConsumer(topic, "sharedSub"); + } else { + session.createSharedConsumer(topic, "sharedSub"); + } + } + } + + private void createNonSharedConsumers(int count) throws Exception { + ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(brokerUrl); + for (int i = 0; i < count; i++) { + Connection conn = factory.createConnection(); + connections.add(conn); + if (durable) { + conn.setClientID("client-" + i); + } + conn.start(); + Session session = conn.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic(TOPIC_NAME); + if (durable) { + session.createDurableSubscriber(topic, "sub-" + i); + } else { + session.createConsumer(topic); + } + } + } +}