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

gnodet pushed a commit to branch camel-4.18.x
in repository https://gitbox.apache.org/repos/asf/camel.git


The following commit(s) were added to refs/heads/camel-4.18.x by this push:
     new 5c2c941ab4de [backport camel-4.18.x] CAMEL-25781: 
camel-azure-eventhubs - an event that completes when the checkpoint timer of 
its window has run is checkpointed by the next timer (#28206)
5c2c941ab4de is described below

commit 5c2c941ab4de4974bfb4d6bc2c2900902351ba13
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Oct 21 19:07:15 2026 +0200

    [backport camel-4.18.x] CAMEL-25781: camel-azure-eventhubs - an event that 
completes when the checkpoint timer of its window has run is checkpointed by 
the next timer (#28206)
    
    * [backport camel-4.18.x] CAMEL-25781: camel-azure-eventhubs - an event 
that completes when the checkpoint timer of its window has run is checkpointed 
by the next timer
    
    * Address review: add missing tests, fix upgrade guide section placement
    
    * Address review: fix upgrade guide logging wording; remove redundant 
awaitility dep
---
 .../camel-azure/camel-azure-eventhubs/pom.xml      |   5 +
 .../eventhubs/EventHubsCheckpointUpdaterTask.java  |  77 ++++++-
 .../azure/eventhubs/EventHubsConsumer.java         | 113 ++++++----
 ...ntHubsConsumerCheckpointAfterBatchSizeTest.java | 229 +++++++++++++++++++++
 .../EventHubsConsumerCheckpointWindowTest.java     | 167 +++++++++++++++
 ...ventHubsConsumerPerPartitionCheckpointTest.java | 199 ++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_18.adoc    |  23 +++
 7 files changed, 763 insertions(+), 50 deletions(-)

diff --git a/components/camel-azure/camel-azure-eventhubs/pom.xml 
b/components/camel-azure/camel-azure-eventhubs/pom.xml
index b0b9b5121773..81b496d5b879 100644
--- a/components/camel-azure/camel-azure-eventhubs/pom.xml
+++ b/components/camel-azure/camel-azure-eventhubs/pom.xml
@@ -72,5 +72,10 @@
             <artifactId>commons-lang3</artifactId>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git 
a/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsCheckpointUpdaterTask.java
 
b/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsCheckpointUpdaterTask.java
index 49723ac7c4f7..1de96d0c19ab 100644
--- 
a/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsCheckpointUpdaterTask.java
+++ 
b/components/camel-azure/camel-azure-eventhubs/src/main/java/org/apache/camel/component/azure/eventhubs/EventHubsCheckpointUpdaterTask.java
@@ -17,6 +17,8 @@
 package org.apache.camel.component.azure.eventhubs;
 
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
 
 import com.azure.messaging.eventhubs.models.EventContext;
 import org.slf4j.Logger;
@@ -29,26 +31,63 @@ public class EventHubsCheckpointUpdaterTask implements 
Runnable {
 
     private static final Logger LOG = 
LoggerFactory.getLogger(EventHubsCheckpointUpdaterTask.class);
 
-    private EventContext eventContext;
+    private volatile EventContext eventContext;
     private final AtomicInteger processedEvents;
     private volatile long scheduledTime;
+    // the lock of the consumer, which counts the events under it
+    private final Lock lock;
+    // set when the task has taken the count of its window, or was cancelled; 
read and written under the lock only
+    private boolean done;
 
+    /**
+     * Creates a task that synchronizes on a private lock of its own, 
unrelated to any consumer: for standalone or test
+     * use. {@link EventHubsConsumer} uses {@link 
#EventHubsCheckpointUpdaterTask(EventContext, AtomicInteger, Lock)}
+     * with its own lock, which it holds while it counts the events; with a 
private lock an event could be counted in a
+     * window whose timer has already taken the count.
+     *
+     * @param eventContext    the event context to checkpoint
+     * @param processedEvents the count of the events processed since the last 
checkpoint
+     */
     public EventHubsCheckpointUpdaterTask(EventContext eventContext, 
AtomicInteger processedEvents) {
+        this(eventContext, processedEvents, new ReentrantLock());
+    }
+
+    public EventHubsCheckpointUpdaterTask(EventContext eventContext, 
AtomicInteger processedEvents, Lock lock) {
         this.eventContext = eventContext;
         this.processedEvents = processedEvents;
+        this.lock = lock;
     }
 
     @Override
     public void run() {
-        if (processedEvents.get() > 0) {
-            LOG.debug("checkpointing offset after reaching timeout, with a 
batch of {}", processedEvents.get());
-            eventContext.updateCheckpointAsync()
+        // take the count when the checkpoint is started: an event that 
completes while it is being stored is counted
+        // for the next window from 0, and a checkpoint by batch size in the 
meantime cannot be confused with this one.
+        // This is done under the lock of the consumer, so an event is either 
counted (and its context set) before, and
+        // checkpointed here, or after, and then it sees that this task is 
done and starts a new window.
+        final int processed;
+        final EventContext context;
+        lock.lock();
+        try {
+            if (done) {
+                LOG.debug("skip checkpointing offset: the window was already 
checkpointed by batch size");
+                return;
+            }
+            done = true;
+            processed = processedEvents.getAndSet(0);
+            context = eventContext;
+        } finally {
+            lock.unlock();
+        }
+        if (processed > 0) {
+            LOG.debug("checkpointing offset after reaching timeout, with a 
batch of {}", processed);
+            context.updateCheckpointAsync()
                     .subscribe(unused -> LOG.debug("Processed one event..."),
-                            error -> LOG.debug("Error when updating 
Checkpoint: {}", error.getMessage()),
-                            () -> {
-                                LOG.debug("Checkpoint updated.");
-                                processedEvents.set(0);
-                            });
+                            error -> {
+                                LOG.warn("Error when updating Checkpoint: {}", 
error.getMessage(), error);
+                                // these events are not checkpointed: count 
them again for the next checkpoint
+                                processedEvents.addAndGet(processed);
+                            },
+                            () -> LOG.debug("Checkpoint updated."));
         } else {
             LOG.debug("skip checkpointing offset even if timeout is reached. 
No events processed");
         }
@@ -66,6 +105,26 @@ public class EventHubsCheckpointUpdaterTask implements 
Runnable {
         return scheduledTime;
     }
 
+    /**
+     * Whether this task has taken the count of its window (or was cancelled), 
so a new event must start a new window.
+     * Called under the lock of the consumer.
+     */
+    public boolean isDone() {
+        return done;
+    }
+
+    /**
+     * Cancels the task under the lock of the consumer: if it is already 
running, it takes nothing.
+     */
+    public void cancel() {
+        lock.lock();
+        try {
+            done = true;
+        } finally {
+            lock.unlock();
+        }
+    }
+
     public boolean isExpired() {
         return System.currentTimeMillis() > scheduledTime;
     }
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 ce1e6e79da56..eddf97f3a7b2 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,10 +16,13 @@
  */
 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;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
 
 import com.azure.messaging.eventhubs.EventProcessorClient;
 import com.azure.messaging.eventhubs.models.ErrorContext;
@@ -46,16 +49,15 @@ 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 ReentrantLock lock = new ReentrantLock();
     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 +84,10 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
             processorClient.stop();
         }
 
+        processedEventsByPartition.clear();
+        scheduledTasksByPartition.clear();
+        checkpointTasksByPartition.clear();
+
         // shutdown camel consumer
         super.doStop();
     }
@@ -214,46 +220,71 @@ public class EventHubsConsumer extends DefaultConsumer 
implements ShutdownAware
     /**
      * Strategy to commit the offset after message being processed 
successfully.
      *
-     * @param exchange the exchange
+     * @param exchange     the exchange
+     * @param eventContext the event context of the event, used to update the 
checkpoint
      */
-    private void processCommit(final Exchange exchange, final EventContext 
eventContext) {
-        if (lastTask == null || lastTask.isExpired()) {
-            lastTask = 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);
-        } else {
-            // updates the eventContext to use for the offset to be the most 
accurate
-            lastTask.setEventContext(eventContext);
-        }
-
+    void processCommit(final Exchange exchange, final EventContext 
eventContext) {
+        lock.lock();
         try {
-            var cnt = processedEvents.incrementAndGet();
-            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);
-                }
-                eventContext.updateCheckpointAsync()
-                        .subscribe(unused -> LOG.debug("Processed one 
event..."),
-                                error -> LOG.debug("Error when updating 
Checkpoint: {}", error.getMessage()),
-                                () -> {
-                                    LOG.debug("Checkpoint updated.");
-                                });
-            } else if (lastTask != null && lastTask.isExpired()) {
-                
exchange.getIn().setHeader(EventHubsConstants.CHECKPOINT_UPDATED_BY, 
COMPLETED_BY_TIMEOUT);
-                LOG.debug("eventhub consumer batch timeout reached");
+            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);
+
+            // a new window starts when the timer of the current one has run, 
not when the wall clock passed its time: the
+            // timer can run in the millisecond of its scheduled time, or 
later, and the event must be counted in the
+            // window whose timer has not taken the count yet
+            if (checkpointTask == null || checkpointTask.isDone()) {
+                checkpointTask = new 
EventHubsCheckpointUpdaterTask(eventContext, processedEvents, lock);
+                // delegate the checkpoint update to a dedicated Thread
+                long timeout = getConfiguration().getCheckpointBatchTimeout();
+                checkpointTask.setScheduledTime(System.currentTimeMillis() + 
timeout);
+                scheduledTask = 
scheduledExecutorService.schedule(checkpointTask, timeout, 
TimeUnit.MILLISECONDS);
+                checkpointTasksByPartition.put(partitionId, checkpointTask);
+                scheduledTasksByPartition.put(partitionId, scheduledTask);
             } else {
-                LOG.debug("neither eventhub consumer batch size of {}/{} nor 
batch timeout reached yet", cnt,
-                        getConfiguration().getCheckpointBatchSize());
+                // updates the eventContext to use for the offset to be the 
most accurate
+                checkpointTask.setEventContext(eventContext);
+            }
+
+            try {
+                var cnt = processedEvents.incrementAndGet();
+                // >= and not ==: a failed timeout checkpoint adds its count 
back, so the count can pass the batch size
+                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 for 
partition {}", partitionId);
+                    // a timer that has already started takes nothing (it 
waits for the lock)
+                    checkpointTask.cancel();
+                    if (scheduledTask != null) {
+                        scheduledTask.cancel(false);
+                    }
+                    // the next event starts a new timeout window: the 
cancelled task must not be reused until its
+                    // scheduled time, else the events of the rest of that 
window are not checkpointed by the timeout
+                    checkpointTasksByPartition.remove(partitionId);
+                    scheduledTasksByPartition.remove(partitionId);
+                    eventContext.updateCheckpointAsync()
+                            .subscribe(unused -> LOG.debug("Processed one 
event..."),
+                                    error -> LOG.warn("Error when updating 
Checkpoint: {}", error.getMessage(), error),
+                                    () -> {
+                                        LOG.debug("Checkpoint updated.");
+                                    });
+                } else if (checkpointTask.isExpired()) {
+                    
exchange.getIn().setHeader(EventHubsConstants.CHECKPOINT_UPDATED_BY, 
COMPLETED_BY_TIMEOUT);
+                    LOG.debug("eventhub consumer batch timeout reached for 
partition {}", partitionId);
+                } else {
+                    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) {
+                getExceptionHandler().handleException(
+                        "Error occurred during updating the checkpoint. This 
exception is ignored.",
+                        exchange, ex);
             }
-            // we assume that the scheduled task has done the update by its 
side
-        } catch (Exception ex) {
-            getExceptionHandler().handleException("Error occurred during 
updating the checkpoint. This exception is ignored.",
-                    exchange, ex);
+        } finally {
+            lock.unlock();
         }
     }
 
diff --git 
a/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointAfterBatchSizeTest.java
 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointAfterBatchSizeTest.java
new file mode 100644
index 000000000000..19a22d70484b
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointAfterBatchSizeTest.java
@@ -0,0 +1,229 @@
+/*
+ * 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.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+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.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import reactor.core.publisher.Mono;
+import reactor.core.publisher.Sinks;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The events completed after a checkpoint by batch size must be checkpointed 
by the batch timeout, also when no further
+ * event arrives (end of a burst). Uses a real scheduler with a short batch 
timeout.
+ */
+class EventHubsConsumerCheckpointAfterBatchSizeTest {
+
+    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 ScheduledExecutorService scheduler = 
Executors.newSingleThreadScheduledExecutor();
+
+    private EventHubsConsumer consumer;
+    private Method processCommit;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        configuration.setCheckpointBatchSize(2);
+        configuration.setCheckpointBatchTimeout(1000);
+
+        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);
+
+        var field = 
EventHubsConsumer.class.getDeclaredField("scheduledExecutorService");
+        field.setAccessible(true);
+        field.set(consumer, scheduler);
+
+        processCommit = 
EventHubsConsumer.class.getDeclaredMethod("processCommit", Exchange.class, 
EventContext.class);
+        processCommit.setAccessible(true);
+    }
+
+    @AfterEach
+    void tearDown() {
+        scheduler.shutdownNow();
+    }
+
+    @Test
+    void eventAfterBatchSizeCheckpointIsCheckpointedByTimeout() throws 
Exception {
+        EventContext first = eventContext("0");
+        EventContext second = eventContext("0");
+        EventContext third = eventContext("0");
+
+        complete(first);
+        complete(second);
+        // the batch size is reached: checkpoint at the second event
+        verify(second, times(1)).updateCheckpointAsync();
+
+        // the last event of the burst: no other event follows
+        complete(third);
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> verify(third, 
times(1)).updateCheckpointAsync());
+    }
+
+    @Test
+    void eventsAfterSecondBatchSizeCheckpointAreCheckpointedByTimeout() throws 
Exception {
+        EventContext[] events = new EventContext[5];
+        for (int i = 0; i < events.length; i++) {
+            events[i] = eventContext("0");
+            complete(events[i]);
+        }
+        // checkpoints by batch size at the second and the fourth event
+        verify(events[1], times(1)).updateCheckpointAsync();
+        verify(events[3], times(1)).updateCheckpointAsync();
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> verify(events[4], 
times(1)).updateCheckpointAsync());
+    }
+
+    @Test
+    void 
eventCompletedWhileTheTimeoutCheckpointIsStoredIsCheckpointedByTheNextTimeout() 
throws Exception {
+        configuration.setCheckpointBatchSize(10);
+        // the checkpoint of the first window is stored only when the test 
says so
+        Sinks.Empty<Void> stored = Sinks.empty();
+        AtomicLong checkpointedAt = new AtomicLong();
+        EventContext first = eventContext("0");
+        doAnswer(invocation -> {
+            checkpointedAt.set(System.currentTimeMillis());
+            return stored.asMono();
+        }).when(first).updateCheckpointAsync();
+        EventContext second = eventContext("0");
+
+        complete(first);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> verify(first, 
times(1)).updateCheckpointAsync());
+        // the window of the first task is over, so the next event starts a 
new task
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
System.currentTimeMillis() > checkpointedAt.get());
+
+        // an event completes while the checkpoint of the first window is 
being stored
+        complete(second);
+        stored.tryEmitEmpty();
+
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> verify(second, 
times(1)).updateCheckpointAsync());
+    }
+
+    @Test
+    void eventAfterBatchSizeCheckpointDuringTimeoutCheckpointIsCheckpointed() 
throws Exception {
+        configuration.setCheckpointBatchSize(3);
+        // the checkpoint of the first window is stored only when the test 
says so
+        Sinks.Empty<Void> stored = Sinks.empty();
+        AtomicLong checkpointedAt = new AtomicLong();
+        EventContext first = eventContext("0");
+        doAnswer(invocation -> {
+            checkpointedAt.set(System.currentTimeMillis());
+            return stored.asMono();
+        }).when(first).updateCheckpointAsync();
+        EventContext second = eventContext("0");
+        EventContext third = eventContext("0");
+        EventContext fourth = eventContext("0");
+
+        complete(first);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> verify(first, 
times(1)).updateCheckpointAsync());
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
System.currentTimeMillis() > checkpointedAt.get());
+
+        // while the timeout checkpoint of the first event is stored, more 
events complete, and the count climbs back
+        // to the value that checkpoint started with
+        complete(second);
+        complete(third);
+        complete(fourth);
+        stored.tryEmitEmpty();
+
+        // the last event of the burst must still be checkpointed, by batch 
size or by the next timeout
+        await().atMost(10, TimeUnit.SECONDS)
+                .untilAsserted(() -> verify(fourth, 
times(1)).updateCheckpointAsync());
+    }
+
+    @Test
+    void batchSizeCheckpointStillWorksAfterFailedTimeoutCheckpoint() throws 
Exception {
+        configuration.setCheckpointBatchSize(3);
+        // the checkpoint of the first window fails only when the test says so
+        Sinks.Empty<Void> stored = Sinks.empty();
+        AtomicLong checkpointedAt = new AtomicLong();
+        EventContext first = eventContext("0");
+        EventContext second = eventContext("0");
+        doAnswer(invocation -> {
+            checkpointedAt.set(System.currentTimeMillis());
+            return stored.asMono();
+        }).when(second).updateCheckpointAsync();
+        EventContext third = eventContext("0");
+        EventContext fourth = eventContext("0");
+
+        complete(first);
+        complete(second);
+        await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> 
verify(second, times(1)).updateCheckpointAsync());
+        await().atMost(10, TimeUnit.SECONDS).until(() -> 
System.currentTimeMillis() > checkpointedAt.get());
+
+        // one event completes while the timeout checkpoint of two events is 
stored, then storing fails: the two
+        // events are counted again, so the count is 3, and the next event 
takes it past the batch size
+        complete(third);
+        stored.tryEmitError(new IllegalStateException("store failed"));
+
+        Exchange exchange = DefaultExchange.newFromEndpoint(endpoint);
+        processCommit.invoke(consumer, exchange, fourth);
+
+        verify(fourth, times(1)).updateCheckpointAsync();
+        assertEquals(EventHubsConstants.COMPLETED_BY_SIZE,
+                
exchange.getIn().getHeader(EventHubsConstants.CHECKPOINT_UPDATED_BY));
+    }
+
+    private void complete(EventContext eventContext) throws Exception {
+        processCommit.invoke(consumer, 
DefaultExchange.newFromEndpoint(endpoint), eventContext);
+    }
+
+    private static EventContext eventContext(String partitionId) {
+        EventContext eventContext = mock(EventContext.class);
+        PartitionContext partitionContext = mock(PartitionContext.class);
+        when(partitionContext.getPartitionId()).thenReturn(partitionId);
+        when(eventContext.getPartitionContext()).thenReturn(partitionContext);
+        when(eventContext.updateCheckpointAsync()).thenReturn(Mono.empty());
+        return eventContext;
+    }
+}
diff --git 
a/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointWindowTest.java
 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointWindowTest.java
new file mode 100644
index 000000000000..3fc1b5525fb3
--- /dev/null
+++ 
b/components/camel-azure/camel-azure-eventhubs/src/test/java/org/apache/camel/component/azure/eventhubs/EventHubsConsumerCheckpointWindowTest.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.camel.component.azure.eventhubs;
+
+import java.util.List;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+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.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.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The timer of a checkpoint window and the events of the partition: every 
completed event must be checkpointed by a
+ * timer that runs after it was counted, whenever the timer runs relative to 
the wall clock and to the batch size.
+ */
+class EventHubsConsumerCheckpointWindowTest {
+
+    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 ScheduledExecutorService scheduledExecutorService = mock();
+    @SuppressWarnings("rawtypes")
+    private final ScheduledFuture scheduledFuture = 
mock(ScheduledFuture.class);
+
+    private EventHubsConsumer consumer;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        configuration.setCheckpointBatchSize(500);
+        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);
+
+        var field = 
EventHubsConsumer.class.getDeclaredField("scheduledExecutorService");
+        field.setAccessible(true);
+        field.set(consumer, scheduledExecutorService);
+        when(scheduledExecutorService.schedule(any(Runnable.class), anyLong(), 
any(TimeUnit.class)))
+                .thenReturn(scheduledFuture);
+    }
+
+    @Test
+    void eventCompletedAfterTheTimerRanBeforeItsScheduledTimeIsCheckpointed() 
throws Exception {
+        EventContext first = eventContext();
+        EventContext second = eventContext();
+
+        complete(first);
+        // the timer runs while the wall clock has not passed its scheduled 
time yet (the same millisecond)
+        scheduledTasks().get(0).run();
+        verify(first).updateCheckpointAsync();
+
+        complete(second);
+        runTasksFrom(1);
+
+        verify(second).updateCheckpointAsync();
+    }
+
+    @Test
+    void eventCompletedAfterTheScheduledTimeBeforeTheTimerRanIsCheckpointed() 
throws Exception {
+        EventContext first = eventContext();
+        EventContext second = eventContext();
+
+        complete(first);
+        // the wall clock has passed the scheduled time, the timer has not run 
yet (late by a GC pause, a busy thread)
+        EventHubsCheckpointUpdaterTask firstTimer = 
(EventHubsCheckpointUpdaterTask) scheduledTasks().get(0);
+        firstTimer.setScheduledTime(System.currentTimeMillis() - 1);
+
+        complete(second);
+        runTasksFrom(0);
+
+        verify(second).updateCheckpointAsync();
+    }
+
+    @Test
+    void timerStartedBeforeABatchSizeCheckpointDoesNotTakeTheNextWindow() 
throws Exception {
+        configuration.setCheckpointBatchSize(2);
+        EventContext first = eventContext();
+        EventContext second = eventContext();
+        EventContext third = eventContext();
+
+        complete(first);
+        complete(second); // batch size reached: checkpoint, the timer of the 
window is cancelled
+        verify(second).updateCheckpointAsync();
+        complete(third); // starts a new window
+
+        // the timer of the first window had already started, so the cancel 
did not stop it
+        runTasksFrom(0);
+
+        // the cancelled timer of the first window takes nothing: its event 
context is second (set when second was
+        // counted), which was checkpointed once by batch size and not again; 
first was never checkpointed by itself
+        verify(first, never()).updateCheckpointAsync();
+        verify(second, times(1)).updateCheckpointAsync();
+
+        verify(third).updateCheckpointAsync();
+    }
+
+    private void complete(EventContext eventContext) throws Exception {
+        consumer.processCommit(DefaultExchange.newFromEndpoint(endpoint), 
eventContext);
+    }
+
+    private List<Runnable> scheduledTasks() {
+        ArgumentCaptor<Runnable> tasks = 
ArgumentCaptor.forClass(Runnable.class);
+        verify(scheduledExecutorService, atLeastOnce())
+                .schedule(tasks.capture(), anyLong(), any(TimeUnit.class));
+        return tasks.getAllValues();
+    }
+
+    private void runTasksFrom(int index) {
+        List<Runnable> tasks = scheduledTasks();
+        for (int i = index; i < tasks.size(); i++) {
+            tasks.get(i).run();
+        }
+    }
+
+    private static EventContext eventContext() {
+        EventContext eventContext = mock(EventContext.class);
+        PartitionContext partitionContext = mock(PartitionContext.class);
+        when(partitionContext.getPartitionId()).thenReturn("0");
+        when(eventContext.getPartitionContext()).thenReturn(partitionContext);
+        when(eventContext.updateCheckpointAsync()).thenReturn(Mono.empty());
+        return eventContext;
+    }
+}
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);
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
index 94b2681660c9..c9cb359db72a 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_18.adoc
@@ -11,6 +11,29 @@ Note that manual migration is still required.
 See the xref:camel-upgrade-recipes-tool.adoc[documentation] page for details.
 ====
 
+== Upgrading from 4.18.5 to 4.18.6
+
+=== camel-azure-eventhubs - per-partition checkpoint counters and timer; 
checkpoint window boundary fix
+
+Several fixes from 4.22.x have been brought forward to 4.18.6 to give this 
change a sound base.
+
+`checkpointBatchSize` and `checkpointBatchTimeout` now count events per 
partition, not across all
+partitions. Each partition has its own `AtomicInteger` counter and its own 
`ScheduledFuture` timer.
+A checkpoint on one partition no longer resets the count of another. Before 
this change, the shared
+counter mixed events across partitions, so the batch size was reached across 
all partitions combined
+and a checkpoint on a busy partition could reset the count for a quiet one.
+
+The `CamelAzureEventHubsCheckpointUpdatedBy` header can now be `timeout`. With 
`checkpointBatchTimeout`,
+the consumer now starts a new checkpoint window when the timer of the current 
window has run, and no
+longer when the wall clock passes the time the timer was scheduled for. 
Before, an event that completed
+in the millisecond in which the timer ran (or while the timer was taking the 
count) was counted in the
+window whose count had already been taken: it was checkpointed only with the 
next event, and when the
+partition stayed quiet it was delivered again after a restart. Routes that 
read this header may now see
+`timeout` more often.
+
+Checkpoint failures from the timer and from the batch-size trigger are now 
logged at `WARN` level with the full stack trace instead of
+at `DEBUG` level with the message only.
+
 == Upgrading from 4.18.4 to 4.18.5
 
 === camel-docling - output path handling

Reply via email to