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

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


The following commit(s) were added to refs/heads/main by this push:
     new 03cb654a4335 CAMEL-24198: Track Event Hubs checkpoint batching per 
partition
03cb654a4335 is described below

commit 03cb654a4335fe35e1de81d237d32630184edbd0
Author: Omar Atie <[email protected]>
AuthorDate: Sun Jul 19 01:41:15 2026 -0700

    CAMEL-24198: Track Event Hubs checkpoint batching per partition
    
    The consumer used shared processedEvents, lastTask, and lastScheduledTask
    fields across all partitions, so checkpoint commits from different
    partitions could interfere with each other. Replaced these with
    per-partition ConcurrentHashMap fields so each partition tracks its own
    batch count and schedules its own timeout checkpoint independently.
    
    Closes #24903
---
 .../camel-azure/camel-azure-eventhubs/pom.xml      |   5 +
 .../azure/eventhubs/EventHubsConsumer.java         |  46 +++--
 ...ventHubsConsumerPerPartitionCheckpointTest.java | 199 +++++++++++++++++++++
 3 files changed, 233 insertions(+), 17 deletions(-)

diff --git a/components/camel-azure/camel-azure-eventhubs/pom.xml 
b/components/camel-azure/camel-azure-eventhubs/pom.xml
index e5468bee1745..638eae7eafad 100644
--- a/components/camel-azure/camel-azure-eventhubs/pom.xml
+++ b/components/camel-azure/camel-azure-eventhubs/pom.xml
@@ -65,6 +65,11 @@
             <artifactId>camel-test-junit6</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.mockito</groupId>
             <artifactId>mockito-core</artifactId>
diff --git 
a/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumer.java
 
b/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumer.java
index 2f3c6c3c5827..21a04f735b7e 100644
--- 
a/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumer.java
+++ 
b/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumer.java
@@ -16,6 +16,8 @@
  */
 package org.apache.camel.component.azure.eventhubs;
 
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -46,16 +48,14 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
     // we use the EventProcessorClient as recommended by Azure docs to consume 
from all partitions
     private EventProcessorClient processorClient;
 
-    private final AtomicInteger processedEvents;
     private final AtomicInteger pendingExchanges = new AtomicInteger();
+    private final Map<String, AtomicInteger> processedEventsByPartition = new 
ConcurrentHashMap<>();
+    private final Map<String, ScheduledFuture<?>> scheduledTasksByPartition = 
new ConcurrentHashMap<>();
+    private final Map<String, EventHubsCheckpointUpdaterTask> 
checkpointTasksByPartition = new ConcurrentHashMap<>();
     private ScheduledExecutorService scheduledExecutorService;
-    private ScheduledFuture<?> lastScheduledTask;
-    private EventHubsCheckpointUpdaterTask lastTask;
 
     public EventHubsConsumer(final EventHubsEndpoint endpoint, final Processor 
processor) {
         super(endpoint, processor);
-
-        this.processedEvents = new AtomicInteger();
     }
 
     @Override
@@ -82,6 +82,10 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
             processorClient.stop();
         }
 
+        processedEventsByPartition.clear();
+        scheduledTasksByPartition.clear();
+        checkpointTasksByPartition.clear();
+
         // shutdown camel consumer
         super.doStop();
     }
@@ -210,15 +214,23 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
      * @param exchange the exchange
      */
     private synchronized void processCommit(final Exchange exchange, final 
EventContext eventContext) {
-        if (lastTask == null || lastTask.isExpired()) {
-            lastTask = new EventHubsCheckpointUpdaterTask(eventContext, 
processedEvents);
+        final String partitionId = 
eventContext.getPartitionContext().getPartitionId();
+        final AtomicInteger processedEvents = 
processedEventsByPartition.computeIfAbsent(partitionId,
+                ignored -> new AtomicInteger());
+        EventHubsCheckpointUpdaterTask checkpointTask = 
checkpointTasksByPartition.get(partitionId);
+        ScheduledFuture<?> scheduledTask = 
scheduledTasksByPartition.get(partitionId);
+
+        if (checkpointTask == null || checkpointTask.isExpired()) {
+            checkpointTask = new EventHubsCheckpointUpdaterTask(eventContext, 
processedEvents);
             // delegate the checkpoint update to a dedicated Thread
             long timeout = getConfiguration().getCheckpointBatchTimeout();
-            lastTask.setScheduledTime(System.currentTimeMillis() + timeout);
-            lastScheduledTask = scheduledExecutorService.schedule(lastTask, 
timeout, TimeUnit.MILLISECONDS);
+            checkpointTask.setScheduledTime(System.currentTimeMillis() + 
timeout);
+            scheduledTask = scheduledExecutorService.schedule(checkpointTask, 
timeout, TimeUnit.MILLISECONDS);
+            checkpointTasksByPartition.put(partitionId, checkpointTask);
+            scheduledTasksByPartition.put(partitionId, scheduledTask);
         } else {
             // updates the eventContext to use for the offset to be the most 
accurate
-            lastTask.setEventContext(eventContext);
+            checkpointTask.setEventContext(eventContext);
         }
 
         try {
@@ -226,9 +238,9 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
             if (cnt == getConfiguration().getCheckpointBatchSize()) {
                 processedEvents.set(0);
                 
exchange.getIn().setHeader(EventHubsConstants.CHECKPOINT_UPDATED_BY, 
COMPLETED_BY_SIZE);
-                LOG.debug("eventhub consumer batch size of reached");
-                if (lastScheduledTask != null) {
-                    lastScheduledTask.cancel(false);
+                LOG.debug("eventhub consumer batch size of reached for 
partition {}", partitionId);
+                if (scheduledTask != null) {
+                    scheduledTask.cancel(false);
                 }
                 eventContext.updateCheckpointAsync()
                         .subscribe(unused -> LOG.debug("Processed one 
event..."),
@@ -236,12 +248,12 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
                                 () -> {
                                     LOG.debug("Checkpoint updated.");
                                 });
-            } else if (lastTask != null && lastTask.isExpired()) {
+            } else if (checkpointTask.isExpired()) {
                 
exchange.getIn().setHeader(EventHubsConstants.CHECKPOINT_UPDATED_BY, 
COMPLETED_BY_TIMEOUT);
-                LOG.debug("eventhub consumer batch timeout reached");
+                LOG.debug("eventhub consumer batch timeout reached for 
partition {}", partitionId);
             } else {
-                LOG.debug("neither eventhub consumer batch size of {}/{} nor 
batch timeout reached yet", cnt,
-                        getConfiguration().getCheckpointBatchSize());
+                LOG.debug("neither eventhub consumer batch size of {}/{} nor 
batch timeout reached yet for partition {}",
+                        cnt, getConfiguration().getCheckpointBatchSize(), 
partitionId);
             }
             // we assume that the scheduled task has done the update by its 
side
         } catch (Exception ex) {
diff --git 
a/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerPerPartitionCheckpointTest.java
 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerPerPartitionCheckpointTest.java
new file mode 100644
index 000000000000..570833d609bc
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerPerPartitionCheckpointTest.java
@@ -0,0 +1,199 @@
+/*
+ * 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.camel.component.azure.eventhubs;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.azure.messaging.eventhubs.EventProcessorClient;
+import com.azure.messaging.eventhubs.models.EventContext;
+import com.azure.messaging.eventhubs.models.PartitionContext;
+import org.apache.camel.AsyncProcessor;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Endpoint;
+import org.apache.camel.Exchange;
+import org.apache.camel.ExtendedCamelContext;
+import org.apache.camel.spi.ExchangeFactory;
+import org.apache.camel.support.DefaultExchange;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import reactor.core.publisher.Mono;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class EventHubsConsumerPerPartitionCheckpointTest {
+
+    private final EventHubsEndpoint endpoint = mock();
+    private final EventHubsConfiguration configuration = new 
EventHubsConfiguration();
+    private final AsyncProcessor processor = mock();
+    private final CamelContext context = mock();
+    private final ExtendedCamelContext ecc = mock();
+    private final ExchangeFactory exchangeFactory = mock();
+    private final EventProcessorClient processorClient = mock();
+    private final ScheduledExecutorService scheduledExecutorService = mock();
+    @SuppressWarnings("rawtypes")
+    private final ScheduledFuture scheduledFuture = 
mock(ScheduledFuture.class);
+
+    private EventHubsConsumer consumer;
+    private Method processCommit;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        configuration.setCheckpointBatchSize(3);
+        configuration.setCheckpointBatchTimeout(60_000);
+
+        when(endpoint.getCamelContext()).thenReturn(context);
+        when(context.getCamelContextExtension()).thenReturn(ecc);
+        when(ecc.getExchangeFactory()).thenReturn(exchangeFactory);
+        
when(exchangeFactory.newExchangeFactory(any())).thenReturn(exchangeFactory);
+        when(exchangeFactory.create(any(Endpoint.class), anyBoolean()))
+                .thenAnswer(invocation -> 
DefaultExchange.newFromEndpoint(invocation.getArgument(0)));
+        when(endpoint.getConfiguration()).thenReturn(configuration);
+        consumer = new EventHubsConsumer(endpoint, processor);
+
+        setField(consumer, "scheduledExecutorService", 
scheduledExecutorService);
+        when(scheduledExecutorService.schedule(any(Runnable.class), anyLong(), 
any(TimeUnit.class)))
+                .thenReturn(scheduledFuture);
+
+        processCommit = 
EventHubsConsumer.class.getDeclaredMethod("processCommit", Exchange.class, 
EventContext.class);
+        processCommit.setAccessible(true);
+    }
+
+    @Test
+    void processedEventsAreTrackedPerPartition() throws Exception {
+        invokeProcessCommit(exchange(), eventContext("0"));
+        invokeProcessCommit(exchange(), eventContext("0"));
+        invokeProcessCommit(exchange(), eventContext("1"));
+
+        assertThat(processedEventsForPartition("0")).isEqualTo(2);
+        assertThat(processedEventsForPartition("1")).isEqualTo(1);
+    }
+
+    @Test
+    void batchSizeCheckpointOnOnePartitionDoesNotResetOtherPartition() throws 
Exception {
+        configuration.setCheckpointBatchSize(2);
+        EventContext partitionZeroContext = eventContext("0");
+        
when(partitionZeroContext.updateCheckpointAsync()).thenReturn(Mono.empty());
+
+        invokeProcessCommit(exchange(), eventContext("1"));
+        invokeProcessCommit(exchange(), partitionZeroContext);
+        invokeProcessCommit(exchange(), partitionZeroContext);
+
+        assertThat(processedEventsForPartition("0")).isZero();
+        assertThat(processedEventsForPartition("1")).isEqualTo(1);
+        verify(partitionZeroContext, times(1)).updateCheckpointAsync();
+    }
+
+    @Test
+    void eachPartitionSchedulesOwnCheckpointTask() throws Exception {
+        ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+
+        invokeProcessCommit(exchange(), eventContext("0"));
+        invokeProcessCommit(exchange(), eventContext("1"));
+
+        verify(scheduledExecutorService, times(2)).schedule(tasks.capture(), 
anyLong(), any(TimeUnit.class));
+        
assertThat(tasks.getAllValues().get(0)).isNotSameAs(tasks.getAllValues().get(1));
+
+        Map<String, EventHubsCheckpointUpdaterTask> checkpointTasks = 
getCheckpointTasksByPartition();
+        assertThat(checkpointTasks).hasSize(2);
+        
assertThat(checkpointTasks.get("0")).isNotSameAs(checkpointTasks.get("1"));
+    }
+
+    @Test
+    void reusesCheckpointTaskWithinSamePartition() throws Exception {
+        invokeProcessCommit(exchange(), eventContext("3"));
+        invokeProcessCommit(exchange(), eventContext("3"));
+
+        verify(scheduledExecutorService, 
times(1)).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class));
+        assertThat(getCheckpointTasksByPartition()).hasSize(1);
+        assertThat(processedEventsForPartition("3")).isEqualTo(2);
+    }
+
+    @Test
+    void doStopClearsPerPartitionCheckpointState() throws Exception {
+        setField(consumer, "processorClient", processorClient);
+        invokeProcessCommit(exchange(), eventContext("0"));
+        invokeProcessCommit(exchange(), eventContext("1"));
+
+        consumer.doStop();
+
+        assertThat(getProcessedEventsByPartition()).isEmpty();
+        assertThat(getCheckpointTasksByPartition()).isEmpty();
+        assertThat(getScheduledTasksByPartition()).isEmpty();
+        verify(processorClient).stop();
+    }
+
+    private void invokeProcessCommit(Exchange exchange, EventContext 
eventContext) throws Exception {
+        processCommit.invoke(consumer, exchange, eventContext);
+    }
+
+    private Exchange exchange() {
+        return DefaultExchange.newFromEndpoint(endpoint);
+    }
+
+    private EventContext eventContext(String partitionId) {
+        EventContext eventContext = mock(EventContext.class);
+        PartitionContext partitionContext = mock(PartitionContext.class);
+        when(partitionContext.getPartitionId()).thenReturn(partitionId);
+        when(eventContext.getPartitionContext()).thenReturn(partitionContext);
+        return eventContext;
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, AtomicInteger> getProcessedEventsByPartition() throws 
Exception {
+        return (Map<String, AtomicInteger>) getField(consumer, 
"processedEventsByPartition");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, EventHubsCheckpointUpdaterTask> 
getCheckpointTasksByPartition() throws Exception {
+        return (Map<String, EventHubsCheckpointUpdaterTask>) 
getField(consumer, "checkpointTasksByPartition");
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, ScheduledFuture<?>> getScheduledTasksByPartition() 
throws Exception {
+        return (Map<String, ScheduledFuture<?>>) getField(consumer, 
"scheduledTasksByPartition");
+    }
+
+    private int processedEventsForPartition(String partitionId) throws 
Exception {
+        AtomicInteger counter = 
getProcessedEventsByPartition().get(partitionId);
+        return counter == null ? 0 : counter.get();
+    }
+
+    private static void setField(Object target, String name, Object value) 
throws Exception {
+        var field = target.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(target, value);
+    }
+
+    private static Object getField(Object target, String name) throws 
Exception {
+        var field = target.getClass().getDeclaredField(name);
+        field.setAccessible(true);
+        return field.get(target);
+    }
+}

Reply via email to