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 17bf5c4d9fde CAMEL-26598: camel-kafka - breakOnFirstError replays the 
failed record also when the consumer is paused (#29287)
17bf5c4d9fde is described below

commit 17bf5c4d9fde7d52816a35bc7d0bd1c7a41e8976
Author: allthingssecurity <[email protected]>
AuthorDate: Fri Nov 6 13:07:01 2026 +0530

    CAMEL-26598: camel-kafka - breakOnFirstError replays the failed record also 
when the consumer is paused (#29287)
    
    With breakOnFirstError, the consumer now reconnects and replays the failed 
record also when the route is suspended at that moment; a reconnected consumer 
that uses assign() is paused before its first poll.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../camel/component/kafka/KafkaFetchRecords.java   |  10 +-
 .../KafkaConsumerBreakOnFirstErrorPausedTest.java  | 382 +++++++++++++++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  10 +
 3 files changed, 400 insertions(+), 2 deletions(-)

diff --git 
a/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
 
b/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
index 63084b2f79cc..9972c2a6fe02 100644
--- 
a/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
+++ 
b/components/camel-kafka/src/main/java/org/apache/camel/component/kafka/KafkaFetchRecords.java
@@ -171,6 +171,11 @@ public class KafkaFetchRecords implements Runnable {
                     recordProcessorFacade = createRecordProcessor();
                     // a new Kafka consumer is not paused: pause it again if 
the consumer is suspended
                     state.compareAndSet(State.PAUSED, State.PAUSE_REQUESTED);
+                    if (state.get() == State.PAUSE_REQUESTED) {
+                        // partitions assigned without a rebalance (assign() 
in a custom SubscribeAdapter) are not
+                        // paused by the rebalance listener: pause them before 
the first poll
+                        consumer.pause(consumer.assignment());
+                    }
                 }
 
                 if (isConnected()) {
@@ -376,8 +381,9 @@ public class KafkaFetchRecords implements Runnable {
                     }
                     updateTaskState();
 
-                    // when breakOnFirstError we want to unsubscribe from Kafka
-                    if (result != null && result.isBreakOnErrorHit() && 
!this.state.get().equals(State.PAUSED)) {
+                    // when breakOnFirstError we want to unsubscribe from 
Kafka, also when the consumer was just paused
+                    // (the new Kafka consumer is paused again)
+                    if (result != null && result.isBreakOnErrorHit()) {
                         LOG.debug("We hit an error ... setting flags to force 
reconnect");
                         // force re-connect
                         setReconnect(true);
diff --git 
a/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerBreakOnFirstErrorPausedTest.java
 
b/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerBreakOnFirstErrorPausedTest.java
new file mode 100644
index 000000000000..5aa7e020e4ad
--- /dev/null
+++ 
b/components/camel-kafka/src/test/java/org/apache/camel/component/kafka/KafkaConsumerBreakOnFirstErrorPausedTest.java
@@ -0,0 +1,382 @@
+/*
+ * 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.kafka;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.LockSupport;
+
+import org.apache.camel.BindToRegistry;
+import org.apache.camel.Exchange;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.component.kafka.consumer.KafkaManualCommit;
+import 
org.apache.camel.component.kafka.consumer.support.subcription.SubscribeAdapter;
+import org.apache.camel.component.mock.MockEndpoint;
+import org.apache.camel.model.RouteDefinition;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.apache.camel.test.junit6.TestExecutionConfiguration;
+import org.apache.camel.throttling.ThrottlingExceptionRoutePolicy;
+import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.clients.consumer.ConsumerGroupMetadata;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.consumer.ConsumerRecords;
+import org.apache.kafka.clients.consumer.MockConsumer;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.clients.consumer.OffsetCommitCallback;
+import org.apache.kafka.clients.producer.MockProducer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.clients.producer.ProducerRecord;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.serialization.Serializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.junit.jupiter.api.Test;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * With breakOnFirstError, a failed record is polled again even when the 
consumer is paused (suspended) in the same poll
+ * loop iteration, for example by a {@link ThrottlingExceptionRoutePolicy} 
that opens its circuit on the failed
+ * exchange. When the partition has no committed offset, the record is only 
polled again by a new Kafka consumer, which
+ * starts from autoOffsetReset (earliest here). A {@link MockConsumer} on a 
simulated log and committed offsets replaces
+ * the broker: each Kafka consumer of the fetcher is a new instance that 
starts from the committed offset of the group.
+ */
+class KafkaConsumerBreakOnFirstErrorPausedTest extends CamelTestSupport {
+
+    private static final String TOPIC = "break-paused";
+    private static final TopicPartition PARTITION = new TopicPartition(TOPIC, 
0);
+    // the group of MockConsumer.groupMetadata(), which the exactly-once 
producer checks
+    private static final String GROUP = "dummy.group.id";
+    private static final List<String> RECORDS = List.of("a", "b", "c");
+    private static final String SYNC_MANUAL_COMMIT = 
"&allowManualCommit=true&kafkaManualCommitFactory=#class:"
+                                                     + 
"org.apache.camel.component.kafka.consumer.DefaultKafkaManualCommitFactory";
+
+    // the committed offsets of the consumer group (the broker)
+    private final Map<TopicPartition, OffsetAndMetadata> committed = new 
ConcurrentHashMap<>();
+    private final AtomicInteger consumersCreated = new AtomicInteger();
+    private final AtomicInteger failures = new AtomicInteger();
+    private final List<String> failOnce = new ArrayList<>();
+    private volatile CountDownLatch failingRecordEntered;
+    private volatile CountDownLatch failingRecordProceed;
+    private volatile TransactionalMockProducer producer;
+    private volatile boolean assignPartitions;
+
+    @BindToRegistry("logFactory")
+    private final KafkaClientFactory factory = new KafkaClientFactory() {
+        @Override
+        public Producer getProducer(Properties kafkaProps) {
+            producer = new TransactionalMockProducer();
+            return producer;
+        }
+
+        @Override
+        public Consumer getConsumer(Properties kafkaProps) {
+            consumersCreated.incrementAndGet();
+            LogMockConsumer consumer = new LogMockConsumer();
+            if (!assignPartitions) {
+                // the group coordinator assigns the partition in the first 
poll
+                consumer.schedulePollTask(() -> 
consumer.rebalance(List.of(PARTITION)));
+            }
+            return consumer;
+        }
+
+        @Override
+        public String getBrokers(KafkaConfiguration configuration) {
+            return "localhost:9092";
+        }
+    };
+
+    @Override
+    public void configureTest(TestExecutionConfiguration 
testExecutionConfiguration) {
+        testExecutionConfiguration.withUseRouteBuilder(false);
+    }
+
+    @Test
+    void testCircuitBreakerOpenedByTheFailedRecord() throws Exception {
+        // the policy suspends the consumer from onExchangeDone of the failed 
exchange, on the fetcher thread, so the
+        // consumer is paused in the same iteration as the break; it resumes 
the consumer when the circuit half opens
+        failOnce.add("a");
+        startRoute(SYNC_MANUAL_COMMIT, true, false);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "b", "c");
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+        await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(3L, committedOffset(), "The processed 
records must be committed"));
+    }
+
+    @Test
+    void testPauseRequestedWhileTheFailedRecordIsProcessed() throws Exception {
+        failOnce.add("a");
+        failingRecordEntered = new CountDownLatch(1);
+        failingRecordProceed = new CountDownLatch(1);
+        startRoute("", false, false);
+        KafkaConsumer consumer = kafkaConsumer();
+
+        // the consumer is suspended while the record that fails is in the 
route
+        assertTrue(failingRecordEntered.await(20, TimeUnit.SECONDS));
+        consumer.suspend();
+        failingRecordProceed.countDown();
+        await().atMost(20, TimeUnit.SECONDS).until(consumer::isKafkaPaused);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "b", "c");
+        consumer.resume();
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+    }
+
+    @Test
+    void 
testPauseRequestedWhileTheFailedRecordIsProcessedWithAssignedPartitions() 
throws Exception {
+        // a custom subscribe adapter assigns the partition: no rebalance 
listener pauses the new Kafka consumer
+        assignPartitions = true;
+        context.getRegistry().bind(KafkaConstants.KAFKA_SUBSCRIBE_ADAPTER,
+                (SubscribeAdapter) (consumer, listener, topicInfo) -> 
consumer.assign(List.of(PARTITION)));
+        failOnce.add("a");
+        failingRecordEntered = new CountDownLatch(1);
+        failingRecordProceed = new CountDownLatch(1);
+        startRoute("", false, false);
+        KafkaConsumer consumer = kafkaConsumer();
+
+        assertTrue(failingRecordEntered.await(20, TimeUnit.SECONDS));
+        consumer.suspend();
+        failingRecordProceed.countDown();
+        await().atMost(20, TimeUnit.SECONDS).until(consumer::isKafkaPaused);
+
+        // nothing is routed while the consumer is suspended
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedMessageCount(0);
+        mock.setAssertPeriod(500);
+        mock.assertIsSatisfied();
+
+        mock.reset();
+        mock.expectedBodiesReceived("a", "b", "c");
+        consumer.resume();
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+    }
+
+    @Test
+    void testCircuitBreakerOpenedByTheFailedRecordExactlyOnce() throws 
Exception {
+        failOnce.add("a");
+        startRoute("&allowManualCommit=true&exactlyOnce=true", true, true);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "b", "c");
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+        // the output of each record and its source offset are committed in 
one transaction
+        await().atMost(20, TimeUnit.SECONDS).untilAsserted(
+                () -> assertEquals(3L, committedOffset(), "The source offsets 
must be committed"));
+        assertEquals(List.of("a", "b", "c"), producer.committedValues());
+    }
+
+    @Test
+    void testFailedRecordWithoutPause() throws Exception {
+        // control: without a pause, the break reconnects and the failed 
record is polled again
+        failOnce.add("a");
+        startRoute("", false, false);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "b", "c");
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+        assertTrue(consumersCreated.get() >= 2, "The break must create a new 
Kafka consumer");
+    }
+
+    @Test
+    void testCircuitBreakerOpenedOnPartitionWithCommittedOffset() throws 
Exception {
+        // control: the record before the failed one is committed, so the 
failed record is polled again from the
+        // committed offset of the partition
+        failOnce.add("b");
+        startRoute(SYNC_MANUAL_COMMIT, true, false);
+
+        MockEndpoint mock = getMockEndpoint("mock:result");
+        mock.expectedBodiesReceived("a", "b", "c");
+        MockEndpoint.assertIsSatisfied(context, 20, TimeUnit.SECONDS);
+        assertEquals(1, failures.get());
+    }
+
+    private void startRoute(String options, boolean circuitBreaker, boolean 
exactlyOnce) throws Exception {
+        boolean manualCommit = options.contains("kafkaManualCommitFactory");
+        context.addRoutes(new RouteBuilder() {
+            @Override
+            public void configure() {
+                RouteDefinition route = from("kafka:" + TOPIC + 
"?brokers=localhost:9092&kafkaClientFactory=#logFactory"
+                                             + "&groupId=" + GROUP + 
"&pollTimeoutMs=10&autoOffsetReset=earliest"
+                                             + 
"&autoCommitEnable=false&breakOnFirstError=true" + options)
+                        .routeId("kafka");
+                if (circuitBreaker) {
+                    // opens on the first failure and half opens after 100 
millis
+                    route.routePolicy(new ThrottlingExceptionRoutePolicy(1, 
60000, 100, null, false));
+                }
+                
route.process(KafkaConsumerBreakOnFirstErrorPausedTest.this::process);
+                if (manualCommit) {
+                    route.process(exchange -> exchange.getMessage()
+                            .getHeader(KafkaConstants.MANUAL_COMMIT, 
KafkaManualCommit.class).commit());
+                }
+                if (exactlyOnce) {
+                    
route.to("kafka:out?brokers=localhost:9092&kafkaClientFactory=#logFactory&exactlyOnce=true"
+                             + "&transactionalId=break-paused-tx");
+                }
+                route.to("mock:result");
+            }
+        });
+        context.start();
+    }
+
+    private void process(Exchange exchange) throws Exception {
+        String body = exchange.getMessage().getBody(String.class);
+        boolean fail;
+        synchronized (failOnce) {
+            fail = failOnce.remove(body);
+        }
+        if (fail) {
+            CountDownLatch entered = failingRecordEntered;
+            if (entered != null) {
+                entered.countDown();
+                if (!failingRecordProceed.await(20, TimeUnit.SECONDS)) {
+                    throw new IllegalStateException("The test did not let the 
failing record proceed");
+                }
+            }
+            failures.incrementAndGet();
+            throw new IllegalStateException("Simulated failure of record " + 
body);
+        }
+    }
+
+    private long committedOffset() {
+        OffsetAndMetadata offset = committed.get(PARTITION);
+        return offset != null ? offset.offset() : -1;
+    }
+
+    private KafkaConsumer kafkaConsumer() {
+        return (KafkaConsumer) context.getRoute("kafka").getConsumer();
+    }
+
+    /**
+     * A Kafka consumer of the group: it polls the records of {@link #RECORDS} 
from its position, which a new consumer
+     * takes from the committed offset of the group, or else from the 
beginning (autoOffsetReset=earliest).
+     */
+    private final class LogMockConsumer extends MockConsumer<Object, Object> {
+
+        LogMockConsumer() {
+            super("earliest");
+            updateBeginningOffsets(Map.of(PARTITION, 0L));
+            updateEndOffsets(Map.of(PARTITION, (long) RECORDS.size()));
+            if (!committed.isEmpty()) {
+                // the committed offsets the new consumer starts from
+                super.commitAsync(new HashMap<>(committed), null);
+            }
+        }
+
+        @Override
+        public synchronized ConsumerRecords<Object, Object> poll(Duration 
timeout) {
+            // runs the poll tasks (the assignment), and sets the position of 
a newly assigned partition
+            super.poll(timeout);
+            Map<TopicPartition, List<ConsumerRecord<Object, Object>>> records 
= new HashMap<>();
+            Map<TopicPartition, OffsetAndMetadata> nextOffsets = new 
HashMap<>();
+            for (TopicPartition partition : assignment()) {
+                if (paused().contains(partition)) {
+                    continue;
+                }
+                List<ConsumerRecord<Object, Object>> fetched = new 
ArrayList<>();
+                for (long offset = position(partition); offset < 
RECORDS.size(); offset++) {
+                    fetched.add(new ConsumerRecord<>(TOPIC, 0, offset, null, 
RECORDS.get((int) offset)));
+                }
+                if (!fetched.isEmpty()) {
+                    records.put(partition, fetched);
+                    nextOffsets.put(partition, new 
OffsetAndMetadata(RECORDS.size()));
+                    super.seek(partition, RECORDS.size());
+                }
+            }
+            if (records.isEmpty()) {
+                // a real consumer waits up to the poll timeout when there is 
nothing to fetch
+                LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
+            }
+            return new ConsumerRecords<>(records, nextOffsets);
+        }
+
+        @Override
+        public synchronized void commitAsync(
+                Map<TopicPartition, OffsetAndMetadata> offsets, 
OffsetCommitCallback callback) {
+            super.commitAsync(offsets, callback);
+            committed.putAll(offsets);
+        }
+
+        @Override
+        public synchronized Map<TopicPartition, OffsetAndMetadata> 
committed(Set<TopicPartition> partitions) {
+            // as the Kafka consumer: a partition without a committed offset 
maps to null
+            Map<TopicPartition, OffsetAndMetadata> answer = new HashMap<>();
+            for (TopicPartition partition : partitions) {
+                answer.put(partition, committed.get(partition));
+            }
+            return answer;
+        }
+    }
+
+    /**
+     * A transactional producer: a committed transaction commits the source 
offsets sent to it for the group.
+     */
+    private final class TransactionalMockProducer extends MockProducer<Object, 
Object> {
+        private Map<TopicPartition, OffsetAndMetadata> pendingOffsets;
+
+        @SuppressWarnings({ "unchecked", "rawtypes" })
+        TransactionalMockProducer() {
+            super(true, null, (Serializer) new StringSerializer(), 
(Serializer) new StringSerializer());
+        }
+
+        @Override
+        public synchronized void sendOffsetsToTransaction(
+                Map<TopicPartition, OffsetAndMetadata> offsets, 
ConsumerGroupMetadata groupMetadata) {
+            super.sendOffsetsToTransaction(offsets, groupMetadata);
+            pendingOffsets = offsets;
+        }
+
+        @Override
+        public synchronized void commitTransaction() {
+            super.commitTransaction();
+            if (pendingOffsets != null) {
+                committed.putAll(pendingOffsets);
+                pendingOffsets = null;
+            }
+        }
+
+        @Override
+        public synchronized void abortTransaction() {
+            super.abortTransaction();
+            pendingOffsets = null;
+        }
+
+        /**
+         * The values of the records of the committed transactions.
+         */
+        synchronized List<Object> committedValues() {
+            return history().stream().map(ProducerRecord::value).toList();
+        }
+    }
+}
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 41a3b204fe36..836baf23e08c 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -6480,6 +6480,16 @@ Jackson 2 and Jackson 3. An object node is now sent as 
one record; previously it
 value, without the field names, as the 4.22 upgrade guide said. A Jackson 3 
scalar node is now sent as one record,
 where nothing was sent before.
 
+=== camel-kafka - breakOnFirstError while the consumer is paused
+
+With `breakOnFirstError=true`, a failed record now creates a new Kafka 
consumer also when the consumer is paused in the
+same poll, for example by a `ThrottlingExceptionRoutePolicy` that suspends the 
route on the failed exchange. The new
+consumer stays paused until the route is resumed, and then polls each 
partition from its committed offset, or from
+`autoOffsetReset` when it has none, as on a break without a pause. Before, a 
partition without a committed offset
+resumed after the failed record. The new consumer leaves and rejoins the 
consumer group, so each such break is now a
+rebalance of the group, also when the partition has a committed offset. There 
is no option to keep the previous
+behaviour.
+
 === camel-dns - dig without a type, and the wikipedia type
 
 `dns:dig` without the `CamelDnsType` header now asks for type A, and without 
the `CamelDnsName` header fails with an

Reply via email to