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 a44c49fa3420 CAMEL-26740: camel-kafka - KafkaKeyValueRepository keys() 
and size() fail once an entry has expired, and putIfAbsent can report an insert 
that did not happen (#29434)
a44c49fa3420 is described below

commit a44c49fa342061c2fab8313627719f80a94bccac
Author: allthingssecurity <[email protected]>
AuthorDate: Mon Nov 9 19:19:00 2026 +0530

    CAMEL-26740: camel-kafka - KafkaKeyValueRepository keys() and size() fail 
once an entry has expired, and putIfAbsent can report an insert that did not 
happen (#29434)
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../keyvalue/kafka/KafkaKeyValueRepository.java    |  35 ++--
 .../kafka/KafkaKeyValueRepositoryTest.java         | 189 +++++++++++++++++++++
 2 files changed, 206 insertions(+), 18 deletions(-)

diff --git 
a/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
 
b/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
index 707b55f87032..9e058b991c73 100644
--- 
a/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
+++ 
b/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
@@ -20,7 +20,6 @@ import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.nio.ByteBuffer;
 import java.time.Duration;
-import java.util.Iterator;
 import java.util.Map;
 import java.util.Properties;
 import java.util.Set;
@@ -252,20 +251,19 @@ public class KafkaKeyValueRepository extends 
ServiceSupport implements KeyValueR
      */
     @Override
     public Object putIfAbsent(String key, Object value, Duration ttl) {
-        CacheEntry existing = cache.get(key);
-        if (existing != null && !existing.isExpired()) {
-            return existing.value;
-        }
-        // Remove expired entry if present
-        if (existing != null) {
-            cache.remove(key, existing);
-        }
         long expiresAt = toExpiresAt(ttl);
         CacheEntry newEntry = new CacheEntry(value, expiresAt);
-        CacheEntry prev = cache.putIfAbsent(key, newEntry);
-        if (prev != null) {
-            // Another thread beat us
-            return prev.isExpired() ? null : prev.value;
+        CacheEntry existing = cache.putIfAbsent(key, newEntry);
+        while (existing != null) {
+            if (!existing.isExpired()) {
+                return existing.value;
+            }
+            // replace the expired entry, unless another thread or the topic 
poller changed the key meanwhile: then look
+            // again, as null must only be returned when this value was stored
+            if (cache.replace(key, existing, newEntry)) {
+                break;
+            }
+            existing = cache.putIfAbsent(key, newEntry);
         }
         try {
             broadcastPut(key, value, expiresAt);
@@ -381,11 +379,12 @@ public class KafkaKeyValueRepository extends 
ServiceSupport implements KeyValueR
     }
 
     private void evictExpired() {
-        Iterator<Map.Entry<String, CacheEntry>> it = 
cache.entrySet().iterator();
-        while (it.hasNext()) {
-            Map.Entry<String, CacheEntry> entry = it.next();
-            if (entry.getValue().isExpired()) {
-                it.remove();
+        // remove through the cache: the entry set of the default cache 
(SimpleLRUCache) is a copy that cannot remove. Only
+        // the expired entry is removed, as the key may have been put again 
meanwhile
+        for (Map.Entry<String, CacheEntry> entry : cache.entrySet()) {
+            CacheEntry value = entry.getValue();
+            if (value.isExpired()) {
+                cache.remove(entry.getKey(), value);
             }
         }
     }
diff --git 
a/components/camel-kafka/src/test/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepositoryTest.java
 
b/components/camel-kafka/src/test/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepositoryTest.java
new file mode 100644
index 000000000000..4d29116e7fb8
--- /dev/null
+++ 
b/components/camel-kafka/src/test/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepositoryTest.java
@@ -0,0 +1,189 @@
+/*
+ * 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.processor.keyvalue.kafka;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.camel.processor.idempotent.kafka.KafkaChangelog;
+import org.apache.camel.support.cache.SimpleLRUCache;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.clients.producer.MockProducer;
+import org.apache.kafka.clients.producer.Producer;
+import org.apache.kafka.common.serialization.ByteArrayDeserializer;
+import org.apache.kafka.common.serialization.ByteArraySerializer;
+import org.apache.kafka.common.serialization.StringSerializer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * Unit tests for the expiry handling of {@link KafkaKeyValueRepository}, 
without a Kafka broker: the changelog sends to
+ * a {@link MockProducer}, and the records of the topic are applied by calling 
the repository's poller callback.
+ */
+public class KafkaKeyValueRepositoryTest {
+
+    // an expiry time long past: the entry has expired when it is applied, as 
for a record replayed on startup
+    private static final long EXPIRED = 1L;
+
+    private MockProducer<String, byte[]> producer;
+    private KafkaKeyValueRepository repository;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        producer = new MockProducer<>(true, null, new StringSerializer(), new 
ByteArraySerializer());
+        repository = new KafkaKeyValueRepository("test-topic", 
"localhost:9092");
+        // the cache that LRUCacheFactory creates by default
+        setField("cache", new SimpleLRUCache<>(16, 16, v -> {
+        }));
+        setField("changelog", changelog(producer));
+    }
+
+    @Test
+    void keysSkipsAnEntryThatHasExpired() throws Exception {
+        repository.put("live", "a", null);
+        applyPut("expired", "b", EXPIRED);
+
+        assertEquals(Set.of("live"), repository.keys());
+        assertNull(repository.get("expired"));
+    }
+
+    @Test
+    void sizeCountsOnlyTheEntriesThatHaveNotExpired() throws Exception {
+        repository.put("live", "a", null);
+        applyPut("expired", "b", EXPIRED);
+
+        assertEquals(1, repository.size());
+        assertFalse(cache().containsKey("expired"), "The expired entry was not 
evicted");
+    }
+
+    @Test
+    void putIfAbsentReplacesAnEntryThatHasExpired() throws Exception {
+        applyPut("key", "old", EXPIRED);
+
+        assertNull(repository.putIfAbsent("key", "new", null));
+
+        assertEquals("new", repository.get("key"));
+        assertEquals(1, producer.history().size());
+        assertEquals("key", producer.history().get(0).key());
+    }
+
+    @Test
+    void 
putIfAbsentStoresAndSendsTheValueWhenTheEntryFoundMeanwhileHasExpired() throws 
Exception {
+        // the poller applies an expired record of the key after putIfAbsent 
found the key absent, and before it stores
+        // its own entry (the poller can lag behind by more than the time to 
live of the record)
+        setField("cache", new ConcurrentHashMap<String, Object>() {
+            private boolean applied;
+
+            @Override
+            public Object putIfAbsent(String key, Object value) {
+                if (!applied) {
+                    applied = true;
+                    applyPut(key, "peer", EXPIRED);
+                }
+                return super.putIfAbsent(key, value);
+            }
+        });
+
+        // null means that the value was stored
+        assertNull(repository.putIfAbsent("key", "mine", null));
+
+        assertEquals("mine", repository.get("key"));
+        assertEquals(1, producer.history().size(), "The value was not sent to 
the topic");
+    }
+
+    @Test
+    void putIfAbsentReturnsTheValueStoredMeanwhileInsteadOfAnExpiredEntry() 
throws Exception {
+        applyPut("key", "old", EXPIRED);
+        // the poller applies a live record of the key after putIfAbsent found 
the expired entry, and before it replaces
+        // that entry
+        setField("cache", new ConcurrentHashMap<String, Object>(cache()) {
+            private boolean applied;
+
+            @Override
+            public boolean replace(String key, Object oldValue, Object 
newValue) {
+                if (!applied) {
+                    applied = true;
+                    applyPut(key, "peer", 0);
+                }
+                return super.replace(key, oldValue, newValue);
+            }
+        });
+
+        assertEquals("peer", repository.putIfAbsent("key", "mine", null));
+
+        assertEquals("peer", repository.get("key"));
+        assertEquals(0, producer.history().size());
+    }
+
+    @Test
+    void putIfAbsentReturnsTheValueOfAnEntryThatHasNotExpired() throws 
Exception {
+        applyPut("key", "peer", 0);
+
+        assertEquals("peer", repository.putIfAbsent("key", "mine", null));
+
+        assertEquals("peer", repository.get("key"));
+        assertEquals(0, producer.history().size());
+    }
+
+    /**
+     * Applies a put record of the topic to the cache, as the poller does.
+     */
+    private void applyPut(String key, Object value, long expiresAt) {
+        try {
+            Method serialize = 
KafkaKeyValueRepository.class.getDeclaredMethod("serializePutAction", 
Object.class, long.class);
+            serialize.setAccessible(true);
+            byte[] payload = (byte[]) serialize.invoke(repository, value, 
expiresAt);
+            Method addToCache = 
KafkaKeyValueRepository.class.getDeclaredMethod("addToCache", 
ConsumerRecord.class);
+            addToCache.setAccessible(true);
+            addToCache.invoke(repository, new ConsumerRecord<>("test-topic", 
0, 0L, key, payload));
+        } catch (ReflectiveOperationException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> cache() throws Exception {
+        Field field = KafkaKeyValueRepository.class.getDeclaredField("cache");
+        field.setAccessible(true);
+        return (Map<String, Object>) field.get(repository);
+    }
+
+    private static KafkaChangelog<byte[]> changelog(Producer<String, byte[]> 
producer) throws Exception {
+        KafkaChangelog<byte[]> changelog = new KafkaChangelog<>(
+                "KafkaKeyValueRepository", "test-topic", new Properties(), new 
Properties(), ByteArrayDeserializer.class,
+                ByteArraySerializer.class, 100, true, record -> {
+                });
+        Method setProducer = 
KafkaChangelog.class.getDeclaredMethod("setProducer", Producer.class);
+        setProducer.setAccessible(true);
+        setProducer.invoke(changelog, producer);
+        return changelog;
+    }
+
+    private void setField(String name, Object value) throws Exception {
+        Field field = KafkaKeyValueRepository.class.getDeclaredField(name);
+        field.setAccessible(true);
+        field.set(repository, value);
+    }
+}

Reply via email to