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 4d7629668f68 CAMEL-26709: camel-kafka - SingleNodeKafkaResumeStrategy 
does not work after a route restart (#29408)
4d7629668f68 is described below

commit 4d7629668f684efcc5bc5a14ef54c9f95323aff8
Author: allthingssecurity <[email protected]>
AuthorDate: Sun Nov 8 15:49:05 2026 +0530

    CAMEL-26709: camel-kafka - SingleNodeKafkaResumeStrategy does not work 
after a route restart (#29408)
    
    * CAMEL-26709: camel-kafka - SingleNodeKafkaResumeStrategy does not work 
after a route restart
    * CAMEL-26709: camel-kafka - review: each refresh task of 
SingleNodeKafkaResumeStrategy closes only its own consumer
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../kafka/SingleNodeKafkaResumeStrategy.java       |  75 +++++--
 .../kafka/SingleNodeKafkaResumeStrategyTest.java   | 225 +++++++++++++++++++++
 2 files changed, 283 insertions(+), 17 deletions(-)

diff --git 
a/components/camel-kafka/src/main/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategy.java
 
b/components/camel-kafka/src/main/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategy.java
index 7bd0ad00875b..d4b872cc5375 100644
--- 
a/components/camel-kafka/src/main/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategy.java
+++ 
b/components/camel-kafka/src/main/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategy.java
@@ -65,7 +65,8 @@ import org.slf4j.LoggerFactory;
 public class SingleNodeKafkaResumeStrategy implements KafkaResumeStrategy, 
CamelContextAware {
     private static final Logger LOG = 
LoggerFactory.getLogger(SingleNodeKafkaResumeStrategy.class);
 
-    private Consumer<byte[], byte[]> consumer;
+    // the consumer of the running refresh task, for wakeup() in stop(); each 
task closes only its own consumer
+    private volatile Consumer<byte[], byte[]> consumer;
     private Producer<byte[], byte[]> producer;
     private Duration pollDuration = Duration.ofSeconds(1);
 
@@ -109,10 +110,14 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
      *
      */
     protected void produce(byte[] key, byte[] message, UpdateCallBack 
updateCallBack) {
+        Producer<byte[], byte[]> current = producer;
+        if (current == null) {
+            throw new IllegalStateException("Cannot update the offset as the 
Kafka resume strategy is not started");
+        }
         ProducerRecord<byte[], byte[]> producerRecord
                 = new ProducerRecord<>(resumeStrategyConfiguration.getTopic(), 
key, message);
 
-        producer.send(producerRecord, (recordMetadata, e) -> {
+        current.send(producerRecord, (recordMetadata, e) -> {
             if (e != null) {
                 LOG.error("Failed to send message {}", e.getMessage(), e);
             }
@@ -203,22 +208,28 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
     private void refresh(CountDownLatch latch) {
         LOG.trace("Creating a offset cache refresher");
 
+        // a local consumer: a task of an earlier start that outlives stop() 
must close its own consumer, not the one of
+        // the task that runs now
+        Consumer<byte[], byte[]> current = null;
         try {
-            consumer = createConsumer();
+            current = createConsumer();
+            consumer = current;
+            // a new consumer (such as after a restart) must subscribe again
+            subscribed = false;
 
-            subscribe(consumer);
+            subscribe(current);
 
             LOG.debug("Loading records from topic {}", 
resumeStrategyConfiguration.getTopic());
-            poll(consumer, latch);
+            poll(current, latch);
         } catch (WakeupException e) {
             LOG.info("Kafka consumer was interrupted during a blocking call");
         } catch (Exception e) {
             LOG.error("Error while refreshing the local cache: {}", 
e.getMessage(), e);
         } finally {
-            if (consumer != null) {
-                consumer.unsubscribe();
+            if (current != null) {
+                current.unsubscribe();
                 try {
-                    
consumer.close(CloseOptions.timeout(Duration.ofSeconds(5)));
+                    current.close(CloseOptions.timeout(Duration.ofSeconds(5)));
                 } catch (Exception e) {
                     LOG.warn("Error closing the consumer: {} (this error will 
be ignored)", e.getMessage(), e);
                 }
@@ -241,9 +252,25 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
                             consumerRecord.timestampType(), value);
                 }
 
-                if 
(!deserializable.deserialize(ByteBuffer.wrap(consumerRecord.key()),
-                        ByteBuffer.wrap(consumerRecord.value()))) {
-                    LOG.warn("Deserializer indicates that this is the last 
record to deserialize");
+                if (value == null) {
+                    // a tombstone, which on a compacted topic means "delete 
this key". Camel never writes tombstones
+                    // to the resume topic, so it is skipped, and the key 
keeps the offset it has in the cache
+                    LOG.warn("Ignoring the tombstone (a record without a 
value) at offset {} of {}-{}: Camel does not write"
+                             + " tombstones to the resume topic, so the offset 
of its key is kept",
+                            consumerRecord.offset(), consumerRecord.topic(), 
consumerRecord.partition());
+                    continue;
+                }
+
+                try {
+                    if 
(!deserializable.deserialize(ByteBuffer.wrap(consumerRecord.key()),
+                            ByteBuffer.wrap(consumerRecord.value()))) {
+                        LOG.warn("Deserializer indicates that this is the last 
record to deserialize");
+                    }
+                } catch (Exception e) {
+                    // for example a record without a key: skip it and keep 
loading the others
+                    LOG.warn("Ignoring the record at offset {} of {}-{} 
because it cannot be deserialized: {}",
+                            consumerRecord.offset(), consumerRecord.topic(), 
consumerRecord.partition(), e.getMessage(),
+                            e);
                 }
             }
 
@@ -384,7 +411,8 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
         }
     }
 
-    private Consumer<byte[], byte[]> createConsumer() {
+    // package-private for the tests
+    Consumer<byte[], byte[]> createConsumer() {
         return new 
KafkaConsumer<>(resumeStrategyConfiguration.getConsumerProperties());
     }
 
@@ -398,6 +426,8 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
 
             LOG.info("Closing the Kafka producer");
             IOHelper.close(producer, "Kafka producer", LOG);
+            // a new producer is created when this strategy is started again
+            producer = null;
         } catch (InterruptedException e) {
             Thread.currentThread().interrupt();
         } catch (Exception e) {
@@ -418,12 +448,19 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
             }
 
             if (executorService != null) {
-                executorService.shutdown();
-
-                if (!executorService.awaitTermination(2, TimeUnit.SECONDS)) {
-                    LOG.warn("Kafka consumer did not shutdown within 2 
seconds");
-                    executorService.shutdownNow();
+                if (camelContext != null) {
+                    // also removes the executor from the manager (and JMX)
+                    
camelContext.getExecutorServiceManager().shutdownGraceful(executorService, 
2000);
+                } else {
+                    executorService.shutdown();
+
+                    if (!executorService.awaitTermination(2, 
TimeUnit.SECONDS)) {
+                        LOG.warn("Kafka consumer did not shutdown within 2 
seconds");
+                        executorService.shutdownNow();
+                    }
                 }
+                // a new executor is created when this strategy is started 
again
+                executorService = null;
             } else {
                 // This may happen if the start up has failed in some other 
part
                 LOG.trace("There's no executor service to shutdown");
@@ -456,6 +493,10 @@ public class SingleNodeKafkaResumeStrategy implements 
KafkaResumeStrategy, Camel
         return producer;
     }
 
+    protected ExecutorService getExecutorService() {
+        return executorService;
+    }
+
     @Override
     public void setResumeStrategyConfiguration(ResumeStrategyConfiguration 
resumeStrategyConfiguration) {
         if (resumeStrategyConfiguration instanceof 
KafkaResumeStrategyConfiguration kafkaResumeStrategyConfiguration) {
diff --git 
a/components/camel-kafka/src/test/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategyTest.java
 
b/components/camel-kafka/src/test/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategyTest.java
index 46175720ddad..4d21050feca7 100644
--- 
a/components/camel-kafka/src/test/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategyTest.java
+++ 
b/components/camel-kafka/src/test/java/org/apache/camel/processor/resume/kafka/SingleNodeKafkaResumeStrategyTest.java
@@ -17,13 +17,34 @@
 package org.apache.camel.processor.resume.kafka;
 
 import java.lang.reflect.Field;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
 import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
 import java.util.concurrent.locks.ReentrantLock;
 
+import org.apache.camel.CamelContext;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.apache.camel.resume.Deserializable;
 import org.apache.camel.resume.ResumeAdapter;
+import org.apache.kafka.clients.consumer.Consumer;
+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.producer.Producer;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.errors.WakeupException;
 import org.junit.jupiter.api.Test;
 
+import static org.awaitility.Awaitility.await;
 import static org.junit.jupiter.api.Assertions.*;
 
 /**
@@ -78,6 +99,210 @@ public class SingleNodeKafkaResumeStrategyTest {
         assertSame(adapter, strategy.getAdapter());
     }
 
+    @Test
+    void testStrategyCanBeStartedAgainAfterStop() throws Exception {
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.start();
+
+            // nothing listens on port 9: no broker is needed to create, 
subscribe and close the clients
+            KafkaResumeStrategyConfiguration config
+                    = new KafkaResumeStrategyConfigurationBuilder(
+                            
KafkaResumeStrategyConfigurationBuilder.createProducerProperties(),
+                            
KafkaResumeStrategyConfigurationBuilder.createConsumerProperties())
+                            .withBootstrapServers("localhost:9")
+                            .withGroupId("test-group")
+                            .withEnableAutoCommit(false)
+                            .withTopic("test-topic")
+                            .build();
+
+            SubscriptionRecordingStrategy strategy = new 
SubscriptionRecordingStrategy(config);
+            strategy.setCamelContext(context);
+            strategy.setAdapter(new TestDeserializableAdapter());
+
+            try {
+                strategy.start();
+                strategy.loadCache();
+                Producer<byte[], byte[]> firstProducer = 
strategy.getProducer();
+                ExecutorService firstExecutor = strategy.getExecutorService();
+                assertTrue(executorsOf(context).contains(firstExecutor));
+                await().atMost(10, TimeUnit.SECONDS).until(() -> 
strategy.subscriptions.size() == 1);
+                strategy.stop();
+                // the executor is shut down, and removed from the manager of 
the context (and so from JMX)
+                assertTrue(firstExecutor.isShutdown());
+                assertFalse(executorsOf(context).contains(firstExecutor), "the 
executor stays registered");
+
+                // a route stop/start stops and starts the strategy again
+                strategy.start();
+                strategy.loadCache();
+                ExecutorService executor = strategy.getExecutorService();
+                assertAll(
+                        () -> assertNotNull(strategy.getProducer()),
+                        () -> assertNotSame(firstProducer, 
strategy.getProducer(), "the closed producer is used again"),
+                        () -> assertFalse(executor.isShutdown(), "the executor 
that was shut down is used again"));
+
+                // the new consumer subscribes to the topic again
+                await().atMost(10, TimeUnit.SECONDS).untilAsserted(
+                        () -> assertEquals(List.of(Set.of("test-topic"), 
Set.of("test-topic")), strategy.subscriptions));
+            } finally {
+                strategy.stop();
+            }
+        }
+    }
+
+    @Test
+    void testRefreshTaskThatOutlivesStopClosesOnlyItsOwnConsumer() throws 
Exception {
+        try (CamelContext context = new DefaultCamelContext()) {
+            context.start();
+            // the producer is created, and never sends: nothing listens on 
port 9
+            KafkaResumeStrategyConfiguration config
+                    = new KafkaResumeStrategyConfigurationBuilder(
+                            
KafkaResumeStrategyConfigurationBuilder.createProducerProperties(),
+                            
KafkaResumeStrategyConfigurationBuilder.createConsumerProperties())
+                            .withBootstrapServers("localhost:9")
+                            .withTopic("test-topic")
+                            .build();
+
+            // each refresh task gets its own MockConsumer, and blocks in its 
first poll until released, ignoring the
+            // wakeup and the interrupt of stop(), so the first task outlives 
stop()
+            List<MockConsumer<byte[], byte[]>> consumers = new 
CopyOnWriteArrayList<>();
+            List<CountDownLatch> polling = List.of(new CountDownLatch(1), new 
CountDownLatch(1));
+            List<CountDownLatch> release = List.of(new CountDownLatch(1), new 
CountDownLatch(1));
+            SingleNodeKafkaResumeStrategy strategy = new 
SingleNodeKafkaResumeStrategy(config) {
+                @Override
+                Consumer<byte[], byte[]> createConsumer() {
+                    MockConsumer<byte[], byte[]> mock = new 
MockConsumer<>("earliest");
+                    consumers.add(mock);
+                    return mock;
+                }
+
+                @Override
+                protected ConsumerRecords<byte[], byte[]> 
consume(Consumer<byte[], byte[]> consumer) {
+                    int task = consumers.indexOf(consumer);
+                    polling.get(task).countDown();
+                    awaitUninterruptibly(release.get(task));
+                    // ends the polling loop, as a wakeup does
+                    throw new WakeupException();
+                }
+            };
+            strategy.setCamelContext(context);
+            strategy.setAdapter(new TestDeserializableAdapter());
+
+            try {
+                strategy.start();
+                strategy.loadCache();
+                assertTrue(polling.get(0).await(10, TimeUnit.SECONDS));
+                // the first task does not end within the shutdown wait of 
stop()
+                strategy.stop();
+
+                strategy.start();
+                strategy.loadCache();
+                assertTrue(polling.get(1).await(10, TimeUnit.SECONDS));
+
+                // the first task ends now, and closes its consumer
+                release.get(0).countDown();
+                await().atMost(10, TimeUnit.SECONDS).until(() -> 
consumers.get(0).closed());
+                assertFalse(consumers.get(1).closed(), "the old refresh task 
closed the consumer of the new one");
+            } finally {
+                release.get(0).countDown();
+                release.get(1).countDown();
+                strategy.stop();
+            }
+            await().atMost(10, TimeUnit.SECONDS).until(() -> 
consumers.get(1).closed());
+        }
+    }
+
+    private static void awaitUninterruptibly(CountDownLatch latch) {
+        boolean interrupted = false;
+        try {
+            while (true) {
+                try {
+                    if (latch.await(10, TimeUnit.SECONDS)) {
+                        return;
+                    }
+                    throw new IllegalStateException("not released");
+                } catch (InterruptedException e) {
+                    interrupted = true;
+                }
+            }
+        } finally {
+            if (interrupted) {
+                Thread.currentThread().interrupt();
+            }
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private static List<ExecutorService> executorsOf(CamelContext context) 
throws Exception {
+        Class<?> type = context.getExecutorServiceManager().getClass();
+        while (type.getSuperclass() != null) {
+            try {
+                Field field = type.getDeclaredField("executorServices");
+                field.setAccessible(true);
+                return (List<ExecutorService>) 
field.get(context.getExecutorServiceManager());
+            } catch (NoSuchFieldException e) {
+                type = type.getSuperclass();
+            }
+        }
+        throw new NoSuchFieldException("executorServices");
+    }
+
+    @Test
+    void testRecordThatCannotBeDeserializedIsSkipped() {
+        TestDeserializableAdapter adapter = new TestDeserializableAdapter();
+        SingleNodeKafkaResumeStrategy strategy = new 
SingleNodeKafkaResumeStrategy() {
+            private int polls;
+
+            @Override
+            protected ConsumerRecords<byte[], byte[]> consume(Consumer<byte[], 
byte[]> consumer) {
+                if (polls++ > 0) {
+                    // ends the polling loop, as stop() does
+                    throw new WakeupException();
+                }
+                byte[] data = "data".getBytes(StandardCharsets.UTF_8);
+                return new ConsumerRecords<>(
+                        Map.of(new TopicPartition("test-topic", 0),
+                                List.of(new ConsumerRecord<>("test-topic", 0, 
0L, null, data),
+                                        // a tombstone: Camel does not write 
them, so it is skipped
+                                        new ConsumerRecord<>("test-topic", 0, 
1L, data, null),
+                                        new ConsumerRecord<>("test-topic", 0, 
2L, data, data))),
+                        Map.of());
+            }
+        };
+        strategy.setAdapter(adapter);
+
+        assertThrows(WakeupException.class, () -> strategy.poll(null, new 
CountDownLatch(1)));
+        // the record without a key and the tombstone are skipped, the next 
record is still loaded
+        assertEquals(1, adapter.deserialized.get());
+    }
+
+    static class SubscriptionRecordingStrategy extends 
SingleNodeKafkaResumeStrategy {
+        final List<Set<String>> subscriptions = new CopyOnWriteArrayList<>();
+
+        SubscriptionRecordingStrategy(KafkaResumeStrategyConfiguration config) 
{
+            super(config);
+        }
+
+        @Override
+        protected void checkAndSubscribe(Consumer<byte[], byte[]> consumer, 
String topic) {
+            super.checkAndSubscribe(consumer, topic);
+            subscriptions.add(new HashSet<>(consumer.subscription()));
+        }
+    }
+
+    static class TestDeserializableAdapter implements ResumeAdapter, 
Deserializable {
+        final AtomicInteger deserialized = new AtomicInteger();
+
+        @Override
+        public void resume() {
+        }
+
+        @Override
+        public boolean deserialize(ByteBuffer keyBuffer, ByteBuffer 
valueBuffer) {
+            deserialized.incrementAndGet();
+            return true;
+        }
+    }
+
     static class TestAdapter implements ResumeAdapter {
         @Override
         public void resume() {

Reply via email to