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 @@
junittest
+
+ org.mockito
+ mockito-core
+ test
+ org.apache.logging.log4jlog4j-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/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);
}
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/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-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 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/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/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/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