diff --git a/activemq-broker/pom.xml b/activemq-broker/pom.xml index a50a2447f98..8097af6d128 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..84603cd35c5 --- /dev/null +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/SharedTopicRegion.java @@ -0,0 +1,542 @@ +/** + * 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 { + // Jakarta Messaging: a consumer may only join an active shared subscription + // that has the same topic and selector; a different topic is a new + // subscription attempt under a name that is already in use. + if (existing.getDestination() != null && !existing.getDestination().equals(incoming.getDestination())) { + throw new JMSException( + "Topic mismatch for shared subscription '" + + incoming.getSubscriptionName() + + "': existing='" + existing.getDestination() + "', incoming='" + incoming.getDestination() + "'", + ActiveMQErrorCode.TOPIC_MISMATCH); + } + 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/broker/region/policy/PolicyEntry.java b/activemq-broker/src/main/java/org/apache/activemq/broker/region/policy/PolicyEntry.java index 373becfbb1c..c4d29df61af 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/region/policy/PolicyEntry.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/policy/PolicyEntry.java @@ -52,6 +52,7 @@ public class PolicyEntry extends DestinationMapEntry { private DispatchPolicy dispatchPolicy; private SubscriptionRecoveryPolicy subscriptionRecoveryPolicy; private boolean sendAdvisoryIfNoConsumers; + private boolean deliveryDelayEnabled = true; private boolean sendDuplicateFromStoreToDLQ = false; private DeadLetterStrategy deadLetterStrategy = Destination.DEFAULT_DEAD_LETTER_STRATEGY; private PendingMessageLimitStrategy pendingMessageLimitStrategy; @@ -474,6 +475,27 @@ public void setSubscriptionRecoveryPolicy(SubscriptionRecoveryPolicy subscriptio this.subscriptionRecoveryPolicy = subscriptionRecoveryPolicy; } + public boolean isDeliveryDelayEnabled() { + return deliveryDelayEnabled; + } + + /** + * Whether a JMS delivery delay (JMSDeliveryTime) is honoured for this destination. + * Enabled by default. + *

+ * Disabling it makes the broker deliver such messages immediately, leaving + * JMSDeliveryTime on the message so a downstream broker can serve the remaining + * delay -- useful when this broker is only a hop and the delay should be held at + * the target instead. Only the JMS delivery delay is affected; messages using the + * explicit AMQ_SCHEDULED_* properties are still scheduled as before. + *

+ * Note the broker-wide equivalent is {@code schedulerSupport}: with the scheduler + * disabled there is no scheduler in the chain at all, so nothing is ever delayed. + */ + public void setDeliveryDelayEnabled(boolean deliveryDelayEnabled) { + this.deliveryDelayEnabled = deliveryDelayEnabled; + } + public boolean isSendAdvisoryIfNoConsumers() { return sendAdvisoryIfNoConsumers; } diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/scheduler/SchedulerBroker.java b/activemq-broker/src/main/java/org/apache/activemq/broker/scheduler/SchedulerBroker.java index 54bfc743851..55a393cd2b1 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/scheduler/SchedulerBroker.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/scheduler/SchedulerBroker.java @@ -31,6 +31,7 @@ import org.apache.activemq.broker.Connector; import org.apache.activemq.broker.ProducerBrokerExchange; import org.apache.activemq.broker.region.ConnectionStatistics; +import org.apache.activemq.broker.region.policy.PolicyEntry; import org.apache.activemq.command.ActiveMQDestination; import org.apache.activemq.command.Command; import org.apache.activemq.command.ConnectionControl; @@ -62,6 +63,11 @@ public class SchedulerBroker extends BrokerFilter implements JobListener { * The max repeat value allowed to prevent clients from causing DoS issues with huge repeat counts */ private static final int MAX_REPEAT_ALLOWED = 1000; + /** + * Internal property used to carry JMSDeliveryTime across the job store, whose + * wire format may predate the OpenWire v13 field. Removed before delivery. + */ + private static final String DELIVERY_TIME_PROPERTY = "AMQ_SCHEDULED_DELIVERY_TIME"; private final LongSequenceGenerator messageIdGenerator = new LongSequenceGenerator(); private final AtomicBoolean started = new AtomicBoolean(); private final WireFormat wireFormat = new OpenWireFormat(); @@ -72,10 +78,13 @@ public class SchedulerBroker extends BrokerFilter implements JobListener { private final JobSchedulerStore store; private JobScheduler scheduler; private int maxRepeatAllowed = MAX_REPEAT_ALLOWED; + // Retained so a per-destination policy can disable JMS delivery delay. + private final BrokerService brokerService; public SchedulerBroker(BrokerService brokerService, Broker next, JobSchedulerStore store) throws Exception { super(next); + this.brokerService = brokerService; this.store = store; this.producerId.setConnectionId(ID_GENERATOR.generateId()); this.context.setSecurityContext(SecurityContext.BROKER_SECURITY_CONTEXT); @@ -208,6 +217,47 @@ public synchronized JobScheduler getJobScheduler() throws Exception { return new JobSchedulerFacade(this); } + /** + * Converts a message's absolute {@code JMSDeliveryTime} into a relative delay for + * the scheduler, or returns null when the message should be delivered immediately + * -- no delivery time set, the time has already passed, or the destination's policy + * has delivery delay disabled. + */ + private Object resolveDeliveryDelay(Message messageSend) { + var deliveryTime = messageSend.getDeliveryTime(); + if (deliveryTime <= 0) { + return null; + } + var delay = deliveryTime - System.currentTimeMillis(); + // Consult the destination policy only once a delay is actually in play, so + // ordinary sends never pay for the policy lookup. + if (delay > 0 && isDeliveryDelayEnabled(messageSend.getDestination())) { + return Long.valueOf(delay); + } + return null; + } + + /** + * Whether the JMS delivery delay carried on a message should be honoured for the + * given destination, per {@code PolicyEntry.deliveryDelayEnabled} (default true). + * + *

Turning this off lets a broker act purely as a hop: the message is delivered + * onward immediately with its {@code JMSDeliveryTime} intact, so a downstream + * broker can serve the remaining delay instead. This applies only to the JMS + * delivery delay -- the explicit {@code AMQ_SCHEDULED_*} properties are unaffected. + */ + private boolean isDeliveryDelayEnabled(ActiveMQDestination destination) { + if (brokerService == null || destination == null) { + return true; + } + var policyMap = brokerService.getDestinationPolicy(); + if (policyMap == null) { + return true; + } + var entry = policyMap.getEntryFor(destination); + return entry == null || entry.isDeliveryDelayEnabled(); + } + @Override public void start() throws Exception { this.started.set(true); @@ -239,6 +289,16 @@ public void send(ProducerBrokerExchange producerExchange, final Message messageS final Object periodValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_PERIOD); final Object delayValue = messageSend.getProperty(ScheduledMessage.AMQ_SCHEDULED_DELAY); + // The JMS 2.0 delivery delay arrives as an absolute JMSDeliveryTime on the + // message rather than as an AMQ_SCHEDULED_* property. Translate it into a + // relative delay for the scheduler, but only when the sender did not set an + // explicit AMQ_SCHEDULED_* property -- those keep precedence, preserving the + // behaviour of everything that predates delivery delay. + final Object effectiveDelayValue = + (cronValue == null && periodValue == null && delayValue == null) + ? resolveDeliveryDelay(messageSend) + : delayValue; + String physicalName = messageSend.getDestination().getPhysicalName(); boolean schedulerManage = physicalName.regionMatches(true, 0, ScheduledMessage.AMQ_SCHEDULER_MANAGEMENT_DESTINATION, 0, ScheduledMessage.AMQ_SCHEDULER_MANAGEMENT_DESTINATION.length()); @@ -287,7 +347,7 @@ public void send(ProducerBrokerExchange producerExchange, final Message messageS } } - } else if ((cronValue != null || periodValue != null || delayValue != null) && jobId == null) { + } else if (shouldSchedule(cronValue, periodValue, delayValue, effectiveDelayValue, jobId)) { // Check for room in the job scheduler store if (systemUsage.getJobSchedulerUsage() != null) { @@ -319,17 +379,34 @@ public void send(ProducerBrokerExchange producerExchange, final Message messageS context.getTransaction().addSynchronization(new Synchronization() { @Override public void afterCommit() throws Exception { - doSchedule(messageSend, cronValue, periodValue, delayValue); + doSchedule(messageSend, cronValue, periodValue, effectiveDelayValue); } }); } else { - doSchedule(messageSend, cronValue, periodValue, delayValue); + doSchedule(messageSend, cronValue, periodValue, effectiveDelayValue); } } else { super.send(producerExchange, messageSend); } } + /** + * A message the scheduler has already delivered carries a scheduledJobId. For + * property-driven schedules that marker must suppress re-scheduling, otherwise a + * consumer re-sending the message would loop it through the scheduler forever. + * The JMS 2.0 delivery time is different: the producer recomputes JMSDeliveryTime + * on every send, so a stale marker on a re-sent message must not defeat the + * fresh delay. + */ + private static boolean shouldSchedule(Object cronValue, Object periodValue, Object delayValue, + Object effectiveDelayValue, String jobId) { + var propertyDriven = cronValue != null || periodValue != null || delayValue != null; + if (propertyDriven) { + return jobId == null; + } + return effectiveDelayValue != null; + } + private void doSchedule(Message messageSend, Object cronValue, Object periodValue, Object delayValue) throws Exception { long delay = 0; long period = 0; @@ -339,6 +416,14 @@ private void doSchedule(Message messageSend, Object cronValue, Object periodValu // clear transaction context Message msg = messageSend.copy(); msg.setTransactionId(null); + // JMSDeliveryTime only exists on the wire from OpenWire v13, but the job store + // marshals at storeOpenWireVersion (11 by default), so the field would be lost + // across the round-trip. Carry it as a property and restore it on delivery, so + // the delivered message reports the delivery time the sender asked for whatever + // store version is in use. + if (msg.getDeliveryTime() > 0) { + msg.setProperty(DELIVERY_TIME_PROPERTY, Long.valueOf(msg.getDeliveryTime())); + } org.apache.activemq.util.ByteSequence packet = wireFormat.marshal(msg); if (cronValue != null) { cronEntry = cronValue.toString(); @@ -389,6 +474,15 @@ public void scheduledJob(String id, ByteSequence job) { // Add the jobId as a property messageSend.setProperty("scheduledJobId", id); + // Restore the JMSDeliveryTime carried across the job store (see doSchedule). + var carriedDeliveryTime = messageSend.getProperty(DELIVERY_TIME_PROPERTY); + if (carriedDeliveryTime != null) { + var value = (Long) TypeConversionSupport.convert(carriedDeliveryTime, Long.class); + if (value != null) { + messageSend.setDeliveryTime(value.longValue()); + } + messageSend.removeProperty(DELIVERY_TIME_PROPERTY); + } // if this goes across a network - we don't want it rescheduled messageSend.removeProperty(ScheduledMessage.AMQ_SCHEDULED_PERIOD); 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 9db3fc8b56c..4477dd9b470 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 @@ -963,7 +963,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(); @@ -1694,6 +1694,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/ActiveMQConnectionFactory.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQConnectionFactory.java index 93cfb20d309..59ac85c6a2f 100644 --- a/activemq-client/src/main/java/org/apache/activemq/ActiveMQConnectionFactory.java +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQConnectionFactory.java @@ -285,7 +285,7 @@ public TopicConnection createTopicConnection(String userName, String password) t @Override public JMSContext createContext() { try { - return new ActiveMQContext(createActiveMQConnection()); + return newContext(createActiveMQConnection()); } catch (JMSException e) { throw JMSExceptionSupport.convertToJMSRuntimeException(e); } @@ -297,7 +297,7 @@ public JMSContext createContext() { @Override public JMSContext createContext(String userName, String password) { try { - return new ActiveMQContext(createActiveMQConnection(userName, password)); + return newContext(createActiveMQConnection(userName, password)); } catch (JMSException e) { throw JMSExceptionSupport.convertToJMSRuntimeException(e); } @@ -309,7 +309,7 @@ public JMSContext createContext(String userName, String password) { @Override public JMSContext createContext(String userName, String password, int sessionMode) { try { - return new ActiveMQContext(createActiveMQConnection(userName, password), sessionMode); + return newContext(createActiveMQConnection(userName, password), sessionMode); } catch (JMSException e) { throw JMSExceptionSupport.convertToJMSRuntimeException(e); } @@ -321,12 +321,28 @@ public JMSContext createContext(String userName, String password, int sessionMod @Override public JMSContext createContext(int sessionMode) { try { - return new ActiveMQContext(createActiveMQConnection(getUserName(), getPassword()), sessionMode); + return newContext(createActiveMQConnection(getUserName(), getPassword()), sessionMode); } catch (JMSException e) { throw JMSExceptionSupport.convertToJMSRuntimeException(e); } } + /** + * Creates the JMSContext returned by the createContext methods once the + * connection exists. A subclass that needs a different JMSContext type + * overrides this rather than the createContext methods themselves. + */ + protected JMSContext newContext(ActiveMQConnection connection) { + return new ActiveMQContext(connection); + } + + /** + * Session-mode variant of {@link #newContext(ActiveMQConnection)}. + */ + protected JMSContext newContext(ActiveMQConnection connection, int sessionMode) { + return new ActiveMQContext(connection, sessionMode); + } + /** * @return the StatsImpl associated with this ConnectionFactory. */ diff --git a/activemq-client/src/main/java/org/apache/activemq/ActiveMQContext.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQContext.java index 72ef853313b..d3e8465f9b2 100644 --- a/activemq-client/src/main/java/org/apache/activemq/ActiveMQContext.java +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQContext.java @@ -61,8 +61,10 @@ public class ActiveMQContext implements JMSContext { private static final boolean DEFAULT_AUTO_START = true; - private final ActiveMQConnection activemqConnection; - private final AtomicLong connectionCounter; + // protected so a subclass (e.g. SharedJMSContext) can construct a + // same-typed child sharing this connection from newChildContext(int). + protected final ActiveMQConnection activemqConnection; + protected final AtomicLong connectionCounter; protected ActiveMQSession activemqSession = null; // Configuration @@ -86,7 +88,7 @@ public class ActiveMQContext implements JMSContext { this.connectionCounter = new AtomicLong(1l); } - private ActiveMQContext(final ActiveMQConnection activemqConnection, final int sessionMode, final AtomicLong connectionCounter) { + protected ActiveMQContext(final ActiveMQConnection activemqConnection, final int sessionMode, final AtomicLong connectionCounter) { this.activemqConnection = activemqConnection; this.sessionMode = sessionMode; this.connectionCounter = connectionCounter; @@ -99,6 +101,15 @@ public JMSContext createContext(int sessionMode) { } connectionCounter.incrementAndGet(); + return newChildContext(sessionMode); + } + + /** + * Creates the context returned by {@link #createContext(int)}, sharing this + * context's connection and reference count. A subclass overrides this to + * keep its own type for child contexts. + */ + protected JMSContext newChildContext(int sessionMode) { return new ActiveMQContext(activemqConnection, sessionMode, connectionCounter); } 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..00efe1abc11 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQErrorCode.java @@ -0,0 +1,74 @@ +/** + * 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"; + + /** Consumer attempted to join a shared subscription on a different topic. */ + public static final String TOPIC_MISMATCH = "AMQ-50005"; +} diff --git a/activemq-client/src/main/java/org/apache/activemq/ActiveMQMessageProducerSupport.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQMessageProducerSupport.java index ab5e21cf0f8..f0b577b0d86 100644 --- a/activemq-client/src/main/java/org/apache/activemq/ActiveMQMessageProducerSupport.java +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQMessageProducerSupport.java @@ -40,6 +40,7 @@ public abstract class ActiveMQMessageProducerSupport implements MessageProducer, protected int defaultPriority; protected long defaultTimeToLive; protected int sendTimeout=0; + protected long deliveryDelay; public ActiveMQMessageProducerSupport(ActiveMQSession session) { this.session = session; @@ -56,7 +57,8 @@ public ActiveMQMessageProducerSupport(ActiveMQSession session) { */ @Override public void setDeliveryDelay(long deliveryDelay) throws JMSException { - throw new UnsupportedOperationException("setDeliveryDelay() is not supported"); + checkClosed(); + this.deliveryDelay = deliveryDelay; } /** @@ -68,7 +70,8 @@ public void setDeliveryDelay(long deliveryDelay) throws JMSException { */ @Override public long getDeliveryDelay() throws JMSException { - return 0L; + checkClosed(); + return this.deliveryDelay; } /** diff --git a/activemq-client/src/main/java/org/apache/activemq/ActiveMQProducer.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQProducer.java index a1046d85e09..9d0e6169ae4 100644 --- a/activemq-client/src/main/java/org/apache/activemq/ActiveMQProducer.java +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQProducer.java @@ -246,12 +246,21 @@ public long getTimeToLive() { @Override public JMSProducer setDeliveryDelay(long deliveryDelay) { - throw new UnsupportedOperationException("setDeliveryDelay(long) is not supported"); + try { + activemqMessageProducer.setDeliveryDelay(deliveryDelay); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } + return this; } @Override public long getDeliveryDelay() { - return 0L; + try { + return activemqMessageProducer.getDeliveryDelay(); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } } @Override diff --git a/activemq-client/src/main/java/org/apache/activemq/ActiveMQSession.java b/activemq-client/src/main/java/org/apache/activemq/ActiveMQSession.java index dc206850415..3488071cd54 100644 --- a/activemq-client/src/main/java/org/apache/activemq/ActiveMQSession.java +++ b/activemq-client/src/main/java/org/apache/activemq/ActiveMQSession.java @@ -2004,16 +2004,24 @@ protected void send(ActiveMQMessageProducer producer, ActiveMQDestination destin message.setJMSDeliveryMode(deliveryMode); long expiration = 0L; long timeStamp = System.currentTimeMillis(); + + // JMS 2.0: JMSDeliveryTime is the earliest time the message may be + // delivered -- the send time plus the producer's delivery delay. With no + // delay configured this is simply the send time, as before. + var deliveryDelay = producer.getDeliveryDelay(); + var deliveryTime = deliveryDelay > 0 ? timeStamp + deliveryDelay : timeStamp; + if (timeToLive > 0) { - expiration = timeToLive + timeStamp; + // JMS 2.0: time-to-live runs from the delivery time, not the send time, + // so a delayed message still gets its full lifetime once it is delivered. + expiration = timeToLive + deliveryTime; } - // TODO: AMQ-8500 - update this when openwire supports JMSDeliveryTime // ref: ActiveMQMessageTransformation#copyProperties if(!(message instanceof ActiveMQMessage)) { - setForeignMessageDeliveryTime(message, timeStamp); + setForeignMessageDeliveryTime(message, deliveryTime); } else { - message.setJMSDeliveryTime(timeStamp); + message.setJMSDeliveryTime(deliveryTime); } if (!disableMessageTimestamp && !producer.getDisableMessageTimestamp()) { message.setJMSTimestamp(timeStamp); diff --git a/activemq-client/src/main/java/org/apache/activemq/SharedJMSContext.java b/activemq-client/src/main/java/org/apache/activemq/SharedJMSContext.java new file mode 100644 index 00000000000..e651c0a9653 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/SharedJMSContext.java @@ -0,0 +1,136 @@ +/** + * 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.util.concurrent.atomic.AtomicLong; + +import jakarta.jms.InvalidDestinationRuntimeException; +import jakarta.jms.JMSConsumer; +import jakarta.jms.JMSContext; +import jakarta.jms.JMSException; +import jakarta.jms.Topic; + +import org.apache.activemq.annotation.Experimental; +import org.apache.activemq.util.JMSExceptionSupport; + +/** + * A {@link JMSContext} that supports JMS 3.1 shared topic subscriptions. + * + *

The base {@link ActiveMQContext} leaves the four + * {@code createSharedConsumer} / {@code createSharedDurableConsumer} methods as + * unimplemented stubs. This subclass wires them to the underlying session, + * which — because the context is created over a {@link SharedTopicConnection} — + * is a {@link SharedTopicSession} that carries the shared subscription logic. + * + *

Instances are produced by {@link SharedTopicConnectionFactory}'s + * {@code createContext} methods; there is no need to construct one directly. + */ +@Experimental("Tech Preview for JMS 3.1 shared topic subscriptions") +public class SharedJMSContext extends ActiveMQContext { + + SharedJMSContext(final ActiveMQConnection activemqConnection) { + super(activemqConnection); + } + + SharedJMSContext(final ActiveMQConnection activemqConnection, final int sessionMode) { + super(activemqConnection, sessionMode); + } + + private SharedJMSContext(final ActiveMQConnection activemqConnection, final int sessionMode, + final AtomicLong connectionCounter) { + super(activemqConnection, sessionMode, connectionCounter); + } + + /** + * Child contexts created over the same connection keep shared subscription + * support by staying a {@code SharedJMSContext}. + */ + @Override + protected JMSContext newChildContext(int sessionMode) { + return new SharedJMSContext(activemqConnection, sessionMode, connectionCounter); + } + + @Override + public JMSConsumer createSharedConsumer(Topic topic, String sharedSubscriptionName) { + checkContextState(); + if (topic == null) { + throw new InvalidDestinationRuntimeException("Topic cannot be null"); + } + try { + if (getAutoStart()) { + start(); + } + return new ActiveMQConsumer(this, + activemqSession.createSharedConsumer(topic, sharedSubscriptionName)); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } + } + + @Override + public JMSConsumer createSharedConsumer(Topic topic, String sharedSubscriptionName, + String messageSelector) { + checkContextState(); + if (topic == null) { + throw new InvalidDestinationRuntimeException("Topic cannot be null"); + } + try { + if (getAutoStart()) { + start(); + } + return new ActiveMQConsumer(this, + activemqSession.createSharedConsumer(topic, sharedSubscriptionName, messageSelector)); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } + } + + @Override + public JMSConsumer createSharedDurableConsumer(Topic topic, String name) { + checkContextState(); + if (topic == null) { + throw new InvalidDestinationRuntimeException("Topic cannot be null"); + } + try { + if (getAutoStart()) { + start(); + } + return new ActiveMQConsumer(this, + activemqSession.createSharedDurableConsumer(topic, name)); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } + } + + @Override + public JMSConsumer createSharedDurableConsumer(Topic topic, String name, + String messageSelector) { + checkContextState(); + if (topic == null) { + throw new InvalidDestinationRuntimeException("Topic cannot be null"); + } + try { + if (getAutoStart()) { + start(); + } + return new ActiveMQConsumer(this, + activemqSession.createSharedDurableConsumer(topic, name, messageSelector)); + } catch (JMSException e) { + throw JMSExceptionSupport.convertToJMSRuntimeException(e); + } + } +} 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..03dc850b320 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/SharedTopicConnectionFactory.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; + +import java.net.URI; + +import jakarta.jms.JMSContext; + +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; + } + + // Route JMSContext creation to SharedJMSContext so the JMS 3.1 simplified + // API (createSharedConsumer / createSharedDurableConsumer) works, rather + // than the base ActiveMQContext whose shared-consumer methods are stubs. + + @Override + protected JMSContext newContext(ActiveMQConnection connection) { + return new SharedJMSContext(connection); + } + + @Override + protected JMSContext newContext(ActiveMQConnection connection, int sessionMode) { + return new SharedJMSContext(connection, sessionMode); + } +} 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 11ccd306fa4..8498ee76c9f 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 @@ -828,6 +828,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/SharedJMSContextTest.java b/activemq-client/src/test/java/org/apache/activemq/SharedJMSContextTest.java new file mode 100644 index 00000000000..b5c86a460f8 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/SharedJMSContextTest.java @@ -0,0 +1,162 @@ +/** + * 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.InvalidDestinationRuntimeException; +import jakarta.jms.JMSConsumer; +import jakarta.jms.JMSContext; +import jakarta.jms.Session; + +import org.apache.activemq.command.ActiveMQTopic; +import org.apache.activemq.command.ConsumerInfo; +import org.apache.activemq.command.SharedConsumerInfo; +import org.apache.activemq.transport.Transport; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that {@link SharedTopicConnectionFactory} produces a + * {@link SharedJMSContext} whose JMS 3.1 simplified-API shared-consumer methods + * route through to the underlying {@link SharedTopicSession}, emitting a + * {@link SharedConsumerInfo} to the broker rather than throwing. + */ +public class SharedJMSContextTest { + + private StubTransport transport; + private SharedTopicConnectionFactory factory; + private JMSContext context; + + @Before + public void setUp() { + transport = new StubTransport(); + factory = new SharedTopicConnectionFactory("tcp://localhost:61616") { + @Override + protected Transport createTransport() { + return transport; + } + }; + } + + @After + public void tearDown() { + if (context != null) { + context.close(); + } + } + + @Test + public void testFactoryCreatesSharedJMSContext() { + context = factory.createContext(); + assertTrue("Factory should create a SharedJMSContext", + context instanceof SharedJMSContext); + } + + @Test + public void testFactoryCreatesSharedJMSContextForAllOverloads() { + assertTrue(factory.createContext() instanceof SharedJMSContext); + assertTrue(factory.createContext(Session.AUTO_ACKNOWLEDGE) instanceof SharedJMSContext); + // Credential overloads take the same routing path. + assertTrue(factory.createContext("u", "p") instanceof SharedJMSContext); + assertTrue(factory.createContext("u", "p", Session.AUTO_ACKNOWLEDGE) instanceof SharedJMSContext); + } + + @Test + public void testChildContextStaysShared() { + context = factory.createContext(); + JMSContext child = context.createContext(Session.AUTO_ACKNOWLEDGE); + try { + assertTrue("createContext(int) must return a SharedJMSContext, not a plain one", + child instanceof SharedJMSContext); + } finally { + child.close(); + } + } + + @Test + public void testCreateSharedConsumerEmitsSharedConsumerInfo() { + context = factory.createContext(); + JMSConsumer consumer = context.createSharedConsumer(new ActiveMQTopic("test.topic"), "mySub"); + assertNotNull(consumer); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull("createSharedConsumer must reach the broker as a SharedConsumerInfo", sent); + assertTrue(sent.isShared()); + assertFalse("Non-durable shared consumer", sent.isDurable()); + assertEquals("mySub", sent.getSubscriptionName()); + } + + @Test + public void testCreateSharedConsumerWithSelector() { + context = factory.createContext(); + context.createSharedConsumer(new ActiveMQTopic("test.topic"), "mySub", "color = 'red'"); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull(sent); + assertTrue(sent.isShared()); + assertEquals("color = 'red'", sent.getSelector()); + } + + @Test + public void testCreateSharedDurableConsumerEmitsDurableSharedConsumerInfo() { + context = factory.createContext(); + context.setClientID("cts"); + context.createSharedDurableConsumer(new ActiveMQTopic("test.topic"), "myDurableSub"); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull("createSharedDurableConsumer must reach the broker as a SharedConsumerInfo", sent); + assertTrue(sent.isShared()); + assertTrue("Durable shared consumer", sent.isDurable()); + assertEquals("myDurableSub", sent.getSubscriptionName()); + } + + @Test + public void testCreateSharedDurableConsumerWithSelector() { + context = factory.createContext(); + context.setClientID("cts"); + context.createSharedDurableConsumer(new ActiveMQTopic("test.topic"), "myDurableSub", "n > 1"); + + SharedConsumerInfo sent = findSharedConsumerInfo(); + assertNotNull(sent); + assertTrue(sent.isShared()); + assertTrue(sent.isDurable()); + assertEquals("n > 1", sent.getSelector()); + } + + @Test(expected = InvalidDestinationRuntimeException.class) + public void testCreateSharedConsumerNullTopicThrows() { + context = factory.createContext(); + context.createSharedConsumer(null, "mySub"); + } + + @Test(expected = InvalidDestinationRuntimeException.class) + public void testCreateSharedDurableConsumerNullTopicThrows() { + context = factory.createContext(); + context.createSharedDurableConsumer(null, "mySub"); + } + + private SharedConsumerInfo findSharedConsumerInfo() { + for (Object cmd : transport.getSent()) { + if (cmd instanceof SharedConsumerInfo) { + return (SharedConsumerInfo) cmd; + } + } + return null; + } +} 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 42fd391a7e5..9a7c368ecba 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-ra/src/main/java/org/apache/activemq/ra/InboundMessageProducerProxy.java b/activemq-ra/src/main/java/org/apache/activemq/ra/InboundMessageProducerProxy.java index 7f8743cb30e..38ddc3f2fb6 100644 --- a/activemq-ra/src/main/java/org/apache/activemq/ra/InboundMessageProducerProxy.java +++ b/activemq-ra/src/main/java/org/apache/activemq/ra/InboundMessageProducerProxy.java @@ -197,27 +197,28 @@ public void send(Destination destination, Message message, int deliveryMode, int } /** - * Gets the delivery delay associated with this MessageProducer. + * Sets the delivery delay on the wrapped MessageProducer. * - * @return this producer's DeliveryDely/ - * @throws JMSException if the JMS provider fails to close the producer due to + * @param deliveryDelay the delivery delay in milliseconds + * @throws JMSException if the JMS provider fails to set the delivery delay due to * some internal error. * @since 2.0 */ @Override public void setDeliveryDelay(long deliveryDelay) throws JMSException { - throw new UnsupportedOperationException("setDeliveryDelay() is not supported"); + messageProducer.setDeliveryDelay(deliveryDelay); } /** - * Gets the delivery delay value for this MessageProducer. + * Gets the delivery delay value of the wrapped MessageProducer. * * @return the delivery delay for this messageProducer - * @throws jakarta.jms.JMSException if the JMS provider fails to determine if deliver delay is - * disabled due to some internal error. + * @throws jakarta.jms.JMSException if the JMS provider fails to determine the delivery delay + * due to some internal error. + * @since 2.0 */ @Override public long getDeliveryDelay() throws JMSException { - return 0L; + return messageProducer.getDeliveryDelay(); } } 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-tooling/activemq-jakarta-messaging-tck/src/main/java/org/apache/activemq/tck/JNDIInitialContextFactory.java b/activemq-tooling/activemq-jakarta-messaging-tck/src/main/java/org/apache/activemq/tck/JNDIInitialContextFactory.java index a95a86eeb23..c66ed0066df 100644 --- a/activemq-tooling/activemq-jakarta-messaging-tck/src/main/java/org/apache/activemq/tck/JNDIInitialContextFactory.java +++ b/activemq-tooling/activemq-jakarta-messaging-tck/src/main/java/org/apache/activemq/tck/JNDIInitialContextFactory.java @@ -28,8 +28,10 @@ import javax.naming.NamingException; import javax.naming.spi.InitialContextFactory; +import org.apache.activemq.ActiveMQPrefetchPolicy; +import org.apache.activemq.broker.SharedTopicBrokerService; +import org.apache.activemq.SharedTopicConnectionFactory; import org.apache.activemq.ActiveMQConnectionFactory; -import org.apache.activemq.broker.BrokerService; import org.apache.activemq.command.ActiveMQQueue; import org.apache.activemq.command.ActiveMQTopic; import org.slf4j.Logger; @@ -47,7 +49,7 @@ public class JNDIInitialContextFactory implements InitialContextFactory { private static final String BROKER_URL = "vm://localhost"; - private static volatile BrokerService broker; + private static volatile SharedTopicBrokerService broker; private static final Object BROKER_LOCK = new Object(); private static final Set QUEUE_NAMES = Set.of( @@ -95,8 +97,35 @@ public Context getInitialContext(final Hashtable environment) throws Namin } private static ActiveMQConnectionFactory createConnectionFactory(final String clientId) { - final ActiveMQConnectionFactory factory = new ActiveMQConnectionFactory(BROKER_URL); + final ActiveMQPrefetchPolicy prefetchPolicy = new ActiveMQPrefetchPolicy(); + prefetchPolicy.setAll(0); + prefetchPolicy.setQueuePrefetch(1); + prefetchPolicy.setQueueBrowserPrefetch(0); + prefetchPolicy.setTopicPrefetch(1); + // Durable-topic prefetch must be >= 1. Shared durable consumers read + // durableTopicPrefetch; at 0 they become pull consumers, and the pull + // path hangs for shared durable subscriptions (receive() never returns). + // 1 also satisfies async consumers (MessageListener/CompletionListener), + // which reject a prefetch of 0. + prefetchPolicy.setDurableTopicPrefetch(1); + prefetchPolicy.setOptimizeDurableTopicPrefetch(1); + final ActiveMQConnectionFactory factory = new SharedTopicConnectionFactory(BROKER_URL); + factory.setPrefetchPolicy(prefetchPolicy); factory.setNestedMapAndListEnabled(false); + // The TCK sends JDK types (e.g. StringBuffer) as ObjectMessage payloads. + // ActiveMQ's ObjectMessage deserialization security rejects untrusted + // classes by default; trust all packages for the test harness so the + // ObjectMessage round-trip tests can deserialize their payloads. + factory.setTrustAllPackages(true); + // Match the client to the broker, which runs with advisorySupport=false. + // ActiveMQConnection only populates activeTempDestinations from temp-destination + // advisories, and isDeleted() consults that set whenever an advisory consumer + // exists. Watching advisories the broker never publishes leaves the set + // permanently empty, so every *other* connection's TemporaryQueue/Topic reads as + // deleted and sending to a peer's temp destination -- legal per JMS, and the + // basis of request/reply -- fails with InvalidDestinationException. Not watching + // restores isDeleted()'s intended permissive branch. + factory.setWatchTopicAdvisories(false); if (clientId != null) { factory.setClientID(clientId); } @@ -112,11 +141,14 @@ private static void ensureBrokerStarted() { return; } try { - final BrokerService bs = new BrokerService(); + final SharedTopicBrokerService bs = new SharedTopicBrokerService(); bs.setBrokerName("localhost"); bs.setPersistent(false); bs.setUseJmx(false); bs.setAdvisorySupport(false); + // JMS 2.0 delivery delay is implemented by the scheduler broker; the + // non-persistent broker uses the in-memory job scheduler store. + bs.setSchedulerSupport(true); bs.start(); bs.waitUntilStarted(); broker = bs; diff --git a/activemq-tooling/activemq-jakarta-messaging-tck/ts.jtx b/activemq-tooling/activemq-jakarta-messaging-tck/ts.jtx index b55c2b40dcc..c94ac4a6ce5 100644 --- a/activemq-tooling/activemq-jakarta-messaging-tck/ts.jtx +++ b/activemq-tooling/activemq-jakarta-messaging-tck/ts.jtx @@ -22,3 +22,32 @@ # TCK bug: variable never assigned before use com/sun/ts/tests/jms/core/messageFormatRuntimeExceptionTests#messageFormatRuntimeExceptionTests_from_standalone + +# Anonymous producer non-compliance (pre-existing; unrelated to shared subscriptions). +# +# These tests create a producer with a null destination -- createSender(null), +# createPublisher(null) -- and require the destination-less send(Message) to throw +# UnsupportedOperationException, per the MessageProducer.send javadoc: "if a client +# uses this method with a MessageProducer that did not specify a destination at +# creation time". +# +# ActiveMQ instead throws InvalidDestinationException("A destination must be +# specified.") from ActiveMQMessageProducer.send(Destination, Message, ...). The +# tests do not see it, because UnsupportedOperationException is a RuntimeException +# while InvalidDestinationException is a JMSException, so the catch never matches. +# +# Reproduces on a stock ActiveMQConnectionFactory against a stock BrokerService with +# no shared-subscription classes involved. A fix belongs in the two destination-less +# overloads of ActiveMQMessageProducerSupport (send(Message) and +# send(Message, int, int, long)), which delegate with a null getDestination(). +# Deferred: changing a checked JMSException to an unchecked +# UnsupportedOperationException on this API is a behaviour change for all users and +# warrants its own issue. +com/sun/ts/tests/jms/core/exceptionQueue/ExceptionQueueTests.java#xUnsupportedOperationExceptionQTest1_from_standalone +com/sun/ts/tests/jms/core/exceptionQueue/ExceptionQueueTests.java#xUnsupportedOperationExceptionQTest2_from_standalone +com/sun/ts/tests/jms/core/exceptionTopic/ExceptionTopicTests.java#xUnsupportedOperationExceptionTTest1_from_standalone + +# ActiveMQ dispatches queue messages to consumers on stopped connections. +# JmsTool creates a competing consumer whose connection is never started; +# round-robin assigns the message there and JMSContext.receive() blocks forever. +com/sun/ts/tests/jms/core20/jmsconsumertests/Client.java#queueReceiveTests_from_standalone diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/ActiveMQJMS2ContextTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/ActiveMQJMS2ContextTest.java index 5334546aa56..bc8eae051be 100644 --- a/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/ActiveMQJMS2ContextTest.java +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/ActiveMQJMS2ContextTest.java @@ -294,14 +294,16 @@ public void testProducerDeliveryDelayGet() throws JMSException { assertEquals(0L, messageProducer.getDeliveryDelay()); } - @Test(expected = UnsupportedOperationException.class) + @Test public void testProducerDeliveryDelaySetZero() throws JMSException { messageProducer.setDeliveryDelay(0L); + assertEquals(0L, messageProducer.getDeliveryDelay()); } - @Test(expected = UnsupportedOperationException.class) + @Test public void testProducerDeliveryDelaySet() throws JMSException { messageProducer.setDeliveryDelay(1000l); + assertEquals(1000L, messageProducer.getDeliveryDelay()); } @Test(expected = UnsupportedOperationException.class) diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/DeliveryDelayNonPersistentBrokerTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/DeliveryDelayNonPersistentBrokerTest.java new file mode 100644 index 00000000000..c44e7edec63 --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/jms2/DeliveryDelayNonPersistentBrokerTest.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.jms2; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import jakarta.jms.Message; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.broker.BrokerService; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * JMS 2.0 delivery delay is implemented by the scheduler broker, which the + * embedded TCK broker runs without persistence. This pins the in-memory job + * scheduler path: a non-persistent broker with schedulerSupport enabled must + * hold a delayed message for the full delay. + */ +public class DeliveryDelayNonPersistentBrokerTest { + + private static final long DELIVERY_DELAY_MS = 3000; + + private BrokerService broker; + + @Before + public void setUp() throws Exception { + broker = new BrokerService(); + broker.setPersistent(false); + broker.setUseJmx(false); + broker.setAdvisorySupport(false); + broker.setSchedulerSupport(true); + broker.addConnector("vm://localhost"); + broker.start(); + broker.waitUntilStarted(); + } + + @After + public void tearDown() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + } + } + + @Test(timeout = 60000) + public void testDelayedMessagesHeldWithPrefetchOneAcrossDeliveryModes() throws Exception { + // TCK jmsproducer deliveryDelayTest shape: prefetch 1, a message property, + // a PERSISTENT send followed by a NON_PERSISTENT send on the same producer, + // each expected to be invisible to receive() until the delay elapses + var factory = new ActiveMQConnectionFactory("vm://localhost"); + factory.getPrefetchPolicy().setAll(0); + factory.getPrefetchPolicy().setQueuePrefetch(1); + factory.getPrefetchPolicy().setTopicPrefetch(1); + + try (var context = factory.createContext()) { + var queue = context.createQueue("test.delivery.delay.prefetch.one"); + var consumer = context.createConsumer(queue); + var producer = context.createProducer().setDeliveryDelay(DELIVERY_DELAY_MS); + + for (int deliveryMode : new int[] {jakarta.jms.DeliveryMode.PERSISTENT, jakarta.jms.DeliveryMode.NON_PERSISTENT}) { + producer.setDeliveryMode(deliveryMode); + var message = context.createTextMessage("delayed " + deliveryMode); + message.setStringProperty("COM_SUN_JMS_TESTNAME", "deliveryDelayTest"); + producer.send(queue, message); + + assertNull("Mode " + deliveryMode + ": message must not be delivered before the delay elapses", + consumer.receive(DELIVERY_DELAY_MS / 2)); + assertNotNull("Mode " + deliveryMode + ": message must be delivered after the delay", + consumer.receive(DELIVERY_DELAY_MS * 3)); + } + } + } + + @Test(timeout = 60000) + public void testResentDelayedMessageIsDelayedAgain() throws Exception { + // TCK deliveryDelayTest re-sends the received message object for its second + // leg. The delivered copy carries the scheduler's scheduledJobId marker, which + // must not suppress the fresh delay the producer applies on the re-send. + var factory = new ActiveMQConnectionFactory("vm://localhost"); + try (var context = factory.createContext()) { + var queue = context.createQueue("test.delivery.delay.resend"); + var consumer = context.createConsumer(queue); + var producer = context.createProducer().setDeliveryDelay(DELIVERY_DELAY_MS); + + producer.send(queue, "first"); + var received = consumer.receive(DELIVERY_DELAY_MS * 3); + assertNotNull(received); + + producer.setDeliveryMode(jakarta.jms.DeliveryMode.NON_PERSISTENT); + producer.send(queue, received); + assertNull("Re-sent message must honor the producer's delivery delay", + consumer.receive(DELIVERY_DELAY_MS / 2)); + assertNotNull("Re-sent message must arrive after the delay", + consumer.receive(DELIVERY_DELAY_MS * 3)); + } + } + + @Test(timeout = 60000) + public void testDelayedMessageIsHeldForTheDelay() throws Exception { + var factory = new ActiveMQConnectionFactory("vm://localhost"); + try (var context = factory.createContext()) { + var queue = context.createQueue("test.delivery.delay.nonpersistent"); + var consumer = context.createConsumer(queue); + + var sentAt = System.currentTimeMillis(); + context.createProducer().setDeliveryDelay(DELIVERY_DELAY_MS).send(queue, "delayed"); + + assertNull("Message must not be delivered before the delivery delay elapses", + consumer.receive(DELIVERY_DELAY_MS / 2)); + + var received = consumer.receive(DELIVERY_DELAY_MS * 3); + assertNotNull("Delayed message must be delivered after the delay", received); + assertTrue("Delivered before the delay elapsed", + System.currentTimeMillis() - sentAt >= DELIVERY_DELAY_MS); + assertTrue("JMSDeliveryTime must reflect the delay", + received.getJMSDeliveryTime() >= sentAt + DELIVERY_DELAY_MS); + } + } +} 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..c05965641df --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/usecases/SharedSubscriptionJmsTest.java @@ -0,0 +1,835 @@ +/** + * 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) { + } + } + } + + @Test + public void testSharedConsumerSameNameDifferentTopicRejected() throws Exception { + connection1 = factory.createConnection(); + connection1.start(); + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topicA = session.createTopic("test.shared.mismatch.a"); + Topic topicB = session.createTopic("test.shared.mismatch.b"); + + // Jakarta Messaging: joining an active shared subscription with the same + // name but a different topic must fail + MessageConsumer first = session.createSharedConsumer(topicA, "mismatchSub"); + try { + session.createSharedConsumer(topicB, "mismatchSub"); + fail("Expected JMSException joining a shared subscription on a different topic"); + } catch (JMSException expected) { + assertEquals(org.apache.activemq.ActiveMQErrorCode.TOPIC_MISMATCH, expected.getErrorCode()); + } + // the original subscription is unaffected + assertNotNull(first); + first.close(); + } + + @Test + public void testSharedDurableConsumerSameNameDifferentTopicRejected() throws Exception { + connection1 = factory.createConnection(); + connection1.setClientID("mismatch-durable-client"); + connection1.start(); + Session session = connection1.createSession(false, Session.AUTO_ACKNOWLEDGE); + Topic topicA = session.createTopic("test.shared.durable.mismatch.a"); + Topic topicB = session.createTopic("test.shared.durable.mismatch.b"); + + MessageConsumer first = session.createSharedDurableConsumer(topicA, "mismatchDurSub"); + try { + session.createSharedDurableConsumer(topicB, "mismatchDurSub"); + fail("Expected JMSException joining an active shared durable subscription on a different topic"); + } catch (JMSException expected) { + assertEquals(org.apache.activemq.ActiveMQErrorCode.TOPIC_MISMATCH, expected.getErrorCode()); + } + first.close(); + session.unsubscribe("mismatchDurSub"); + } +} 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); + } + } + } +}