diff --git a/activemq-broker/pom.xml b/activemq-broker/pom.xml index f52ba1edd6e..faf89546605 100644 --- a/activemq-broker/pom.xml +++ b/activemq-broker/pom.xml @@ -75,6 +75,11 @@ junit test + + org.mockito + mockito-core + test + org.apache.logging.log4j log4j-core diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java b/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java new file mode 100644 index 00000000000..a37c989b0b5 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/SharedTopicBrokerService.java @@ -0,0 +1,167 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker; + +import java.io.IOException; + +import javax.management.ObjectName; +import javax.management.MalformedObjectNameException; + +import org.apache.activemq.annotation.Experimental; +import org.apache.activemq.broker.jmx.ManagedSharedTopicRegion; +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.apache.activemq.broker.Broker; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.Region; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; + +/** + * Extends {@link BrokerService} to install a {@link SharedTopicRegion} + * that supports JMS 3.1 shared topic subscriptions. + * + *

Use this class in place of {@code BrokerService} in XML configuration + * to enable shared subscription support. In Spring/XBean configuration this + * is the {@code } element of the + * {@code http://activemq.apache.org/schema/core} namespace, and it accepts + * every attribute {@code } does. + * + * @org.apache.xbean.XBean + * + */ +@Experimental("Tech Preview for JMS 3.1 shared topic subscriptions") +public class SharedTopicBrokerService extends BrokerService { + + private static final Logger LOG = LoggerFactory.getLogger(SharedTopicBrokerService.class); + private static final int SHARED_STORE_OPENWIRE_VERSION = 13; + + private boolean topicSubscriptionConversionEnabled; + + public SharedTopicBrokerService() { + setStoreOpenWireVersion(SHARED_STORE_OPENWIRE_VERSION); + } + + public boolean isTopicSubscriptionConversionEnabled() { + return topicSubscriptionConversionEnabled; + } + + public void setTopicSubscriptionConversionEnabled(boolean topicSubscriptionConversionEnabled) { + this.topicSubscriptionConversionEnabled = topicSubscriptionConversionEnabled; + } + + @Override + protected Broker createRegionBroker( + org.apache.activemq.broker.region.DestinationInterceptor destinationInterceptor) + throws IOException { + + RegionBroker regionBroker; + if (isUseJmx()) { + try { + regionBroker = new ManagedRegionBroker(this, getManagementContext(), + getBrokerObjectName(), getTaskRunnerFactory(), getConsumerSystemUsage(), + destinationFactory, destinationInterceptor, getScheduler(), + getExecutor()) { + @Override + protected Region createTopicRegion(SystemUsage memoryManager, + TaskRunnerFactory taskRunnerFactory, + DestinationFactory df) { + ManagedSharedTopicRegion region = new ManagedSharedTopicRegion( + this, destinationStatistics, memoryManager, + taskRunnerFactory, df); + region.setTopicSubscriptionConversionEnabled( + topicSubscriptionConversionEnabled); + return region; + } + + @Override + public void removeConsumer(ConnectionContext context, + ConsumerInfo info) throws Exception { + if (info instanceof SharedConsumerInfo + && ((SharedConsumerInfo) info).isShared()) { + ActiveMQDestination dest = info.getDestination(); + Region region = getRegion(dest); + Subscription sub = null; + if (region instanceof org.apache.activemq.broker.region.AbstractRegion) { + sub = ((org.apache.activemq.broker.region.AbstractRegion) region) + .getSubscriptions().get(info.getConsumerId()); + } + region.removeConsumer(context, info); + if (sub != null && sub instanceof DurableTopicSubscription + && !((DurableTopicSubscription) sub).isActive()) { + ObjectName name = sub.getObjectName(); + if (name != null) { + unregisterSubscription(name, true); + } + } + } else { + super.removeConsumer(context, info); + } + } + + @Override + public void unregisterSubscription(Subscription sub) { + ObjectName name = sub.getObjectName(); + if (name != null) { + try { + unregisterSubscription(name, false); + } catch (Exception e) { + LOG.warn("Failed to unregister shared subscription MBean: {}", + e.getMessage(), e); + } + } + super.unregisterSubscription(sub); + } + }; + } catch (MalformedObjectNameException me) { + LOG.warn("Cannot create ManagedRegionBroker due {}", me.getMessage(), me); + throw new IOException(me); + } + } else { + regionBroker = new RegionBroker(this, getTaskRunnerFactory(), + getConsumerSystemUsage(), destinationFactory, destinationInterceptor, + getScheduler(), getExecutor()) { + @Override + protected Region createTopicRegion(SystemUsage memoryManager, + TaskRunnerFactory taskRunnerFactory, + DestinationFactory df) { + SharedTopicRegion region = new SharedTopicRegion(this, + destinationStatistics, memoryManager, taskRunnerFactory, df); + region.setTopicSubscriptionConversionEnabled( + topicSubscriptionConversionEnabled); + return region; + } + }; + } + + destinationFactory.setRegionBroker(regionBroker); + regionBroker.setKeepDurableSubsActive(isKeepDurableSubsActive()); + regionBroker.getDestinationStatistics().setEnabled(isEnableStatistics()); + regionBroker.setAllowTempAutoCreationOnSend(isAllowTempAutoCreationOnSend()); + return regionBroker; + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java new file mode 100644 index 00000000000..2f31a9435d9 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ManagedSharedTopicRegion.java @@ -0,0 +1,93 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import jakarta.jms.JMSException; +import javax.management.ObjectName; + +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DestinationStatistics; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; + +/** + * JMX-aware variant of {@link SharedTopicRegion}. Registers subscription + * and destination MBeans with the {@link ManagedRegionBroker}. + * + *

Mirrors the upstream {@code ManagedTopicRegion} pattern — used when + * JMX is enabled, while the base {@code SharedTopicRegion} is used when + * JMX is disabled. + */ +public class ManagedSharedTopicRegion extends SharedTopicRegion { + + private final ManagedRegionBroker regionBroker; + + public ManagedSharedTopicRegion(ManagedRegionBroker broker, + DestinationStatistics destinationStatistics, + SystemUsage memoryManager, TaskRunnerFactory taskRunnerFactory, + DestinationFactory destinationFactory) { + super(broker, destinationStatistics, memoryManager, taskRunnerFactory, destinationFactory); + this.regionBroker = broker; + } + + @Override + protected Subscription createSubscription(ConnectionContext context, ConsumerInfo info) + throws JMSException { + Subscription sub = super.createSubscription(context, info); + ObjectName name = regionBroker.registerSubscription(context, sub); + sub.setObjectName(name); + return sub; + } + + @Override + protected void destroySubscription(Subscription sub) { + regionBroker.unregisterSubscription(sub); + super.destroySubscription(sub); + } + + @Override + protected void onSharedDurableReactivated(ConnectionContext context, Subscription sub) { + regionBroker.registerSubscription(context, sub); + } + + @Override + protected void onSharedNonDurableDestroyed(Subscription sub) { + regionBroker.unregisterSubscription(sub); + } + + @Override + protected Destination createDestination(ConnectionContext context, + ActiveMQDestination destination) throws Exception { + Destination rc = super.createDestination(context, destination); + regionBroker.register(destination, rc); + return rc; + } + + @Override + public void removeDestination(ConnectionContext context, + ActiveMQDestination destination, long timeout) throws Exception { + super.removeDestination(context, destination, timeout); + regionBroker.unregister(destination); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java new file mode 100644 index 00000000000..307c423e694 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionView.java @@ -0,0 +1,56 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import org.apache.activemq.broker.region.SharedDurableTopicSubscription; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.jmx.DurableSubscriptionView; +import org.apache.activemq.broker.jmx.ManagedRegionBroker; +import org.apache.activemq.broker.region.Subscription; + +/** + * JMX view for shared durable topic subscriptions. Delegates + * shared-specific attributes to the underlying + * {@link SharedDurableTopicSubscription}. + */ +public class SharedDurableSubscriptionView extends DurableSubscriptionView + implements SharedDurableSubscriptionViewMBean { + + private final SharedDurableTopicSubscription sharedSub; + + public SharedDurableSubscriptionView(ManagedRegionBroker broker, + BrokerService brokerService, String clientId, String userName, + Subscription sub) { + super(broker, brokerService, clientId, userName, sub); + this.sharedSub = (SharedDurableTopicSubscription) sub; + } + + @Override + public boolean isShared() { + return true; + } + + @Override + public int getConsumerCount() { + return sharedSub.getConsumerCount(); + } + + @Override + public String toString() { + return "SharedDurableSubscriptionView: " + getClientId() + ":" + getSubscriptionName(); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java new file mode 100644 index 00000000000..09f0efd794a --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedDurableSubscriptionViewMBean.java @@ -0,0 +1,34 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import org.apache.activemq.broker.jmx.DurableSubscriptionViewMBean; +import org.apache.activemq.broker.jmx.MBeanInfo; + +/** + * JMX MBean interface for shared durable topic subscriptions. + * Extends the standard durable subscription view with shared-specific + * attributes. + */ +public interface SharedDurableSubscriptionViewMBean extends DurableSubscriptionViewMBean { + + @MBeanInfo("Whether this is a JMS 3.1 shared subscription") + boolean isShared(); + + @MBeanInfo("Number of consumers currently sharing this subscription") + int getConsumerCount(); +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java new file mode 100644 index 00000000000..f5b04386546 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionView.java @@ -0,0 +1,52 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import org.apache.activemq.broker.region.SharedTopicSubscription; +import org.apache.activemq.broker.jmx.SubscriptionView; +import org.apache.activemq.broker.region.Subscription; + +/** + * JMX view for shared non-durable topic subscriptions. Delegates + * shared-specific attributes to the underlying + * {@link SharedTopicSubscription}. + */ +public class SharedSubscriptionView extends SubscriptionView + implements SharedSubscriptionViewMBean { + + private final SharedTopicSubscription sharedSub; + + public SharedSubscriptionView(String clientId, String userName, Subscription sub) { + super(clientId, userName, sub); + this.sharedSub = (SharedTopicSubscription) sub; + } + + @Override + public boolean isShared() { + return true; + } + + @Override + public int getConsumerCount() { + return sharedSub.getConsumerCount(); + } + + @Override + public String toString() { + return "SharedSubscriptionView: " + getClientId() + ":" + getSubscriptionName(); + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java new file mode 100644 index 00000000000..c2d08aa26fc --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/SharedSubscriptionViewMBean.java @@ -0,0 +1,32 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.jmx; + +import org.apache.activemq.broker.jmx.MBeanInfo; +import org.apache.activemq.broker.jmx.SubscriptionViewMBean; + +/** + * JMX MBean interface for shared non-durable topic subscriptions. + */ +public interface SharedSubscriptionViewMBean extends SubscriptionViewMBean { + + @MBeanInfo("Whether this is a JMS 3.1 shared subscription") + boolean isShared(); + + @MBeanInfo("Number of consumers currently sharing this subscription") + int getConsumerCount(); +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java new file mode 100644 index 00000000000..c31173289ac --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedDurableTopicSubscription.java @@ -0,0 +1,242 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.region; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.jms.JMSException; + +import org.apache.activemq.broker.Broker; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.MessageReference; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.MessageAck; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.usage.SystemUsage; +import org.apache.activemq.util.SubscriptionKey; + +/** + * Durable topic subscription that dispatches messages queue-style across + * multiple consumers sharing the same subscription name. + * + *

The parent {@link DurableTopicSubscription} assumes a single consumer. + * This subclass maintains a list of consumers and overrides {@code dispatch()} + * to round-robin among non-full consumers, giving each message to exactly + * one consumer — queue semantics within a topic subscription. + */ +public class SharedDurableTopicSubscription extends DurableTopicSubscription { + + private final SubscriptionKey storedKey; + private final CopyOnWriteArrayList consumers = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap dispatchedTo = new ConcurrentHashMap<>(); + private int nextConsumerIndex; + + public SharedDurableTopicSubscription(Broker broker, SystemUsage usageManager, + ConnectionContext context, ConsumerInfo info, boolean keepDurableSubsActive) + throws JMSException { + super(broker, usageManager, context, info, keepDurableSubsActive); + consumers.add(new ConsumerState(context, info)); + String clientId = context.getClientId() != null ? context.getClientId() : ""; + this.storedKey = new SubscriptionKey(clientId, info.getSubscriptionName()); + } + + @Override + public SubscriptionKey getSubscriptionKey() { + return storedKey; + } + + public void addConsumer(ConnectionContext ctx, ConsumerInfo consumerInfo) throws IOException { + if (!isActive() && !consumers.isEmpty()) { + consumers.clear(); + } + consumers.add(new ConsumerState(ctx, consumerInfo)); + dispatchPending(); + } + + public void removeConsumer(ConsumerId consumerId) throws Exception { + ConsumerState removed = null; + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(consumerId)) { + removed = cs; + consumers.remove(cs); + break; + } + } + if (removed != null) { + requeueDispatchedTo(removed); + if (!consumers.isEmpty()) { + ConsumerState first = consumers.get(0); + this.context = first.context; + this.info = first.info; + dispatchPending(); + } + } + } + + public int getConsumerCount() { + return consumers.size(); + } + + public boolean hasConsumers() { + return !consumers.isEmpty(); + } + + // [dispatch override] queue-style among consumers + + @Override + protected boolean dispatch(MessageReference node) throws IOException { + ConsumerState target = selectConsumer(); + if (target == null) { + return false; + } + + ConnectionContext savedContext = this.context; + ConsumerInfo savedInfo = this.info; + this.context = target.context; + this.info = target.info; + try { + boolean result = super.dispatch(node); + if (result) { + dispatchedTo.put(node.getMessageId(), target.info.getConsumerId()); + target.dispatched++; + } + return result; + } finally { + this.context = savedContext; + this.info = savedInfo; + } + } + + @Override + public boolean isFull() { + if (!isActive()) { + return true; + } + List snapshot = consumers; + if (snapshot.isEmpty()) { + return true; + } + for (ConsumerState cs : snapshot) { + if (!cs.isFull()) { + return false; + } + } + return true; + } + + @Override + public int countBeforeFull() { + int total = 0; + for (ConsumerState cs : consumers) { + total += cs.countBeforeFull(); + } + return total; + } + + // [ack routing] decrement the correct consumer's dispatch count + + @Override + protected void acknowledge(ConnectionContext ctx, MessageAck ack, + MessageReference node) throws IOException { + ConsumerId cid = dispatchedTo.remove(node.getMessageId()); + if (cid != null) { + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(cid)) { + cs.dispatched--; + break; + } + } + } + super.acknowledge(ctx, ack, node); + } + + // [consumer selection] round-robin, skip full + + ConsumerState selectConsumer() { + List snapshot = consumers; + int size = snapshot.size(); + if (size == 0) { + return null; + } + for (int i = 0; i < size; i++) { + int idx = (nextConsumerIndex + i) % size; + ConsumerState cs = snapshot.get(idx); + if (!cs.isFull()) { + nextConsumerIndex = (idx + 1) % size; + return cs; + } + } + return null; + } + + private void requeueDispatchedTo(ConsumerState removed) throws Exception { + ConsumerId cid = removed.info.getConsumerId(); + List toRequeue = new ArrayList<>(); + + synchronized (dispatchLock) { + for (MessageReference ref : dispatched) { + ConsumerId owner = dispatchedTo.get(ref.getMessageId()); + if (cid.equals(owner)) { + toRequeue.add(ref); + } + } + for (MessageReference ref : toRequeue) { + dispatched.remove(ref); + dispatchedTo.remove(ref.getMessageId()); + } + } + + if (!toRequeue.isEmpty()) { + Collections.reverse(toRequeue); + synchronized (pendingLock) { + for (MessageReference ref : toRequeue) { + ref.incrementRedeliveryCounter(); + pending.addMessageFirst(ref); + } + } + } + } + + // [per-consumer state] + + static class ConsumerState { + final ConnectionContext context; + final ConsumerInfo info; + int dispatched; + + ConsumerState(ConnectionContext context, ConsumerInfo info) { + this.context = context; + this.info = info; + } + + boolean isFull() { + return info.getPrefetchSize() > 0 && dispatched >= info.getPrefetchSize(); + } + + int countBeforeFull() { + return Math.max(0, info.getPrefetchSize() - dispatched); + } + } +} diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java new file mode 100644 index 00000000000..8bff667f043 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java @@ -0,0 +1,532 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.broker.region; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.apache.activemq.broker.ConnectionContext; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.DestinationFactory; +import org.apache.activemq.broker.region.DestinationStatistics; +import org.apache.activemq.broker.region.DurableTopicSubscription; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.Subscription; +import org.apache.activemq.broker.region.Topic; +import org.apache.activemq.broker.region.TopicRegion; +import org.apache.activemq.command.ActiveMQDestination; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.RemoveSubscriptionInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.command.SharedSubscriptionInfo; +import org.apache.activemq.command.SubscriptionInfo; +import org.apache.activemq.store.TopicMessageStore; +import org.apache.activemq.thread.TaskRunnerFactory; +import org.apache.activemq.usage.SystemUsage; +import org.apache.activemq.util.SharedSubscriptionKey; +import org.apache.activemq.util.SubscriptionKey; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Extends {@link TopicRegion} to route {@link SharedConsumerInfo} commands + * to shared subscription instances. Multiple consumers with the same + * subscription name join a single subscription rather than creating + * separate ones. + * + *

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

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

Uses an in-memory cursor (no store persistence). The subscription is + * removed when the last consumer detaches. + */ +public class SharedTopicSubscription extends PrefetchSubscription { + + private final CopyOnWriteArrayList consumers = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap dispatchedTo = new ConcurrentHashMap<>(); + private int nextConsumerIndex; + + public SharedTopicSubscription(Broker broker, SystemUsage usageManager, + ConnectionContext context, ConsumerInfo info) throws JMSException { + super(broker, usageManager, context, info); + consumers.add(new ConsumerState(context, info)); + } + + public void addConsumer(ConnectionContext ctx, ConsumerInfo consumerInfo) throws IOException { + consumers.add(new ConsumerState(ctx, consumerInfo)); + dispatchPending(); + } + + public void removeConsumer(ConsumerId consumerId) throws Exception { + ConsumerState removed = null; + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(consumerId)) { + removed = cs; + consumers.remove(cs); + break; + } + } + if (removed != null) { + requeueDispatchedTo(removed); + if (!consumers.isEmpty()) { + ConsumerState first = consumers.get(0); + this.context = first.context; + this.info = first.info; + dispatchPending(); + } + } + } + + public int getConsumerCount() { + return consumers.size(); + } + + public boolean hasConsumers() { + return !consumers.isEmpty(); + } + + // [dispatch override] queue-style among consumers + + @Override + protected boolean dispatch(MessageReference node) throws IOException { + ConsumerState target = selectConsumer(); + if (target == null) { + return false; + } + + ConnectionContext savedContext = this.context; + ConsumerInfo savedInfo = this.info; + this.context = target.context; + this.info = target.info; + try { + boolean result = super.dispatch(node); + if (result) { + dispatchedTo.put(node.getMessageId(), target.info.getConsumerId()); + target.dispatched++; + } + return result; + } finally { + this.context = savedContext; + this.info = savedInfo; + } + } + + @Override + public boolean isFull() { + List snapshot = consumers; + if (snapshot.isEmpty()) { + return true; + } + for (ConsumerState cs : snapshot) { + if (!cs.isFull()) { + return false; + } + } + return true; + } + + @Override + public int countBeforeFull() { + int total = 0; + for (ConsumerState cs : consumers) { + total += cs.countBeforeFull(); + } + return total; + } + + @Override + protected boolean canDispatch(MessageReference node) throws IOException { + return true; + } + + @Override + protected boolean isDropped(MessageReference node) { + return false; + } + + // [ack routing] decrement the correct consumer's dispatch count + + @Override + protected void acknowledge(ConnectionContext ctx, MessageAck ack, + MessageReference node) throws IOException { + ConsumerId cid = dispatchedTo.remove(node.getMessageId()); + if (cid != null) { + for (ConsumerState cs : consumers) { + if (cs.info.getConsumerId().equals(cid)) { + cs.dispatched--; + break; + } + } + } + this.setTimeOfLastMessageAck(System.currentTimeMillis()); + Destination regionDestination = (Destination) node.getRegionDestination(); + regionDestination.acknowledge(ctx, this, ack, node); + node.decrementReferenceCount(); + } + + @Override + public void destroy() { + synchronized (pendingLock) { + try { + pending.reset(); + while (pending.hasNext()) { + MessageReference node = pending.next(); + node.decrementReferenceCount(); + } + } finally { + pending.release(); + pending.clear(); + } + } + synchronized (dispatchLock) { + for (MessageReference node : dispatched) { + node.decrementReferenceCount(); + } + dispatched.clear(); + } + dispatchedTo.clear(); + consumers.clear(); + setSlowConsumer(false); + } + + // [consumer selection] round-robin, skip full + + ConsumerState selectConsumer() { + List snapshot = consumers; + int size = snapshot.size(); + if (size == 0) { + return null; + } + for (int i = 0; i < size; i++) { + int idx = (nextConsumerIndex + i) % size; + ConsumerState cs = snapshot.get(idx); + if (!cs.isFull()) { + nextConsumerIndex = (idx + 1) % size; + return cs; + } + } + return null; + } + + private void requeueDispatchedTo(ConsumerState removed) throws Exception { + ConsumerId cid = removed.info.getConsumerId(); + List toRequeue = new ArrayList<>(); + + synchronized (dispatchLock) { + for (MessageReference ref : dispatched) { + ConsumerId owner = dispatchedTo.get(ref.getMessageId()); + if (cid.equals(owner)) { + toRequeue.add(ref); + } + } + for (MessageReference ref : toRequeue) { + dispatched.remove(ref); + dispatchedTo.remove(ref.getMessageId()); + } + } + + if (!toRequeue.isEmpty()) { + Collections.reverse(toRequeue); + synchronized (pendingLock) { + for (MessageReference ref : toRequeue) { + ref.incrementRedeliveryCounter(); + pending.addMessageFirst(ref); + } + } + } + } + + // [per-consumer state] shared with SharedDurableTopicSubscription + + static class ConsumerState { + final ConnectionContext context; + final ConsumerInfo info; + int dispatched; + + ConsumerState(ConnectionContext context, ConsumerInfo info) { + this.context = context; + this.info = info; + } + + boolean isFull() { + return info.getPrefetchSize() > 0 && dispatched >= info.getPrefetchSize(); + } + + int countBeforeFull() { + return Math.max(0, info.getPrefetchSize() - dispatched); + } + } +} diff --git a/activemq-broker/src/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: + *

+ */ +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 sent = new ConcurrentLinkedQueue<>(); + private int receiveCounter; + + @Override + public void oneway(Object command) throws IOException { + receiveCounter++; + sent.add(command); + } + + @Override + public Object request(Object command) throws IOException { + receiveCounter++; + sent.add(command); + return new Response(); + } + + @Override + public Object request(Object command, int timeout) throws IOException { + return request(command); + } + + Queue getSent() { + return sent; + } + + @Override + public String getRemoteAddress() { + return "stub://localhost"; + } + + @Override + public int getReceiveCounter() { + return receiveCounter; + } + + @Override + public X509Certificate[] getPeerCertificates() { + return null; + } + + @Override + public void setPeerCertificates(X509Certificate[] certificates) { + } + + @Override + public WireFormat getWireFormat() { + return null; + } + + @Override + protected void doStart() throws Exception { + } + + @Override + protected void doStop(ServiceStopper stopper) throws Exception { + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java b/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java new file mode 100644 index 00000000000..e703ee4fe24 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/ExceptionResponseTest.java @@ -0,0 +1,137 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.command; + +import static org.junit.Assert.*; + +import jakarta.jms.InvalidDestinationException; +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.junit.Test; + +public class ExceptionResponseTest { + + @Test + public void testNullErrorCodeLeavesExceptionUntouched() { + JMSException original = new JMSException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + + assertSame(original, response.getException()); + assertNull(response.getErrorCode()); + } + + @Test + public void testErrorCodeAppliedToJMSException() { + ExceptionResponse response = new ExceptionResponse(new JMSException("boom")); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + Throwable result = response.getException(); + assertTrue(result instanceof JMSException); + assertEquals(ActiveMQErrorCode.SELECTOR_MISMATCH, ((JMSException) result).getErrorCode()); + assertEquals("boom", result.getMessage()); + } + + @Test + public void testErrorCodeAppliedToJMSExceptionSubclass() { + ExceptionResponse response = + new ExceptionResponse(new InvalidDestinationException("bad dest")); + response.setErrorCode(ActiveMQErrorCode.INVALID_DESTINATION); + + Throwable result = response.getException(); + assertTrue("Subclass type must be preserved", result instanceof InvalidDestinationException); + assertEquals(ActiveMQErrorCode.INVALID_DESTINATION, + ((JMSException) result).getErrorCode()); + } + + @Test + public void testMatchingErrorCodeDoesNotReconstruct() { + JMSException original = new JMSException("boom", ActiveMQErrorCode.SELECTOR_MISMATCH); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame("Already-correct errorCode must skip reflection", original, + response.getException()); + } + + @Test + public void testNonJMSExceptionIgnoresErrorCode() { + RuntimeException original = new RuntimeException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame(original, response.getException()); + } + + @Test + public void testExceptionWithoutTwoArgConstructorFallsBackToOriginal() { + JMSException original = new NoTwoArgCtorException("boom"); + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + assertSame("Missing (String,String) ctor must leave the exception intact", + original, response.getException()); + } + + @Test + public void testReconstructionPreservesCauseAndLinkedException() { + Throwable cause = new IllegalStateException("root cause"); + Exception linked = new Exception("linked"); + JMSException original = new JMSException("boom"); + original.initCause(cause); + original.setLinkedException(linked); + + ExceptionResponse response = new ExceptionResponse(original); + response.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_IN_USE); + + JMSException result = (JMSException) response.getException(); + assertEquals(ActiveMQErrorCode.SUBSCRIPTION_IN_USE, result.getErrorCode()); + assertSame(cause, result.getCause()); + assertSame(linked, result.getLinkedException()); + assertArrayEquals(original.getStackTrace(), result.getStackTrace()); + } + + @Test + public void testGetExceptionIsIdempotent() { + ExceptionResponse response = new ExceptionResponse(new JMSException("boom")); + response.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_TYPE_CONFLICT); + + Throwable first = response.getException(); + Throwable second = response.getException(); + assertSame("Second call must not reconstruct again", first, second); + } + + @Test + public void testIsException() { + assertTrue(new ExceptionResponse(new JMSException("boom")).isException()); + } + + @Test + public void testDataStructureType() { + assertEquals(CommandTypes.EXCEPTION_RESPONSE, + new ExceptionResponse().getDataStructureType()); + } + + /** A JMSException subclass lacking the (String message, String errorCode) constructor. */ + private static class NoTwoArgCtorException extends JMSException { + private static final long serialVersionUID = 1L; + + NoTwoArgCtorException(String reason) { + super(reason); + } + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java b/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java new file mode 100644 index 00000000000..f9e27e55176 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/SharedConsumerInfoTest.java @@ -0,0 +1,99 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.command; + +import static org.junit.Assert.*; + +import org.junit.Test; + +public class SharedConsumerInfoTest { + + @Test + public void testDefaultsToNonSharedNonDurable() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertFalse(info.isShared()); + assertFalse(info.isDurable()); + } + + @Test + public void testSharedFlag() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setShared(true); + assertTrue(info.isShared()); + } + + @Test + public void testDurableFlag() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setDurable(true); + assertTrue(info.isDurable()); + } + + @Test + public void testDurableOverridesParentInference() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setSubscriptionName("mySub"); + assertFalse(info.isDurable()); + + info.setDurable(true); + assertTrue(info.isDurable()); + } + + @Test + public void testIsInstanceOfConsumerInfo() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertTrue(info instanceof ConsumerInfo); + } + + @Test + public void testDataStructureType() { + SharedConsumerInfo info = new SharedConsumerInfo(); + assertEquals(ConsumerInfo.DATA_STRUCTURE_TYPE, info.getDataStructureType()); + } + + @Test + public void testCopyPreservesSharedFields() { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setShared(true); + original.setDurable(true); + original.setSubscriptionName("mySub"); + + SharedConsumerInfo copy = original.copy(); + assertTrue(copy.isShared()); + assertTrue(copy.isDurable()); + assertEquals("mySub", copy.getSubscriptionName()); + } + + @Test + public void testCopyToParentConsumerInfo() { + SharedConsumerInfo info = new SharedConsumerInfo(); + info.setShared(true); + info.setSubscriptionName("mySub"); + + ConsumerInfo parentCopy = new ConsumerInfo(); + info.copy(parentCopy); + assertEquals("mySub", parentCopy.getSubscriptionName()); + } + + @Test + public void testConstructorWithConsumerId() { + ConsumerId cid = new ConsumerId(new SessionId(new ConnectionId("conn"), 1), 1); + SharedConsumerInfo info = new SharedConsumerInfo(cid); + assertEquals(cid, info.getConsumerId()); + assertFalse(info.isShared()); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java b/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java new file mode 100644 index 00000000000..136a1af207f --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/command/SharedSubscriptionInfoTest.java @@ -0,0 +1,91 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.command; + +import static org.junit.Assert.*; + +import org.junit.Test; + +public class SharedSubscriptionInfoTest { + + @Test + public void testDefaultsToNonShared() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertFalse(info.isShared()); + } + + @Test + public void testSharedFlag() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + info.setShared(true); + assertTrue(info.isShared()); + } + + @Test + public void testIsInstanceOfSubscriptionInfo() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertTrue(info instanceof SubscriptionInfo); + } + + @Test + public void testDataStructureType() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + assertEquals(SubscriptionInfo.DATA_STRUCTURE_TYPE, info.getDataStructureType()); + } + + @Test + public void testConstructorWithClientIdAndName() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo("client1", "mySub"); + assertEquals("client1", info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + assertFalse(info.isShared()); + } + + @Test + public void testNullClientId() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(null, "mySub"); + assertNull(info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + } + + @Test + public void testEqualsWithParent() { + SharedSubscriptionInfo shared = new SharedSubscriptionInfo("client1", "mySub"); + shared.setShared(true); + SubscriptionInfo plain = new SubscriptionInfo("client1", "mySub"); + + assertTrue(plain.equals(shared)); + assertTrue(shared.equals(plain)); + } + + @Test + public void testInheritsAllParentFields() { + SharedSubscriptionInfo info = new SharedSubscriptionInfo(); + info.setClientId("client1"); + info.setSubscriptionName("mySub"); + info.setSelector("color = 'blue'"); + info.setDestination(new ActiveMQTopic("test.topic")); + info.setNoLocal(true); + info.setShared(true); + + assertEquals("client1", info.getClientId()); + assertEquals("mySub", info.getSubscriptionName()); + assertEquals("color = 'blue'", info.getSelector()); + assertTrue(info.isNoLocal()); + assertTrue(info.isShared()); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java new file mode 100644 index 00000000000..6558c1bb801 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/ExceptionResponseMarshallerRoundTripTest.java @@ -0,0 +1,134 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQErrorCode; +import org.apache.activemq.command.ExceptionResponse; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Test; + +/** + * Round-trips the v13 {@code errorCode} field on {@link ExceptionResponse} + * in both tight and loose encodings. + */ +public class ExceptionResponseMarshallerRoundTripTest { + + @Test + public void testErrorCodeSurvivesTightRoundTrip() throws Exception { + assertErrorCodeRoundTrip(true); + } + + @Test + public void testErrorCodeSurvivesLooseRoundTrip() throws Exception { + assertErrorCodeRoundTrip(false); + } + + @Test + public void testNullErrorCodeSurvivesRoundTrip() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertNull(restored.getErrorCode()); + } + + /** + * OpenWire marshals a throwable as type plus message only, dropping + * {@code JMSException.errorCode}. The separate v13 errorCode field is what + * carries it across, and {@code getException()} reapplies it on the far side. + */ + @Test + public void testErrorCodeReappliedToExceptionAfterUnmarshal() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SUBSCRIPTION_ALREADY_EXISTS); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + Throwable exception = restored.getException(); + assertTrue(exception instanceof JMSException); + assertEquals(ActiveMQErrorCode.SUBSCRIPTION_ALREADY_EXISTS, + ((JMSException) exception).getErrorCode()); + assertEquals("boom", exception.getMessage()); + } + + /** + * The v13 errorCode must not reach the v12 wire, leaving a v12 peer with + * exactly the exception it would have received before this field existed. + */ + @Test + public void testErrorCodeIsNotWrittenAtV12() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + wireFormat.setVersion(12); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertNull("errorCode must not cross the v12 wire", restored.getErrorCode()); + assertTrue(restored.getException() instanceof JMSException); + assertEquals("boom", restored.getException().getMessage()); + } + + private void assertErrorCodeRoundTrip(boolean tightEncoding) throws Exception { + OpenWireFormat wireFormat = wireFormat(tightEncoding); + + ExceptionResponse original = new ExceptionResponse(new JMSException("boom")); + original.setErrorCode(ActiveMQErrorCode.SELECTOR_MISMATCH); + + ExceptionResponse restored = + (ExceptionResponse) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(ActiveMQErrorCode.SELECTOR_MISMATCH, restored.getErrorCode()); + assertTrue(restored.isException()); + } + + private static OpenWireFormat wireFormat(boolean tightEncoding) { + OpenWireFormat wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + wireFormat.setCacheEnabled(false); + wireFormat.setTightEncodingEnabled(tightEncoding); + return wireFormat; + } + + private static byte[] marshal(OpenWireFormat wireFormat, Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private static Object unmarshal(OpenWireFormat wireFormat, byte[] bytes) throws Exception { + return wireFormat.unmarshal(new DataInputStream(new ByteArrayInputStream(bytes))); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java new file mode 100644 index 00000000000..4cea1bbbd92 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MarshallerRoundTripTest.java @@ -0,0 +1,211 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; + +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConsumerId; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.ConnectionId; +import org.apache.activemq.command.SessionId; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.command.SharedSubscriptionInfo; +import org.apache.activemq.command.SubscriptionInfo; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Before; +import org.junit.Test; + +public class MarshallerRoundTripTest { + + private OpenWireFormat wireFormat; + + @Before + public void setUp() { + wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + } + + @Test + public void testVersionThirteenLoads() { + assertEquals(13, wireFormat.getVersion()); + } + + @Test + public void testSharedConsumerInfoRoundTrip() throws Exception { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 1)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("mySub"); + original.setShared(true); + original.setDurable(true); + original.setPrefetchSize(100); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue("Should unmarshal as SharedConsumerInfo", result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertTrue(restored.isShared()); + assertTrue(restored.isDurable()); + assertEquals("mySub", restored.getSubscriptionName()); + assertEquals(100, restored.getPrefetchSize()); + assertEquals(new ActiveMQTopic("test.topic"), restored.getDestination()); + } + + @Test + public void testSharedConsumerInfoNonSharedRoundTrip() throws Exception { + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 2)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(false); + original.setDurable(false); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertFalse(restored.isShared()); + assertFalse(restored.isDurable()); + } + + @Test + public void testSharedSubscriptionInfoRoundTrip() throws Exception { + SharedSubscriptionInfo original = new SharedSubscriptionInfo(); + original.setClientId("client1"); + original.setSubscriptionName("mySub"); + original.setSelector("color = 'blue'"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setNoLocal(false); + original.setShared(true); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue("Should unmarshal as SharedSubscriptionInfo", + result instanceof SharedSubscriptionInfo); + SharedSubscriptionInfo restored = (SharedSubscriptionInfo) result; + assertTrue(restored.isShared()); + assertEquals("client1", restored.getClientId()); + assertEquals("mySub", restored.getSubscriptionName()); + assertEquals("color = 'blue'", restored.getSelector()); + } + + @Test + public void testSharedSubscriptionInfoNullClientId() throws Exception { + SharedSubscriptionInfo original = new SharedSubscriptionInfo(); + original.setSubscriptionName("sharedSub"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(true); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedSubscriptionInfo); + SharedSubscriptionInfo restored = (SharedSubscriptionInfo) result; + assertNull(restored.getClientId()); + assertTrue(restored.isShared()); + } + + @Test + public void testPlainConsumerInfoMarshalledAsNonShared() throws Exception { + ConsumerInfo original = new ConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 3)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("durableSub"); + + byte[] bytes = marshal(original); + Object result = unmarshal(bytes); + + assertTrue(result instanceof SharedConsumerInfo); + SharedConsumerInfo restored = (SharedConsumerInfo) result; + assertFalse(restored.isShared()); + assertFalse(restored.isDurable()); + assertEquals("durableSub", restored.getSubscriptionName()); + } + + @Test + public void testMarshallerFactoryReplacesOnlyTwoMarshallers() { + org.apache.activemq.openwire.DataStreamMarshaller[] map = + MarshallerFactory.createMarshallerMap(wireFormat); + + assertTrue(map[ConsumerInfo.DATA_STRUCTURE_TYPE & 0xFF] + instanceof ConsumerInfoMarshaller); + assertTrue(map[SubscriptionInfo.DATA_STRUCTURE_TYPE & 0xFF] + instanceof SubscriptionInfoMarshaller); + } + + /** + * A v12 peer must see an ordinary {@link ConsumerInfo} with none of the + * shared fields, so a broker left at the default store version behaves + * exactly as it did before v13 existed. + */ + @Test + public void testSharedFlagsAreNotWrittenAtV12() throws Exception { + wireFormat.setVersion(12); + + SharedConsumerInfo original = new SharedConsumerInfo(); + original.setConsumerId(new ConsumerId(new SessionId(new ConnectionId("conn1"), 1), 4)); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setSubscriptionName("mySub"); + original.setShared(true); + original.setDurable(true); + + Object result = unmarshal(marshal(original)); + + assertFalse("v12 must not produce a SharedConsumerInfo", + result instanceof SharedConsumerInfo); + assertTrue(result instanceof ConsumerInfo); + assertEquals("mySub", ((ConsumerInfo) result).getSubscriptionName()); + } + + @Test + public void testSharedSubscriptionFlagIsNotWrittenAtV12() throws Exception { + wireFormat.setVersion(12); + + SharedSubscriptionInfo original = new SharedSubscriptionInfo("client1", "mySub"); + original.setDestination(new ActiveMQTopic("test.topic")); + original.setShared(true); + + Object result = unmarshal(marshal(original)); + + assertFalse("v12 must not produce a SharedSubscriptionInfo", + result instanceof SharedSubscriptionInfo); + assertTrue(result instanceof SubscriptionInfo); + assertEquals("mySub", ((SubscriptionInfo) result).getSubscriptionName()); + } + + private byte[] marshal(Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private Object unmarshal(byte[] bytes) throws Exception { + ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + DataInputStream in = new DataInputStream(bais); + return wireFormat.unmarshal(in); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java new file mode 100644 index 00000000000..d500d069fdb --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/openwire/v13/MessageMarshallerRoundTripTest.java @@ -0,0 +1,208 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.openwire.v13; + +import static org.junit.Assert.*; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.util.Arrays; +import java.util.Collection; + +import jakarta.jms.JMSException; + +import org.apache.activemq.command.ActiveMQBlobMessage; +import org.apache.activemq.command.ActiveMQBytesMessage; +import org.apache.activemq.command.ActiveMQMapMessage; +import org.apache.activemq.command.ActiveMQMessage; +import org.apache.activemq.command.ActiveMQObjectMessage; +import org.apache.activemq.command.ActiveMQQueue; +import org.apache.activemq.command.ActiveMQStreamMessage; +import org.apache.activemq.command.ActiveMQTextMessage; +import org.apache.activemq.command.Message; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.command.ProducerId; +import org.apache.activemq.openwire.OpenWireFormat; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; + +/** + * Round-trips every concrete v13 message marshaller in both tight and loose + * encodings, verifying the {@code deliveryTime} field added in v13 survives + * the wire and that type-specific fields following it stay correctly aligned. + */ +@RunWith(Parameterized.class) +public class MessageMarshallerRoundTripTest { + + private static final long DELIVERY_TIME = 1234567890123L; + + private final String label; + private final Message message; + + public MessageMarshallerRoundTripTest(String label, Message message) { + this.label = label; + this.message = message; + } + + @Parameters(name = "{0}") + public static Collection messageTypes() throws Exception { + ActiveMQTextMessage text = new ActiveMQTextMessage(); + text.setText("hello world"); + + ActiveMQBytesMessage bytes = new ActiveMQBytesMessage(); + bytes.writeBytes(new byte[] {1, 2, 3, 4}); + bytes.storeContent(); + + ActiveMQMapMessage map = new ActiveMQMapMessage(); + map.setString("key", "value"); + map.storeContent(); + + ActiveMQStreamMessage stream = new ActiveMQStreamMessage(); + stream.writeString("streamed"); + stream.storeContent(); + + ActiveMQObjectMessage object = new ActiveMQObjectMessage(); + object.setObject("payload"); + object.storeContent(); + + ActiveMQBlobMessage blob = new ActiveMQBlobMessage(); + blob.setRemoteBlobUrl("http://localhost/blob/1"); + blob.setMimeType("application/octet-stream"); + blob.setDeletedByBroker(true); + + return Arrays.asList(new Object[][] { + {"ActiveMQMessage", new ActiveMQMessage()}, + {"ActiveMQTextMessage", text}, + {"ActiveMQBytesMessage", bytes}, + {"ActiveMQMapMessage", map}, + {"ActiveMQStreamMessage", stream}, + {"ActiveMQObjectMessage", object}, + {"ActiveMQBlobMessage", blob}, + }); + } + + @Test + public void testDeliveryTimeSurvivesTightRoundTrip() throws Exception { + assertDeliveryTimeRoundTrip(true); + } + + @Test + public void testDeliveryTimeSurvivesLooseRoundTrip() throws Exception { + assertDeliveryTimeRoundTrip(false); + } + + private void assertDeliveryTimeRoundTrip(boolean tightEncoding) throws Exception { + OpenWireFormat wireFormat = wireFormat(tightEncoding); + + Message original = prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(label + " must round-trip as the same type", + original.getClass(), restored.getClass()); + assertEquals(label + " deliveryTime must survive the v13 wire", + DELIVERY_TIME, restored.getDeliveryTime()); + assertEquals(original.getMessageId(), restored.getMessageId()); + assertEquals(original.getDestination(), restored.getDestination()); + } + + @Test + public void testDeliveryTimeDefaultsToZero() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + + Message original = prepare(message); + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(0L, restored.getDeliveryTime()); + } + + /** + * The v13 field must not reach the v12 wire. This pins the version gate -- + * a broker left at the default store version neither writes nor reads + * {@code deliveryTime} -- and confirms the round-trip assertions above owe + * their result to the v13 marshaller rather than to incidental state. + */ + @Test + public void testDeliveryTimeIsNotWrittenAtV12() throws Exception { + OpenWireFormat wireFormat = wireFormat(true); + wireFormat.setVersion(12); + + Message original = prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + Message restored = (Message) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(label + " must not carry deliveryTime on the v12 wire", + 0L, restored.getDeliveryTime()); + } + + /** + * The blob marshaller inserts {@code deliveryTime} ahead of its own fields, + * so a misaligned offset would corrupt the blob payload rather than the + * timestamp. Assert both together. + */ + @Test + public void testBlobFieldsStayAlignedAfterDeliveryTime() throws Exception { + if (!(message instanceof ActiveMQBlobMessage)) { + return; + } + OpenWireFormat wireFormat = wireFormat(true); + + ActiveMQBlobMessage original = (ActiveMQBlobMessage) prepare(message); + original.setDeliveryTime(DELIVERY_TIME); + + ActiveMQBlobMessage restored = + (ActiveMQBlobMessage) unmarshal(wireFormat, marshal(wireFormat, original)); + + assertEquals(DELIVERY_TIME, restored.getDeliveryTime()); + assertEquals("http://localhost/blob/1", restored.getRemoteBlobUrl()); + assertEquals("application/octet-stream", restored.getMimeType()); + assertTrue(restored.isDeletedByBroker()); + } + + private static Message prepare(Message message) throws JMSException { + Message copy = message.copy(); + copy.setMessageId(new MessageId(new ProducerId("id:localhost:1:1:1"), 1)); + copy.setDestination(new ActiveMQQueue("TEST.QUEUE")); + return copy; + } + + private static OpenWireFormat wireFormat(boolean tightEncoding) { + OpenWireFormat wireFormat = new OpenWireFormat(); + wireFormat.setVersion(13); + wireFormat.setCacheEnabled(false); + wireFormat.setTightEncodingEnabled(tightEncoding); + return wireFormat; + } + + private static byte[] marshal(OpenWireFormat wireFormat, Object obj) throws Exception { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + DataOutputStream out = new DataOutputStream(baos); + wireFormat.marshal(obj, out); + out.flush(); + return baos.toByteArray(); + } + + private static Object unmarshal(OpenWireFormat wireFormat, byte[] bytes) throws Exception { + return wireFormat.unmarshal(new DataInputStream(new ByteArrayInputStream(bytes))); + } +} diff --git a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java index 08751b9c3bd..769644d875a 100644 --- a/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java +++ b/activemq-kahadb-store/src/main/java/org/apache/activemq/store/kahadb/MessageDatabase.java @@ -1572,7 +1572,10 @@ void updateIndex(Transaction tx, KahaRemoveMessageCommand command, Location ackL if (command.getAck() != UNMATCHED) { sd.orderIndex.get(tx, sequence); byte priority = sd.orderIndex.lastGetPriority(); - sd.subscriptionAcks.put(tx, subscriptionKey, new LastAck(sequence, priority)); + LastAck current = sd.subscriptionAcks.get(tx, subscriptionKey); + if (current == null || sequence > current.lastAckedSequence) { + sd.subscriptionAcks.put(tx, subscriptionKey, new LastAck(sequence, priority)); + } } MessageKeys keys = sd.orderIndex.get(tx, sequence); diff --git a/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java b/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java new file mode 100644 index 00000000000..6a6cdd0a829 --- /dev/null +++ b/activemq-spring/src/test/java/org/apache/activemq/xbean/SharedTopicBrokerServiceXBeanTest.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.xbean; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.apache.activemq.broker.BrokerFactory; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.broker.region.RegionBroker; +import org.apache.activemq.broker.region.SharedTopicRegion; +import org.junit.After; +import org.junit.Test; + +/** + * Shows how to wire a {@link SharedTopicBrokerService} from Spring XML via the + * {@code } XBean element, and verifies the element + * actually produces a shared-topic-capable broker rather than a plain one. + * + * @see "src/test/resources/spring/shared-topic-broker.xml" + */ +public class SharedTopicBrokerServiceXBeanTest { + + private BrokerService broker; + + @After + public void tearDown() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + broker = null; + } + } + + @Test + public void testXBeanElementCreatesSharedTopicBrokerService() throws Exception { + broker = createBroker(); + + assertTrue(" must yield a SharedTopicBrokerService, not a " + + "plain BrokerService; got " + broker.getClass().getName(), + broker instanceof SharedTopicBrokerService); + assertEquals("sharedTopicXBeanBroker", broker.getBrokerName()); + } + + /** + * The whole point of the element: without v13 the shared flag is lost on + * restart, so a broker wired this way must not be left at the default + * store version. + */ + @Test + public void testStoreOpenWireVersionIsThirteen() throws Exception { + broker = createBroker(); + + assertEquals("shared subs require OpenWire v13 for KahaDB persistence", + 13, broker.getStoreOpenWireVersion()); + assertTrue("v13 must be an upgrade over the plain broker default", + 13 > new BrokerService().getStoreOpenWireVersion()); + } + + @Test + public void testXmlAttributeIsAppliedToTheBroker() throws Exception { + broker = createBroker(); + + assertTrue("topicSubscriptionConversionEnabled=\"true\" in the XML must reach the bean", + ((SharedTopicBrokerService) broker).isTopicSubscriptionConversionEnabled()); + } + + /** + * Attributes inherited from {@code } must still work on the + * subclass element, since XBean maps setters from the whole hierarchy. + */ + @Test + public void testInheritedBrokerAttributesStillApply() throws Exception { + broker = createBroker(); + + assertEquals(false, broker.isUseJmx()); + assertEquals(false, broker.isPersistent()); + assertNotNull("transportConnector from the XML should be present", + broker.getTransportConnectorByScheme("tcp")); + } + + @Test + public void testSharedTopicRegionIsInstalled() throws Exception { + broker = createBroker(); + broker.start(); + broker.waitUntilStarted(); + + RegionBroker regionBroker = (RegionBroker) broker.getRegionBroker(); + assertTrue("broker must install SharedTopicRegion as its topic region; got " + + regionBroker.getTopicRegion().getClass().getName(), + regionBroker.getTopicRegion() instanceof SharedTopicRegion); + } + + private BrokerService createBroker() throws Exception { + return BrokerFactory.createBroker("xbean:spring/shared-topic-broker.xml"); + } +} diff --git a/activemq-spring/src/test/resources/spring/shared-topic-broker.xml b/activemq-spring/src/test/resources/spring/shared-topic-broker.xml new file mode 100644 index 00000000000..fda20139ca2 --- /dev/null +++ b/activemq-spring/src/test/resources/spring/shared-topic-broker.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java new file mode 100644 index 00000000000..256650c9f5a --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/store/kahadb/LastAckMonotonicityTest.java @@ -0,0 +1,204 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.store.kahadb; + +import static org.junit.Assert.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.jms.Connection; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.Topic; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.ActiveMQSession; +import org.apache.activemq.broker.BrokerService; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.MessageId; +import org.apache.activemq.store.MessageRecoveryListener; +import org.apache.activemq.store.TopicMessageStore; +import org.apache.activemq.util.IOHelper; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Demonstrates recovery cost when KahaDB's lastAckedSequence regresses + * due to blind overwrite during out-of-order acks. + * + *

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

After broker restart, recovery for the fast subscription should find + * its single pending message. With blind overwrite the lastAck cursor + * regresses to sequence 1, forcing recovery to scan all 499 entries. + * With monotonic max-tracking, lastAck stays at 499 and recovery jumps + * directly to the one pending message. + */ +public class LastAckMonotonicityTest { + + private static final String BROKER_NAME = "lastack-test"; + private static final String DATA_DIR = "target/test-data/lastack-monotonicity"; + private static final String TOPIC_NAME = "test.lastack.monotonic"; + private static final String FAST_CLIENT = "fast-client"; + private static final String SLOW_CLIENT = "slow-client"; + private static final String FAST_SUB = "fast-sub"; + private static final String SLOW_SUB = "slow-sub"; + private static final int MESSAGE_COUNT = 500; + + private BrokerService broker; + + @Before + public void setUp() throws Exception { + IOHelper.deleteFile(new File(DATA_DIR)); + } + + @After + public void tearDown() throws Exception { + stopBroker(); + IOHelper.deleteFile(new File(DATA_DIR)); + } + + @Test + public void testOutOfOrderAckRecoveryCost() throws Exception { + startBroker(); + + ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory("vm://" + BROKER_NAME); + Topic topic; + + // Create slow-sub FIRST — it never acks, keeping messages in the orderIndex. + Connection slowConn = factory.createConnection(); + slowConn.setClientID(SLOW_CLIENT); + slowConn.start(); + Session slowSession = slowConn.createSession(false, Session.CLIENT_ACKNOWLEDGE); + topic = slowSession.createTopic(TOPIC_NAME); + MessageConsumer slowConsumer = slowSession.createDurableSubscriber(topic, SLOW_SUB); + + // Create fast-sub with INDIVIDUAL_ACKNOWLEDGE for out-of-order acking. + Connection fastConn = factory.createConnection(); + fastConn.setClientID(FAST_CLIENT); + fastConn.start(); + Session fastSession = fastConn.createSession(false, ActiveMQSession.INDIVIDUAL_ACKNOWLEDGE); + topic = fastSession.createTopic(TOPIC_NAME); + MessageConsumer fastConsumer = fastSession.createDurableSubscriber(topic, FAST_SUB); + + // Publish messages — both subscriptions receive all of them. + MessageProducer producer = fastSession.createProducer(topic); + for (int i = 0; i < MESSAGE_COUNT; i++) { + producer.send(fastSession.createTextMessage("msg-" + i)); + } + + // slow-sub: receive but don't ack — messages stay in orderIndex. + for (int i = 0; i < MESSAGE_COUNT; i++) { + Message msg = slowConsumer.receive(5000); + assertNotNull("slow-sub should receive message " + i, msg); + } + + // fast-sub: receive all messages. + List fastReceived = new ArrayList<>(); + for (int i = 0; i < MESSAGE_COUNT; i++) { + Message msg = fastConsumer.receive(5000); + assertNotNull("fast-sub should receive message " + i, msg); + fastReceived.add(msg); + } + + // Ack messages 0..498 in REVERSE order (worst case for blind overwrite). + // Message 499 (the last) stays un-acked — the single pending message. + // With blind overwrite, the last ack (for sequence ~1) regresses lastAckedSequence. + List toAck = new ArrayList<>(fastReceived.subList(0, MESSAGE_COUNT - 1)); + Collections.reverse(toAck); + for (Message msg : toAck) { + msg.acknowledge(); + } + + slowConsumer.close(); + fastConsumer.close(); + slowConn.close(); + fastConn.close(); + + // Restart broker — KahaDB data persists. + stopBroker(); + startBroker(); + + // Recover fast-sub and count how many entries the store yields. + KahaDBPersistenceAdapter adapter = (KahaDBPersistenceAdapter) broker.getPersistenceAdapter(); + TopicMessageStore store = adapter.createTopicMessageStore(new ActiveMQTopic(TOPIC_NAME)); + + CountingRecoveryListener counter = new CountingRecoveryListener(); + store.recoverSubscription(FAST_CLIENT, FAST_SUB, counter); + + int recovered = counter.recovered.get(); + + assertEquals("Should recover exactly the 1 pending message", 1, recovered); + } + + private void startBroker() throws Exception { + broker = new BrokerService(); + broker.setPersistent(true); + broker.setUseJmx(false); + broker.setBrokerName(BROKER_NAME); + broker.setDataDirectory(DATA_DIR); + broker.setDeleteAllMessagesOnStartup(false); + broker.addConnector("vm://" + BROKER_NAME); + broker.start(); + broker.waitUntilStarted(); + } + + private void stopBroker() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + broker = null; + } + } + + static class CountingRecoveryListener implements MessageRecoveryListener { + final AtomicInteger recovered = new AtomicInteger(); + String lastRecoveredId; + + @Override + public boolean recoverMessage(org.apache.activemq.command.Message message) { + recovered.incrementAndGet(); + lastRecoveredId = message.getMessageId().toString(); + return true; + } + + @Override + public boolean recoverMessageReference(MessageId ref) { + return true; + } + + @Override + public boolean hasSpace() { + return true; + } + + @Override + public boolean isDuplicate(MessageId ref) { + return false; + } + } +} diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java new file mode 100644 index 00000000000..907eb50dc71 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java @@ -0,0 +1,793 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.usecases; + +import static org.junit.Assert.*; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import jakarta.jms.Connection; +import jakarta.jms.JMSException; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageListener; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; +import jakarta.jms.TextMessage; +import jakarta.jms.Topic; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; +import org.apache.activemq.broker.jmx.BrokerView; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.activemq.test.annotations.ParallelTest; + +/** + * End-to-end JMS integration tests for shared topic subscriptions. + * Uses {@link SharedTopicConnectionFactory} (client) and + * {@link SharedTopicBrokerService} (broker) to exercise the full stack. + */ +@Category(ParallelTest.class) +public class SharedSubscriptionJmsTest { + + private static final int WAIT_TIMEOUT = 10_000; + private static final int WAIT_INTERVAL = 10; + + private SharedTopicBrokerService broker; + private SharedTopicConnectionFactory factory; + private Connection connection1; + private Connection connection2; + + @Before + public void setUp() throws Exception { + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-test"); + broker.addConnector("vm://shared-jms-test"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-test"); + } + + @After + public void tearDown() throws Exception { + closeQuietly(connection1); + closeQuietly(connection2); + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + } + } + + @Test + public void testSharedDurableCreateAndReceive() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-1"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.durable"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "durSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Durable subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("hello-durable")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message", msg); + assertEquals("hello-durable", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableMultipleConsumersRoundRobin() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.roundrobin"); + + int messageCount = 20; + CountDownLatch latch = new CountDownLatch(messageCount); + ConcurrentLinkedQueue consumer1Msgs = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue consumer2Msgs = new ConcurrentLinkedQueue<>(); + + MessageConsumer c1 = session1.createSharedDurableConsumer(topic, "rrSub"); + c1.setMessageListener(msg -> { + try { + consumer1Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + MessageConsumer c2 = session2.createSharedDurableConsumer(topic, "rrSub"); + c2.setMessageListener(msg -> { + try { + consumer2Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Durable subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session producerSession = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageProducer producer = producerSession.createProducer(topic); + for (int i = 0; i < messageCount; i++) { + producer.send(producerSession.createTextMessage("msg-" + i)); + } + + assertTrue("All messages should be received", + latch.await(WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); + + assertEquals("Total messages should match", + messageCount, consumer1Msgs.size() + consumer2Msgs.size()); + assertFalse("Consumer 1 should receive some messages", consumer1Msgs.isEmpty()); + assertFalse("Consumer 2 should receive some messages", consumer2Msgs.isEmpty()); + + Set allReceived = new HashSet<>(consumer1Msgs); + allReceived.addAll(consumer2Msgs); + assertEquals("No duplicates — each message to exactly one consumer", + messageCount, allReceived.size()); + } + + @Test + public void testSharedDurableReconnectReceivesBuffered() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.reconnect"); + MessageConsumer consumer = session1.createSharedDurableConsumer(topic, "reconnSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length == 0 + && adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session1.createProducer(topic); + producer.send(session1.createTextMessage("buffered-msg")); + + connection2 = factory.createConnection(); + connection2.start(); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.reconnect"); + MessageConsumer consumer2 = session2.createSharedDurableConsumer(topic2, "reconnSub"); + + assertTrue("Subscriber should reactivate", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Message msg = consumer2.receive(WAIT_TIMEOUT); + assertNotNull("Should receive buffered message after reconnect", msg); + assertEquals("buffered-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableWithSelector() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-sel"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.selector"); + MessageConsumer consumer = session.createSharedDurableConsumer( + topic, "selSub", "color = 'red'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + + TextMessage red = session.createTextMessage("red-msg"); + red.setStringProperty("color", "red"); + producer.send(red); + + TextMessage blue = session.createTextMessage("blue-msg"); + blue.setStringProperty("color", "blue"); + producer.send(blue); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive red message", msg); + assertEquals("red-msg", ((TextMessage) msg).getText()); + + Message noMsg = consumer.receiveNoWait(); + assertNull("Should NOT receive blue message", noMsg); + } + + @Test + public void testSharedNonDurableCreateAndReceive() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable"); + MessageConsumer consumer = session.createSharedConsumer(topic, "nonDurSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("hello-nondurable")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message", msg); + assertEquals("hello-nondurable", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedNonDurableCleanupOnDisconnect() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + BrokerView adminView = broker.getAdminView(); + final int baselineCount = adminView.getTopicSubscribers().length; + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.cleanup"); + MessageConsumer consumer = session.createSharedConsumer(topic, "cleanupSub"); + + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length > baselineCount, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Topic subscriber should be removed after close", + Wait.waitFor(() -> adminView.getTopicSubscribers().length <= baselineCount, + WAIT_TIMEOUT, WAIT_INTERVAL)); + } + + @Test + public void testSharedNonDurableMultipleConsumersRoundRobin() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.nondurable.rr"); + + int messageCount = 20; + CountDownLatch latch = new CountDownLatch(messageCount); + ConcurrentLinkedQueue consumer1Msgs = new ConcurrentLinkedQueue<>(); + ConcurrentLinkedQueue consumer2Msgs = new ConcurrentLinkedQueue<>(); + + MessageConsumer c1 = session1.createSharedConsumer(topic, "ndRRSub"); + c1.setMessageListener(msg -> { + try { + consumer1Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + MessageConsumer c2 = session2.createSharedConsumer(topic, "ndRRSub"); + c2.setMessageListener(msg -> { + try { + consumer2Msgs.add(((TextMessage) msg).getText()); + latch.countDown(); + } catch (JMSException e) { + throw new RuntimeException(e); + } + }); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session producerSession = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + MessageProducer producer = producerSession.createProducer(topic); + for (int i = 0; i < messageCount; i++) { + producer.send(producerSession.createTextMessage("nd-msg-" + i)); + } + + assertTrue("All messages should be received", + latch.await(WAIT_TIMEOUT, TimeUnit.MILLISECONDS)); + + assertEquals("Total messages should match", + messageCount, consumer1Msgs.size() + consumer2Msgs.size()); + assertFalse("Consumer 1 should receive some messages", consumer1Msgs.isEmpty()); + assertFalse("Consumer 2 should receive some messages", consumer2Msgs.isEmpty()); + + Set allReceived = new HashSet<>(consumer1Msgs); + allReceived.addAll(consumer2Msgs); + assertEquals("No duplicates — each message to exactly one consumer", + messageCount, allReceived.size()); + } + + @Test + public void testSharedNonDurableWithSelector() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable.sel"); + MessageConsumer consumer = session.createSharedConsumer( + topic, "ndSelSub", "color = 'green'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + + TextMessage green = session.createTextMessage("green-msg"); + green.setStringProperty("color", "green"); + producer.send(green); + + TextMessage yellow = session.createTextMessage("yellow-msg"); + yellow.setStringProperty("color", "yellow"); + producer.send(yellow); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive green message", msg); + assertEquals("green-msg", ((TextMessage) msg).getText()); + + Message noMsg = consumer.receiveNoWait(); + assertNull("Should NOT receive yellow message", noMsg); + } + + @Test + public void testSharedNonDurableConsumerLeaveRebalance() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.nondurable.rebalance"); + + MessageConsumer c1 = session1.createSharedConsumer(topic, "rebalSub"); + MessageConsumer c2 = session2.createSharedConsumer(topic, "rebalSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Topic subscriber should register", + Wait.waitFor(() -> adminView.getTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + c2.close(); + + MessageProducer producer = session1.createProducer(topic); + for (int i = 0; i < 5; i++) { + producer.send(session1.createTextMessage("solo-" + i)); + } + + for (int i = 0; i < 5; i++) { + Message msg = c1.receive(WAIT_TIMEOUT); + assertNotNull("Remaining consumer should receive all messages, msg " + i, msg); + assertEquals("solo-" + i, ((TextMessage) msg).getText()); + } + } + + @Test(expected = JMSException.class) + public void testSelectorMismatchOnJoinThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + connection2 = factory.createConnection(); + connection2.start(); + + Session session1 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session1.createTopic("test.shared.selmismatch"); + session1.createSharedDurableConsumer(topic, "selMisSub", "color = 'red'"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("First subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Session session2 = connection2.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.selmismatch"); + session2.createSharedDurableConsumer(topic2, "selMisSub", "color = 'blue'"); + } + + @Test(expected = JMSException.class) + public void testSharedToUnsharedTypeConflictThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-conflict"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conflict"); + session.createSharedDurableConsumer(topic, "conflictSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Shared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.createDurableSubscriber(topic, "conflictSub"); + } + + @Test(expected = JMSException.class) + public void testUnsharedToSharedTypeConflictThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("client-conflict2"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conflict2"); + session.createDurableSubscriber(topic, "conflictSub2"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Unshared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.createSharedDurableConsumer(topic, "conflictSub2"); + } + + @Test + public void testSharedDurableWithoutClientId() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nocid"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "noCidSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register without clientId", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("no-cid-msg")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message without clientId", msg); + assertEquals("no-cid-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurableUnsubscribeWhenInactive() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.unsub"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "unsubSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length == 0 + && adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.unsubscribe("unsubSub"); + assertTrue("Inactive subscriber should be removed after unsubscribe", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length == 0, + WAIT_TIMEOUT, WAIT_INTERVAL)); + } + + @Test(expected = JMSException.class) + public void testSharedDurableUnsubscribeWithActiveConsumerThrows() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.unsub.active"); + session.createSharedDurableConsumer(topic, "unsubActiveSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + session.unsubscribe("unsubActiveSub"); + } + + @Test + public void testSharedDurableClientAcknowledge() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.CLIENT_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.clientack"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "clientAckSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("ack-me")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message with CLIENT_ACKNOWLEDGE", msg); + assertEquals("ack-me", ((TextMessage) msg).getText()); + msg.acknowledge(); + } + + @Test + public void testSharedDurableDupsOkAcknowledge() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.DUPS_OK_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.dupsok"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "dupsOkSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("dup-ok-msg")); + + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message with DUPS_OK_ACKNOWLEDGE", msg); + assertEquals("dup-ok-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testConversionUnsharedToSharedWhenEnabled() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-conv1"); + broker.setTopicSubscriptionConversionEnabled(true); + broker.addConnector("vm://shared-jms-conv1"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-conv1"); + + connection1 = factory.createConnection(); + connection1.setClientID("client-conv1"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conv1"); + + MessageConsumer unshared = session.createDurableSubscriber(topic, "convSub1"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Unshared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + unshared.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageConsumer shared = session.createSharedDurableConsumer(topic, "convSub1"); + + assertTrue("Shared subscriber should register after conversion", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("converted-msg")); + + Message msg = shared.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message after unshared-to-shared conversion", msg); + assertEquals("converted-msg", ((TextMessage) msg).getText()); + } + + @Test + public void testConversionSharedToUnsharedWhenEnabled() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + broker = new SharedTopicBrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.setBrokerName("shared-jms-conv2"); + broker.setTopicSubscriptionConversionEnabled(true); + broker.addConnector("vm://shared-jms-conv2"); + broker.start(); + broker.waitUntilStarted(); + + factory = new SharedTopicConnectionFactory("vm://shared-jms-conv2"); + + connection1 = factory.createConnection(); + connection1.setClientID("client-conv2"); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.conv2"); + + MessageConsumer shared = session.createSharedDurableConsumer(topic, "convSub2"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Shared subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + shared.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageConsumer unshared = session.createDurableSubscriber(topic, "convSub2"); + + assertTrue("Unshared subscriber should register after conversion", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("converted-msg-2")); + + Message msg = unshared.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message after shared-to-unshared conversion", msg); + assertEquals("converted-msg-2", ((TextMessage) msg).getText()); + } + + @Test + public void testSharedDurablePersistsAcrossRestart() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + String brokerName = "shared-persist-test"; + broker = createPersistentBroker(brokerName, true); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.persist"); + MessageConsumer consumer = session.createSharedDurableConsumer(topic, "persistSub"); + + BrokerView adminView = broker.getAdminView(); + assertTrue("Subscriber should register", + Wait.waitFor(() -> adminView.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("before-restart")); + Message msg1 = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message before restart", msg1); + + consumer.close(); + assertTrue("Subscriber should become inactive", + Wait.waitFor(() -> adminView.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + producer.send(session.createTextMessage("buffered-across-restart")); + + connection1.close(); + connection1 = null; + broker.stop(); + broker.waitUntilStopped(); + + // Restart broker — preserve data + broker = createPersistentBroker(brokerName, false); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + BrokerView adminView2 = broker.getAdminView(); + assertTrue("Subscription should be restored as inactive after restart", + Wait.waitFor(() -> adminView2.getInactiveDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + connection1 = factory.createConnection(); + connection1.start(); + Session session2 = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic2 = session2.createTopic("test.shared.persist"); + MessageConsumer consumer2 = session2.createSharedDurableConsumer(topic2, "persistSub"); + + assertTrue("Subscriber should reactivate after restart", + Wait.waitFor(() -> adminView2.getDurableTopicSubscribers().length >= 1, + WAIT_TIMEOUT, WAIT_INTERVAL)); + + Message buffered = consumer2.receive(WAIT_TIMEOUT); + assertNotNull("Should receive buffered message after restart", buffered); + assertEquals("buffered-across-restart", ((TextMessage) buffered).getText()); + } + + @Test + public void testSharedNonDurableDoesNotPersistAcrossRestart() throws Exception { + broker.stop(); + broker.waitUntilStopped(); + + String brokerName = "shared-nondurable-persist"; + broker = createPersistentBroker(brokerName, true); + factory = new SharedTopicConnectionFactory("vm://" + brokerName); + + connection1 = factory.createConnection(); + connection1.start(); + + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topic = session.createTopic("test.shared.nondurable.persist"); + MessageConsumer consumer = session.createSharedConsumer(topic, "nonDurPersistSub"); + + MessageProducer producer = session.createProducer(topic); + producer.send(session.createTextMessage("ephemeral-msg")); + Message msg = consumer.receive(WAIT_TIMEOUT); + assertNotNull("Should receive message before restart", msg); + + connection1.close(); + connection1 = null; + broker.stop(); + broker.waitUntilStopped(); + + // Restart broker — preserve data + broker = createPersistentBroker(brokerName, false); + + BrokerView adminView = broker.getAdminView(); + assertEquals("No durable subscriptions should exist after restart", + 0, adminView.getDurableTopicSubscribers().length); + assertEquals("No inactive durable subscriptions should exist after restart", + 0, adminView.getInactiveDurableTopicSubscribers().length); + } + + private SharedTopicBrokerService createPersistentBroker(String name, boolean deleteOnStartup) + throws Exception { + SharedTopicBrokerService b = new SharedTopicBrokerService(); + b.setPersistent(true); + b.setUseJmx(true); + b.setBrokerName(name); + b.setDataDirectory("target/test-data/" + name); + b.setDeleteAllMessagesOnStartup(deleteOnStartup); + b.addConnector("vm://" + name); + b.start(); + b.waitUntilStarted(); + return b; + } + + private static void closeQuietly(Connection connection) { + if (connection != null) { + try { + connection.close(); + } catch (Exception ignored) { + } + } + } +} diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java new file mode 100644 index 00000000000..a9c1e20b290 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/TopicBridgeDemandForwardingTest.java @@ -0,0 +1,261 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.activemq.usecases; + +import static org.junit.Assert.*; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.jms.Connection; +import jakarta.jms.Message; +import jakarta.jms.MessageConsumer; +import jakarta.jms.MessageProducer; +import jakarta.jms.Session; + +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.broker.region.Destination; +import org.apache.activemq.broker.region.Topic; +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.network.DiscoveryNetworkConnector; +import org.apache.activemq.network.NetworkConnector; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +import org.apache.activemq.test.annotations.ParallelTest; +import org.junit.runner.RunWith; +import org.junit.runners.Parameterized; +import org.junit.runners.Parameterized.Parameters; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Parameterized test validating that DemandForwardingBridge correctly + * propagates topic consumer demand across a two-broker network for all + * combinations of subscription type. + * + *

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

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

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