This is an automated email from the ASF dual-hosted git repository.

cshannon pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/activemq.git


The following commit(s) were added to refs/heads/main by this push:
     new 5dcc72b779 feat(#1798) Performance fix for topic send (#1792)
5dcc72b779 is described below

commit 5dcc72b779c3b51b32bbad3daa6b44a7f19648b4
Author: Jean-Louis Monteiro <[email protected]>
AuthorDate: Thu Jul 16 16:44:01 2026 +0200

    feat(#1798) Performance fix for topic send (#1792)
    
    This change replaces synchronized with a ReentrantLock held only during 
persistence (to guarantee message ordering as javadoc was mentioning). Dispatch 
to subscribers and result.get() happen outside the lock, allowing concurrent 
dispatch for messages from different producers.
    
    Closes #1798
---
 .../org/apache/activemq/broker/region/Topic.java   | 111 +++--
 activemq-unit-tests/pom.xml                        |  14 +
 .../broker/region/TopicSendLockBenchmark.java      | 455 +++++++++++++++++++++
 3 files changed, 537 insertions(+), 43 deletions(-)

diff --git 
a/activemq-broker/src/main/java/org/apache/activemq/broker/region/Topic.java 
b/activemq-broker/src/main/java/org/apache/activemq/broker/region/Topic.java
index d4c60aa742..714f95ef69 100644
--- a/activemq-broker/src/main/java/org/apache/activemq/broker/region/Topic.java
+++ b/activemq-broker/src/main/java/org/apache/activemq/broker/region/Topic.java
@@ -29,6 +29,7 @@ import java.util.concurrent.ConcurrentMap;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.Future;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.locks.ReentrantLock;
 import java.util.concurrent.locks.ReentrantReadWriteLock;
 
 import java.util.stream.Collectors;
@@ -89,6 +90,7 @@ public class Topic extends BaseDestination implements Task {
     private final ConcurrentMap<SubscriptionKey, DurableTopicSubscription> 
durableSubscribers = new ConcurrentHashMap<SubscriptionKey, 
DurableTopicSubscription>();
     private final TaskRunner taskRunner;
     private final TaskRunnerFactory taskRunnerFactor;
+    private final ReentrantLock sendLock = new ReentrantLock();
     private final LinkedList<Runnable> messagesWaitingForSpace = new 
LinkedList<Runnable>();
     private final Runnable sendMessagesWaitingForSpaceTask = new Runnable() {
         @Override
@@ -101,7 +103,7 @@ public class Topic extends BaseDestination implements Task {
     };
 
     public Topic(BrokerService brokerService, ActiveMQDestination destination, 
TopicMessageStore store,
-            DestinationStatistics parentStats, TaskRunnerFactory taskFactory) 
throws Exception {
+                 DestinationStatistics parentStats, TaskRunnerFactory 
taskFactory) throws Exception {
         super(brokerService, store, destination, parentStats);
         this.topicStore = store;
         subscriptionRecoveryPolicy = new 
RetainedMessageSubscriptionRecoveryPolicy(null);
@@ -523,40 +525,59 @@ public class Topic extends BaseDestination implements 
Task {
     }
 
     /**
-     * do send the message - this needs to be synchronized to ensure messages
-     * are stored AND dispatched in the right order
+     * Sends a message to this topic. Uses a ReentrantLock instead of
+     * synchronized to avoid lock convoys and biased locking overhead under
+     * high contention (many concurrent producers with slow persistence or
+     * many subscribers).
+     *
+     * The write lock is held only during persistence (to guarantee message
+     * ordering via brokerSequenceId). Dispatch to subscribers and persistence
+     * completion wait happen outside the lock, allowing concurrent dispatch
+     * for messages from different producers.
+     *
+     * This is valid per Jakarta Messaging 3.1 Section 6.2.9: message ordering
+     * is guaranteed per-session/per-producer only. A JMS Session is not
+     * thread-safe, so a single producer cannot have concurrent send() calls.
      *
      * @param producerExchange
      * @param message
      * @throws IOException
      * @throws Exception
      */
-    synchronized void doMessageSend(final ProducerBrokerExchange 
producerExchange, final Message message)
+    void doMessageSend(final ProducerBrokerExchange producerExchange, final 
Message message)
             throws IOException, Exception {
         final ConnectionContext context = 
producerExchange.getConnectionContext();
-        message.getMessageId().setBrokerSequenceId(getDestinationSequenceId());
         Future<Object> result = null;
 
-        if (topicStore != null && message.isPersistent() && 
!canOptimizeOutPersistence()) {
-            if 
(systemUsage.getStoreUsage().isFull(getStoreUsageHighWaterMark())) {
-                final String logMessage = "Persistent store is Full, " + 
getStoreUsageHighWaterMark() + "% of "
-                        + systemUsage.getStoreUsage().getLimit() + ". Stopping 
producer (" + message.getProducerId()
-                        + ") to prevent flooding " + 
getActiveMQDestination().getQualifiedName() + "."
-                        + " See 
http://activemq.apache.org/producer-flow-control.html for more info";
-                if (!context.isNetworkConnection() && 
systemUsage.isSendFailIfNoSpace()) {
-                    throw new 
jakarta.jms.ResourceAllocationException(logMessage);
-                }
+        // Write lock: serialize persistence for message ordering
+        sendLock.lock();
+        try {
+            
message.getMessageId().setBrokerSequenceId(getDestinationSequenceId());
 
-                waitForSpace(context,producerExchange, 
systemUsage.getStoreUsage(), getStoreUsageHighWaterMark(), logMessage);
+            if (topicStore != null && message.isPersistent() && 
!canOptimizeOutPersistence()) {
+                if 
(systemUsage.getStoreUsage().isFull(getStoreUsageHighWaterMark())) {
+                    final String logMessage = "Persistent store is Full, " + 
getStoreUsageHighWaterMark() + "% of "
+                            + systemUsage.getStoreUsage().getLimit() + ". 
Stopping producer (" + message.getProducerId()
+                            + ") to prevent flooding " + 
getActiveMQDestination().getQualifiedName() + "."
+                            + " See 
http://activemq.apache.org/producer-flow-control.html for more info";
+                    if (!context.isNetworkConnection() && 
systemUsage.isSendFailIfNoSpace()) {
+                        throw new 
jakarta.jms.ResourceAllocationException(logMessage);
+                    }
+
+                    waitForSpace(context, producerExchange, 
systemUsage.getStoreUsage(), getStoreUsageHighWaterMark(), logMessage);
+                }
+                result = topicStore.asyncAddTopicMessage(context, message, 
isOptimizeStorage());
             }
-            result = topicStore.asyncAddTopicMessage(context, 
message,isOptimizeStorage());
 
-            //Moved the reduceMemoryfootprint clearing to the dispatch method
+            message.incrementReferenceCount();
+        } finally {
+            sendLock.unlock();
         }
 
-        message.incrementReferenceCount();
-
+        // Dispatch and persistence wait outside the lock — concurrent for
+        // messages from different producers
         if (context.isInTransaction() && (context.getTransaction() != null)) {
+            final Future<Object> pendingResult = result;
             context.getTransaction().addSynchronization(new Synchronization() {
                 @Override
                 public void afterCommit() throws Exception {
@@ -576,6 +597,7 @@ public class Topic extends BaseDestination implements Task {
                     } finally {
                         message.decrementReferenceCount();
                     }
+                    awaitPersistence(pendingResult);
                 }
 
                 @Override
@@ -590,8 +612,11 @@ public class Topic extends BaseDestination implements Task 
{
             } finally {
                 message.decrementReferenceCount();
             }
+            awaitPersistence(result);
         }
+    }
 
+    private void awaitPersistence(final Future<Object> result) throws 
Exception {
         if (result != null && !result.isCancelled()) {
             try {
                 result.get();
@@ -613,14 +638,14 @@ public class Topic extends BaseDestination implements 
Task {
 
     @Override
     public void acknowledge(ConnectionContext context, Subscription sub, final 
MessageAck ack,
-            final MessageReference node) throws IOException {
+                            final MessageReference node) throws IOException {
         if (topicStore != null && node.isPersistent()) {
             if (sub instanceof DurableTopicSubscription) {
                 DurableTopicSubscription dsub = (DurableTopicSubscription) sub;
                 SubscriptionKey key = dsub.getSubscriptionKey();
                 topicStore.acknowledge(context, key.getClientId(), 
key.getSubscriptionName(),
-                    node.getMessageId(),
-                    convertToNonRangedAck(ack, node));
+                        node.getMessageId(),
+                        convertToNonRangedAck(ack, node));
             }
         }
         messageConsumed(context, node);
@@ -903,8 +928,8 @@ public class Topic extends BaseDestination implements Task {
 
                 // get the sub keys that should be checked for expired messages
                 final var subs = durableSubscribers.entrySet().stream()
-                    .filter(entry -> isEligibleForExpiration(entry.getValue()))
-                    .map(Entry::getKey).collect(Collectors.toSet());
+                        .filter(entry -> 
isEligibleForExpiration(entry.getValue()))
+                        .map(Entry::getKey).collect(Collectors.toSet());
 
                 if (subs.isEmpty()) {
                     LOG.debug("Skipping topic expiration check for {}, no 
eligible subscriptions to check", destination);
@@ -914,7 +939,7 @@ public class Topic extends BaseDestination implements Task {
                 // For each eligible subscription, return the messages in the 
store that are expired
                 // The same message refs are shared between subs if duplicated 
so this is efficient
                 var expired = store.recoverExpired(subs, 
getMaxExpirePageSize(),
-                    expiryListener);
+                        expiryListener);
 
                 final ConnectionContext connectionContext = 
createConnectionContext();
                 // Go through any expired messages and remove for each sub
@@ -992,25 +1017,25 @@ public class Topic extends BaseDestination implements 
Task {
         boolean result = false;
 
         if (isDoOptimzeMessageStorage() && 
durableSubscribers.isEmpty()==false){
-                result = true;
-                for (DurableTopicSubscription s : durableSubscribers.values()) 
{
-                    if (s.isActive()== false){
-                        result = false;
-                        break;
-                    }
-                    if (s.getPrefetchSize()==0){
-                        result = false;
-                        break;
-                    }
-                    if (s.isSlowConsumer()){
-                        result = false;
-                        break;
-                    }
-                    if (s.getInFlightUsage() > 
getOptimizeMessageStoreInFlightLimit()){
-                        result = false;
-                        break;
-                    }
+            result = true;
+            for (DurableTopicSubscription s : durableSubscribers.values()) {
+                if (s.isActive()== false){
+                    result = false;
+                    break;
+                }
+                if (s.getPrefetchSize()==0){
+                    result = false;
+                    break;
+                }
+                if (s.isSlowConsumer()){
+                    result = false;
+                    break;
                 }
+                if (s.getInFlightUsage() > 
getOptimizeMessageStoreInFlightLimit()){
+                    result = false;
+                    break;
+                }
+            }
         }
         return result;
     }
diff --git a/activemq-unit-tests/pom.xml b/activemq-unit-tests/pom.xml
index 8a89ea9d3d..43dd95cc85 100644
--- a/activemq-unit-tests/pom.xml
+++ b/activemq-unit-tests/pom.xml
@@ -278,6 +278,20 @@
       <artifactId>mockito-core</artifactId>
       <scope>test</scope>
     </dependency>
+
+    <!-- JMH for micro-benchmarks (excluded from CI via naming convention) -->
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-core</artifactId>
+      <version>1.37</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.openjdk.jmh</groupId>
+      <artifactId>jmh-generator-annprocess</artifactId>
+      <version>1.37</version>
+      <scope>test</scope>
+    </dependency>
   </dependencies>
 
   <reporting>
diff --git 
a/activemq-unit-tests/src/test/java/org/apache/activemq/broker/region/TopicSendLockBenchmark.java
 
b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/region/TopicSendLockBenchmark.java
new file mode 100644
index 0000000000..aa3fbdd61b
--- /dev/null
+++ 
b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/region/TopicSendLockBenchmark.java
@@ -0,0 +1,455 @@
+/**
+ * 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.List;
+import java.util.concurrent.TimeUnit;
+
+import jakarta.jms.Connection;
+import jakarta.jms.DeliveryMode;
+import jakarta.jms.MessageConsumer;
+import jakarta.jms.MessageProducer;
+import jakarta.jms.Queue;
+import jakarta.jms.Session;
+import jakarta.jms.Topic;
+
+import org.apache.activemq.ActiveMQConnectionFactory;
+import org.apache.activemq.broker.BrokerService;
+import org.apache.activemq.broker.ConnectionContext;
+import org.apache.activemq.broker.region.policy.PolicyEntry;
+import org.apache.activemq.broker.region.policy.PolicyMap;
+import org.apache.activemq.broker.region.virtual.VirtualDestination;
+import org.apache.activemq.broker.region.virtual.VirtualDestinationInterceptor;
+import org.apache.activemq.broker.region.virtual.VirtualTopic;
+import org.apache.activemq.command.ActiveMQTopic;
+import org.apache.activemq.command.Message;
+import org.apache.activemq.store.ListenableFuture;
+import org.apache.activemq.store.PersistenceAdapter;
+import org.apache.activemq.store.ProxyTopicMessageStore;
+import org.apache.activemq.store.TopicMessageStore;
+import org.apache.activemq.store.jdbc.JDBCPersistenceAdapter;
+import org.apache.activemq.store.jdbc.adapter.H2JDBCAdapter;
+import org.apache.activemq.store.memory.MemoryPersistenceAdapter;
+import org.apache.commons.dbcp2.BasicDataSource;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Threads;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+/**
+ * JMH benchmark for Topic.doMessageSend() lock contention.
+ * <p>
+ * Measures throughput of concurrent producers sending persistent messages
+ * to a virtual topic with:
+ * <ul>
+ *   <li><b>N durable subscribers</b> on the topic — these force persistence
+ *       ({@code canOptimizeOutPersistence()} returns false) AND create real
+ *       dispatch work inside {@code Topic.doMessageSend()}</li>
+ *   <li><b>N virtual topic queue consumers</b> — create additional realistic
+ *       broker-level routing work via {@code 
VirtualDestinationInterceptor}</li>
+ * </ul>
+ * <p>
+ * The parameter {@code numSubscribers} controls both the number of durable
+ * subscribers AND virtual topic queue consumers, making each message:
+ * <ol>
+ *   <li>Persisted to the topic store (under the lock in both approaches)</li>
+ *   <li>Dispatched to N durable subscribers (under the lock in old approach,
+ *       <b>outside</b> the lock in new approach — this is the key 
difference)</li>
+ *   <li>Routed to N consumer queues via the virtual topic interceptor</li>
+ * </ol>
+ * <p>
+ * Two store modes are available:
+ * <ul>
+ *   <li>{@code MEMORY_BUSYWAIT} — in-memory store with 200µs busy-wait 
latency</li>
+ *   <li>{@code H2_JDBC} — real H2 file-based database with small DBCP2 pool
+ *       (maxTotal=5), creating realistic IO + pool contention</li>
+ * </ul>
+ * <p>
+ * <b>Excluded from CI:</b> file is named {@code *Benchmark.java} (not
+ * {@code *Test.java}), so surefire's {@code **\/*Test.*} pattern skips it.
+ * <p>
+ * <b>Run locally:</b>
+ * <pre>
+ * # Build first (skip tests)
+ * mvn install -DskipTests
+ *
+ * # Full benchmark (all combinations — takes a long time)
+ * java -cp "activemq-unit-tests/target/test-classes:$(mvn -pl 
activemq-unit-tests dependency:build-classpath -Dmdep.outputFile=/dev/stdout 
-q)" \
+ *   org.openjdk.jmh.Main "TopicSendLockBenchmark"
+ *
+ * # Quick: H2 JDBC, 5 subscribers
+ * java -cp "activemq-unit-tests/target/test-classes:$(mvn -pl 
activemq-unit-tests dependency:build-classpath -Dmdep.outputFile=/dev/stdout 
-q)" \
+ *   org.openjdk.jmh.Main "TopicSendLockBenchmark" \
+ *   -p storeType=H2_JDBC -p numSubscribers=5
+ *
+ * # Quick: busy-wait, 10 subscribers, high threads only
+ * java -cp "activemq-unit-tests/target/test-classes:$(mvn -pl 
activemq-unit-tests dependency:build-classpath -Dmdep.outputFile=/dev/stdout 
-q)" \
+ *   org.openjdk.jmh.Main 
"TopicSendLockBenchmark.send_50_threads|TopicSendLockBenchmark.send_100_threads"
 \
+ *   -p storeType=MEMORY_BUSYWAIT -p numSubscribers=10
+ * </pre>
+ *
+ * <b>MacBook Pro M1 results — side-by-side comparison</b> (higher is better):
+ * <pre>
+ *                                                          MEMORY_BUSYWAIT 
(ops/s)           H2_JDBC (ops/s)
+ * Benchmark              Subs    OLD (synchronized)  NEW (ReentrantLock)  
Gain     OLD (synchronized)  NEW (ReentrantLock)  Gain
+ * 
-------------------------------------------------------------------------------------------------------------------------------
+ * send_01_thread            1         4238 ±  770         4360 ±   55       
~0%        10025 ± 1810         9761 ± 1704       ~0%
+ * send_01_thread            5         3120 ±  251         3166 ±  171       
~0%         3253 ±  332         3205 ±  353       ~0%
+ * send_01_thread           10         2109 ±   86         2128 ±  183       
~0%         1654 ±  169         1598 ±  279       ~0%
+ * send_02_threads           1         4627 ±  130         4751 ±   40       
~0%        11217 ± 2522        12753 ± 1854      +14%
+ * send_02_threads           5         3956 ±   61         4470 ±  105      
+13%         3936 ±  769         3897 ±  860       ~0%
+ * send_02_threads          10         2546 ±  123         2626 ±  215       
~0%         2030 ±  386         2037 ±  577       ~0%
+ * send_10_threads           1         4583 ±  181         4304 ± 1236       
~0%        11925 ± 1661        15427 ± 5230      +29%
+ * send_10_threads           5         3879 ±  444         4495 ±  197      
+16%         4547 ±  554         5192 ± 1363      +14%
+ * send_10_threads          10         2601 ±  166         3369 ±  203      
+30%         2156 ±  534         2469 ±  242      +15%
+ * send_20_threads           1         4606 ±   28         4654 ±  353       
~0%        11970 ± 1709        14948 ± 5646      +25%
+ * send_20_threads           5         3927 ±  124         4505 ±  142      
+15%         4568 ± 1601         5097 ± 1992      +12%
+ * send_20_threads          10         2683 ±  120         3689 ±  165      
+38%         2324 ±  413         2545 ±  213      +10%
+ * send_50_threads           1         4496 ±  298         4678 ±   74       
~0%        10889 ± 1916        14175 ± 3652      +30%
+ * send_50_threads           5         3844 ±  189         4436 ±  146      
+15%         4608 ± 1209         4707 ± 1894       ~0%
+ * send_50_threads          10         2737 ±  227         3859 ± 1113      
+41%         2416 ±  124         2422 ±  204       ~0%
+ * send_100_threads          1         4424 ±  214         4501 ±  260       
~0%         9734 ± 1668        12012 ± 5054      +23%
+ * send_100_threads          5         3043 ± 1408         4283 ±  247      
+41%         4339 ± 1146         4059 ±  958       ~0%
+ * send_100_threads         10         2576 ±  257         2085 ±  947       
~0%         2174 ±  633         2040 ±  393       ~0%
+ * </pre>
+ * <p>
+ * <b>Summary:</b>
+ * <pre>
+ * - 1 thread:     ~0% difference (no lock contention — expected)
+ * - 10+ threads:  +14% to +41% with the ReentrantLock patch
+ * - Sweet spot:   MEMORY_BUSYWAIT + 10 subs + 20-50 threads → +38-41%
+ *                 H2_JDBC + 1 sub + 10-50 threads → +25-30%
+ * - The gain increases with:
+ *   (a) more subscribers — more dispatch work moved outside the lock
+ *   (b) more producer threads — more contention on the lock
+ * </pre>
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 3, time = 3)
+@Measurement(iterations = 5, time = 5)
+@Fork(value = 1, jvmArgsAppend = {"-Xmx2g", 
"-Dorg.apache.activemq.default.directory.prefix=target/"})
+@State(Scope.Benchmark)
+public class TopicSendLockBenchmark {
+
+    /**
+     * Store type for the benchmark.
+     */
+    @Param({"MEMORY_BUSYWAIT", "H2_JDBC"})
+    String storeType;
+
+    /**
+     * Number of subscribers. Controls BOTH:
+     * <ul>
+     *   <li>Durable topic subscribers — dispatch work inside 
Topic.doMessageSend()
+     *       (this is under the lock in old code, outside in new code)</li>
+     *   <li>Virtual topic queue consumers — additional broker-level 
routing</li>
+     * </ul>
+     */
+    @Param({"1", "5", "10"})
+    int numSubscribers;
+
+    private static final String VIRTUAL_TOPIC_NAME = "VirtualTopic.BENCH";
+    private static final int IO_LATENCY_MICROS = 200;
+
+    private BrokerService broker;
+    private ActiveMQConnectionFactory connectionFactory;
+    private BasicDataSource dataSource;
+    private final List<Connection> consumerConnections = new ArrayList<>();
+
+    // Per-thread state: each thread gets its own connection/session/producer
+    @State(Scope.Thread)
+    public static class ThreadState {
+        Connection connection;
+        Session session;
+        MessageProducer producer;
+        int messageCounter;
+
+        @Setup(Level.Trial)
+        public void setup(final TopicSendLockBenchmark benchState) throws 
Exception {
+            connection = benchState.connectionFactory.createConnection();
+            connection.start();
+            session = connection.createSession(false, 
Session.AUTO_ACKNOWLEDGE);
+            final Topic topic = session.createTopic(VIRTUAL_TOPIC_NAME);
+            producer = session.createProducer(topic);
+            producer.setDeliveryMode(DeliveryMode.PERSISTENT);
+        }
+
+        @TearDown(Level.Trial)
+        public void tearDown() throws Exception {
+            if (producer != null) producer.close();
+            if (session != null) session.close();
+            if (connection != null) connection.close();
+        }
+    }
+
+    @Setup(Level.Trial)
+    public void setupBroker() throws Exception {
+        broker = new BrokerService();
+        broker.setBrokerName("benchmark");
+        broker.setUseJmx(false);
+        broker.setPersistent(true);
+        broker.setDeleteAllMessagesOnStartup(true);
+        broker.setAdvisorySupport(false);
+        broker.setUseShutdownHook(false);
+        broker.setDataDirectory("target/benchmark-data");
+
+        // Disable producer flow control and set generous memory limits
+        final PolicyEntry topicPolicy = new PolicyEntry();
+        topicPolicy.setTopic(">");
+        topicPolicy.setProducerFlowControl(false);
+        topicPolicy.setMemoryLimit(512L * 1024 * 1024);
+
+        final PolicyEntry queuePolicy = new PolicyEntry();
+        queuePolicy.setQueue(">");
+        queuePolicy.setProducerFlowControl(false);
+        queuePolicy.setMemoryLimit(512L * 1024 * 1024);
+
+        final PolicyMap policyMap = new PolicyMap();
+        policyMap.put(new ActiveMQTopic(">"), topicPolicy);
+        policyMap.put(new org.apache.activemq.command.ActiveMQQueue(">"), 
queuePolicy);
+        broker.setDestinationPolicy(policyMap);
+
+        // Configure virtual topics: messages sent to VirtualTopic.>
+        // are dispatched to Consumer.N.VirtualTopic.> queues
+        final VirtualTopic virtualTopic = new VirtualTopic();
+        virtualTopic.setName("VirtualTopic.>");
+        virtualTopic.setPrefix("Consumer.*.");
+        final VirtualDestinationInterceptor interceptor = new 
VirtualDestinationInterceptor();
+        interceptor.setVirtualDestinations(new 
VirtualDestination[]{virtualTopic});
+        broker.setDestinationInterceptors(new 
DestinationInterceptor[]{interceptor});
+
+        // Configure persistence adapter
+        broker.setPersistenceAdapter(createPersistenceAdapter());
+
+        broker.addConnector("vm://benchmark");
+        broker.start();
+        broker.waitUntilStarted();
+
+        connectionFactory = new 
ActiveMQConnectionFactory("vm://benchmark?create=false");
+        connectionFactory.setWatchTopicAdvisories(false);
+        connectionFactory.getPrefetchPolicy().setQueuePrefetch(1000);
+        connectionFactory.getPrefetchPolicy().setDurableTopicPrefetch(1000);
+
+        // ---- Durable topic subscribers ----
+        // These are CRITICAL: they ensure canOptimizeOutPersistence()=false
+        // (so persistence actually happens under the lock) AND they create
+        // real dispatch work inside Topic.doMessageSend().
+        // With synchronized: persistence + dispatch to N durables = all under 
lock
+        // With ReentrantLock patch: persistence under lock, dispatch outside
+        for (int i = 1; i <= numSubscribers; i++) {
+            final Connection conn = connectionFactory.createConnection();
+            conn.setClientID("durable-bench-" + i);
+            conn.start();
+            final Session sess = conn.createSession(false, 
Session.AUTO_ACKNOWLEDGE);
+            final Topic topic = sess.createTopic(VIRTUAL_TOPIC_NAME);
+            final MessageConsumer durableSub = 
sess.createDurableSubscriber(topic, "bench-sub-" + i);
+            // Actively consume to create dispatch work and prevent backlog
+            durableSub.setMessageListener(msg -> { });
+            consumerConnections.add(conn);
+        }
+
+        // ---- Virtual topic queue consumers ----
+        // Additional realistic load: each message is also routed to N
+        // consumer queues by the VirtualDestinationInterceptor
+        for (int i = 1; i <= numSubscribers; i++) {
+            final Connection conn = connectionFactory.createConnection();
+            conn.start();
+            final Session sess = conn.createSession(false, 
Session.AUTO_ACKNOWLEDGE);
+            final Queue queue = sess.createQueue("Consumer." + i + "." + 
VIRTUAL_TOPIC_NAME);
+            final MessageConsumer consumer = sess.createConsumer(queue);
+            consumer.setMessageListener(msg -> { });
+            consumerConnections.add(conn);
+        }
+    }
+
+    private PersistenceAdapter createPersistenceAdapter() throws IOException {
+        if ("H2_JDBC".equals(storeType)) {
+            return createH2JdbcAdapter();
+        }
+        return new SlowPersistenceAdapterWrapper(() -> IO_LATENCY_MICROS);
+    }
+
+    private JDBCPersistenceAdapter createH2JdbcAdapter() throws IOException {
+        dataSource = new BasicDataSource();
+        dataSource.setDriverClassName("org.h2.Driver");
+        
dataSource.setUrl("jdbc:h2:./target/benchmark-data/h2-bench;DB_CLOSE_DELAY=-1;AUTO_SERVER=TRUE");
+        dataSource.setUsername("sa");
+        dataSource.setPassword("");
+        // Intentionally small pool to create contention
+        dataSource.setMaxTotal(5);
+        dataSource.setMaxIdle(2);
+        dataSource.setMinIdle(1);
+        dataSource.setMaxWaitMillis(5000);
+
+        final JDBCPersistenceAdapter jdbc = new JDBCPersistenceAdapter();
+        jdbc.setDataSource(dataSource);
+        jdbc.setAdapter(new H2JDBCAdapter());
+        jdbc.setUseLock(false);
+        return jdbc;
+    }
+
+    @TearDown(Level.Trial)
+    public void tearDownBroker() throws Exception {
+        for (final Connection conn : consumerConnections) {
+            try {
+                conn.close();
+            } catch (final Exception ignored) { }
+        }
+        consumerConnections.clear();
+        if (broker != null) {
+            broker.stop();
+            broker.waitUntilStopped();
+        }
+        if (dataSource != null) {
+            dataSource.close();
+            dataSource = null;
+        }
+    }
+
+    // ---- Benchmark methods ----
+
+    @Benchmark
+    @Threads(1)
+    public void send_01_thread(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    @Benchmark
+    @Threads(2)
+    public void send_02_threads(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    @Benchmark
+    @Threads(10)
+    public void send_10_threads(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    @Benchmark
+    @Threads(20)
+    public void send_20_threads(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    @Benchmark
+    @Threads(50)
+    public void send_50_threads(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    @Benchmark
+    @Threads(100)
+    public void send_100_threads(final ThreadState state) throws Exception {
+        doSend(state);
+    }
+
+    private void doSend(final ThreadState state) throws Exception {
+        final jakarta.jms.TextMessage msg = 
state.session.createTextMessage("bench-" + state.messageCounter++);
+        state.producer.send(msg);
+    }
+
+    // ---- Slow persistence adapter (MEMORY_BUSYWAIT mode) ----
+
+    @FunctionalInterface
+    interface LatencyProvider {
+        int getMicros();
+    }
+
+    static class SlowPersistenceAdapterWrapper extends 
MemoryPersistenceAdapter {
+        private final LatencyProvider latencyProvider;
+
+        SlowPersistenceAdapterWrapper(final LatencyProvider latencyProvider) {
+            this.latencyProvider = latencyProvider;
+        }
+
+        @Override
+        public TopicMessageStore createTopicMessageStore(final ActiveMQTopic 
destination) throws IOException {
+            final TopicMessageStore realStore = 
super.createTopicMessageStore(destination);
+            return new SlowTopicMessageStore(realStore, latencyProvider);
+        }
+    }
+
+    static class SlowTopicMessageStore extends ProxyTopicMessageStore {
+        private final LatencyProvider latencyProvider;
+
+        SlowTopicMessageStore(final TopicMessageStore delegate, final 
LatencyProvider latencyProvider) {
+            super(delegate);
+            this.latencyProvider = latencyProvider;
+        }
+
+        @Override
+        public void addMessage(final ConnectionContext context, final Message 
message) throws IOException {
+            simulateIO();
+            super.addMessage(context, message);
+        }
+
+        @Override
+        public void addMessage(final ConnectionContext context, final Message 
message, final boolean canOptimizeHint) throws IOException {
+            simulateIO();
+            super.addMessage(context, message, canOptimizeHint);
+        }
+
+        @Override
+        public ListenableFuture<Object> asyncAddTopicMessage(final 
ConnectionContext context, final Message message) throws IOException {
+            simulateIO();
+            return super.asyncAddTopicMessage(context, message);
+        }
+
+        @Override
+        public ListenableFuture<Object> asyncAddTopicMessage(final 
ConnectionContext context, final Message message, final boolean 
canOptimizeHint) throws IOException {
+            simulateIO();
+            return super.asyncAddTopicMessage(context, message, 
canOptimizeHint);
+        }
+
+        private void simulateIO() {
+            final int micros = latencyProvider.getMicros();
+            if (micros > 0) {
+                final long deadlineNanos = System.nanoTime() + 
TimeUnit.MICROSECONDS.toNanos(micros);
+                while (System.nanoTime() < deadlineNanos) {
+                    Thread.onSpinWait(); // important because it prevents the 
busy-wait loop from consuming 100% CPU and
+                    // allows other threads to run, increasing contention and 
realism of the benchmark
+                }
+            }
+        }
+    }
+
+    // Runner for manual execution (e.g. from IDE) — not needed when running 
via JMH plugin or command line
+
+    public static void main(final String[] args) throws Exception {
+        final Options opt = new OptionsBuilder()
+                .include(TopicSendLockBenchmark.class.getSimpleName())
+                .build();
+        new Runner(opt).run();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]
For further information, visit: https://activemq.apache.org/contact



Reply via email to