This is an automated email from the ASF dual-hosted git repository.
gnodet 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 dbb0045881bc CAMEL-24463: Add CAS methods and Duration TTL to
KeyValueRepository SPI (#25863)
dbb0045881bc is described below
commit dbb0045881bcbfc8b422dddaa061fdd98e8906d0
Author: Guillaume Nodet <[email protected]>
AuthorDate: Tue Sep 1 11:38:23 2026 +0200
CAMEL-24463: Add CAS methods and Duration TTL to KeyValueRepository SPI
(#25863)
Add compare-and-swap operations to KeyValueRepository:
- replace(key, expectedOldValue, newValue, ttl): atomic CAS replace
- delete(key, expectedValue): atomic CAS delete
Change TTL parameter from long millis to java.time.Duration across the
entire SPI for type safety. null/zero/negative Duration means no expiry.
Updated MemoryKeyValueRepository, KeyValueAggregationRepository,
KeyValueIdempotentRepository, and StateStoreProducer accordingly.
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../component/statestore/StateStoreProducer.java | 4 +-
.../org/apache/camel/spi/KeyValueRepository.java | 72 +++++++++--
.../support/KeyValueAggregationRepository.java | 4 +-
.../support/KeyValueIdempotentRepository.java | 2 +-
.../camel/support/MemoryKeyValueRepository.java | 44 ++++++-
.../support/KeyValueIdempotentRepositoryTest.java | 4 +-
.../support/MemoryKeyValueRepositoryTest.java | 131 ++++++++++++++++-----
7 files changed, 211 insertions(+), 50 deletions(-)
diff --git
a/components/camel-state-store/camel-state-store/src/main/java/org/apache/camel/component/statestore/StateStoreProducer.java
b/components/camel-state-store/camel-state-store/src/main/java/org/apache/camel/component/statestore/StateStoreProducer.java
index 4f4a175c6b11..2cb03af7218e 100644
---
a/components/camel-state-store/camel-state-store/src/main/java/org/apache/camel/component/statestore/StateStoreProducer.java
+++
b/components/camel-state-store/camel-state-store/src/main/java/org/apache/camel/component/statestore/StateStoreProducer.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.component.statestore;
+import java.time.Duration;
import java.util.Arrays;
import org.apache.camel.Exchange;
@@ -46,7 +47,8 @@ public class StateStoreProducer extends DefaultProducer {
}
KeyValueRepository backend = endpoint.getBackend();
- long ttl = determineTtl(exchange);
+ long ttlMillis = determineTtl(exchange);
+ Duration ttl = ttlMillis > 0 ? Duration.ofMillis(ttlMillis) : null;
Message message = exchange.getMessage();
switch (op) {
diff --git
a/core/camel-api/src/main/java/org/apache/camel/spi/KeyValueRepository.java
b/core/camel-api/src/main/java/org/apache/camel/spi/KeyValueRepository.java
index cd7d6f8a1ac6..fef877edda4c 100644
--- a/core/camel-api/src/main/java/org/apache/camel/spi/KeyValueRepository.java
+++ b/core/camel-api/src/main/java/org/apache/camel/spi/KeyValueRepository.java
@@ -16,6 +16,8 @@
*/
package org.apache.camel.spi;
+import java.time.Duration;
+import java.util.Objects;
import java.util.Set;
import org.apache.camel.Service;
@@ -33,8 +35,8 @@ import org.jspecify.annotations.Nullable;
* storage technology (Redis, Hazelcast, Infinispan, JDBC, etc.), a single
{@code KeyValueRepository} implementation can
* be wrapped by the appropriate adapter.
* <p/>
- * Implementations must be thread-safe. Entries may optionally have a
time-to-live (TTL); a TTL of {@code 0} or less
- * means the entry does not expire.
+ * Implementations must be thread-safe. Entries may optionally have a
time-to-live (TTL); a {@code null}, zero, or
+ * negative TTL means the entry does not expire.
*
* @since 4.23
*/
@@ -52,13 +54,13 @@ public interface KeyValueRepository extends Service {
/**
* Stores a value under the given key with an optional time-to-live.
*
- * @param key the key
- * @param value the value to store
- * @param ttlMillis the time-to-live in milliseconds; {@code 0} or
negative means no expiration
- * @return the previous value associated with the key, or {@code
null} if there was no mapping
+ * @param key the key
+ * @param value the value to store
+ * @param ttl the time-to-live; {@code null}, zero, or negative means
no expiration
+ * @return the previous value associated with the key, or {@code
null} if there was no mapping
*/
@Nullable
- Object put(String key, Object value, long ttlMillis);
+ Object put(String key, Object value, @Nullable Duration ttl);
/**
* Removes the entry for the given key.
@@ -95,21 +97,65 @@ public interface KeyValueRepository extends Service {
* The default implementation is not atomic. Implementations backed by
stores that support atomic compare-and-set
* operations should override this method for better concurrency
guarantees.
*
- * @param key the key
- * @param value the value to store
- * @param ttlMillis the time-to-live in milliseconds; {@code 0} or
negative means no expiration
- * @return the existing value if the key was already present, or
{@code null} if the put succeeded
+ * @param key the key
+ * @param value the value to store
+ * @param ttl the time-to-live; {@code null}, zero, or negative means
no expiration
+ * @return the existing value if the key was already present, or
{@code null} if the put succeeded
*/
@Nullable
- default Object putIfAbsent(String key, Object value, long ttlMillis) {
+ default Object putIfAbsent(String key, Object value, @Nullable Duration
ttl) {
Object existing = get(key);
if (existing != null) {
return existing;
}
- put(key, value, ttlMillis);
+ put(key, value, ttl);
return null;
}
+ /**
+ * Atomically replaces the value for the given key only if the current
value equals the expected old value
+ * (compare-and-swap).
+ * <p/>
+ * The default implementation is not atomic. Implementations backed by
stores that support atomic compare-and-swap
+ * operations (e.g., {@code ConcurrentMap.replace}, Hazelcast {@code
IMap.replace}) should override this method for
+ * better concurrency guarantees.
+ *
+ * @param key the key
+ * @param expectedOldValue the value that must currently be associated
with the key
+ * @param newValue the new value to store
+ * @param ttl the time-to-live for the new entry; {@code
null}, zero, or negative means no expiration
+ * @return {@code true} if the value was replaced, {@code
false} if the current value did not match
+ */
+ default boolean replace(String key, Object expectedOldValue, Object
newValue, @Nullable Duration ttl) {
+ Object current = get(key);
+ if (current != null && Objects.equals(current, expectedOldValue)) {
+ put(key, newValue, ttl);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Removes the entry for the given key only if the current value equals
the expected value (compare-and-swap).
+ * <p/>
+ * The default implementation is not atomic. Implementations backed by
stores that support atomic compare-and-remove
+ * operations (e.g., {@code ConcurrentMap.remove(key, value)}, Hazelcast
{@code IMap.remove(key, value)}) should
+ * override this method for better concurrency guarantees.
+ *
+ * @param key the key to remove
+ * @param expectedValue the value that must currently be associated with
the key
+ * @return {@code true} if the entry was removed, {@code
false} if the current value did not match or
+ * the key was not present
+ */
+ default boolean delete(String key, Object expectedValue) {
+ Object current = get(key);
+ if (current != null && Objects.equals(current, expectedValue)) {
+ delete(key);
+ return true;
+ }
+ return false;
+ }
+
/**
* Returns the number of non-expired entries in the repository.
*
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
index bfa46ae46514..95fb22141b5b 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueAggregationRepository.java
@@ -110,7 +110,7 @@ public class KeyValueAggregationRepository extends
ServiceSupport
public Exchange add(CamelContext camelContext, String key, Exchange
exchange) {
LOG.trace("Adding an Exchange with ID {} for key {}",
exchange.getExchangeId(), key);
DefaultExchangeHolder newHolder =
DefaultExchangeHolder.marshal(exchange, true, allowSerializedHeaders);
- DefaultExchangeHolder oldHolder = (DefaultExchangeHolder)
repository.put(AGGREGATE_PREFIX + key, newHolder, 0);
+ DefaultExchangeHolder oldHolder = (DefaultExchangeHolder)
repository.put(AGGREGATE_PREFIX + key, newHolder, null);
return unmarshallExchange(camelContext, oldHolder);
}
@@ -126,7 +126,7 @@ public class KeyValueAggregationRepository extends
ServiceSupport
if (useRecovery && holder != null) {
// Store under the exchangeId for potential recovery
LOG.trace("Moving Exchange with ID {} to completed (pending
confirmation)", exchange.getExchangeId());
- repository.put(COMPLETED_PREFIX + exchange.getExchangeId(),
holder, 0);
+ repository.put(COMPLETED_PREFIX + exchange.getExchangeId(),
holder, null);
}
}
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueIdempotentRepository.java
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueIdempotentRepository.java
index 010e913361a2..d11b656641e1 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueIdempotentRepository.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueIdempotentRepository.java
@@ -84,7 +84,7 @@ public class KeyValueIdempotentRepository extends
ServiceSupport implements Idem
@Override
public boolean add(String key) {
// putIfAbsent returns null if the key was successfully added (not
already present)
- return repository.putIfAbsent(IDEMPOTENT_PREFIX + key, Boolean.TRUE,
0) == null;
+ return repository.putIfAbsent(IDEMPOTENT_PREFIX + key, Boolean.TRUE,
null) == null;
}
@Override
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/MemoryKeyValueRepository.java
b/core/camel-support/src/main/java/org/apache/camel/support/MemoryKeyValueRepository.java
index f32527ea9094..d6ce48680ca9 100644
---
a/core/camel-support/src/main/java/org/apache/camel/support/MemoryKeyValueRepository.java
+++
b/core/camel-support/src/main/java/org/apache/camel/support/MemoryKeyValueRepository.java
@@ -18,8 +18,10 @@ package org.apache.camel.support;
import java.io.Serial;
import java.io.Serializable;
+import java.time.Duration;
import java.util.Iterator;
import java.util.Map;
+import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@@ -73,8 +75,8 @@ public class MemoryKeyValueRepository extends ServiceSupport
implements KeyValue
@Override
@ManagedOperation(description = "Put a key-value pair with optional TTL")
- public Object put(String key, Object value, long ttlMillis) {
- long expiresAt = ttlMillis > 0 ? System.currentTimeMillis() +
ttlMillis : Long.MAX_VALUE;
+ public Object put(String key, Object value, Duration ttl) {
+ long expiresAt = toExpiresAt(ttl);
Entry previous = store.put(key, new Entry(value, expiresAt));
if (previous == null) {
return null;
@@ -130,8 +132,8 @@ public class MemoryKeyValueRepository extends
ServiceSupport implements KeyValue
}
@Override
- public Object putIfAbsent(String key, Object value, long ttlMillis) {
- long expiresAt = ttlMillis > 0 ? System.currentTimeMillis() +
ttlMillis : Long.MAX_VALUE;
+ public Object putIfAbsent(String key, Object value, Duration ttl) {
+ long expiresAt = toExpiresAt(ttl);
Entry newEntry = new Entry(value, expiresAt);
Entry existing = store.putIfAbsent(key, newEntry);
if (existing == null) {
@@ -149,6 +151,33 @@ public class MemoryKeyValueRepository extends
ServiceSupport implements KeyValue
return existing.value();
}
+ @Override
+ public boolean replace(String key, Object expectedOldValue, Object
newValue, Duration ttl) {
+ long expiresAt = toExpiresAt(ttl);
+ boolean[] replaced = { false };
+ store.computeIfPresent(key, (k, current) -> {
+ if (!current.isExpired() && Objects.equals(current.value(),
expectedOldValue)) {
+ replaced[0] = true;
+ return new Entry(newValue, expiresAt);
+ }
+ return current;
+ });
+ return replaced[0];
+ }
+
+ @Override
+ public boolean delete(String key, Object expectedValue) {
+ boolean[] removed = { false };
+ store.computeIfPresent(key, (k, current) -> {
+ if (!current.isExpired() && Objects.equals(current.value(),
expectedValue)) {
+ removed[0] = true;
+ return null; // returning null removes the entry from the map
+ }
+ return current;
+ });
+ return removed[0];
+ }
+
@Override
@ManagedAttribute(description = "The number of entries in the repository")
public int size() {
@@ -161,6 +190,13 @@ public class MemoryKeyValueRepository extends
ServiceSupport implements KeyValue
store.clear();
}
+ private static long toExpiresAt(Duration ttl) {
+ if (ttl == null || ttl.isZero() || ttl.isNegative()) {
+ return Long.MAX_VALUE;
+ }
+ return System.currentTimeMillis() + ttl.toMillis();
+ }
+
private void evictExpired() {
Iterator<Map.Entry<String, Entry>> it = store.entrySet().iterator();
while (it.hasNext()) {
diff --git
a/core/camel-support/src/test/java/org/apache/camel/support/KeyValueIdempotentRepositoryTest.java
b/core/camel-support/src/test/java/org/apache/camel/support/KeyValueIdempotentRepositoryTest.java
index 6148fb5444ac..c1853e4c172a 100644
---
a/core/camel-support/src/test/java/org/apache/camel/support/KeyValueIdempotentRepositoryTest.java
+++
b/core/camel-support/src/test/java/org/apache/camel/support/KeyValueIdempotentRepositoryTest.java
@@ -145,7 +145,7 @@ class KeyValueIdempotentRepositoryTest {
@Test
void testClearDoesNotAffectOtherPrefixes() {
// Simulate another adapter storing entries under a different prefix
- kvRepository.put("aggregate:order-1", "exchange-holder", 0);
+ kvRepository.put("aggregate:order-1", "exchange-holder", null);
// Add idempotent entries and clear them
idempotentRepository.add("msg-001");
@@ -166,7 +166,7 @@ class KeyValueIdempotentRepositoryTest {
idempotentRepository.add("order-1");
// A different adapter storing under its own prefix should not collide
- kvRepository.put("aggregate:order-1", "exchange-data", 0);
+ kvRepository.put("aggregate:order-1", "exchange-data", null);
// The idempotent entry should still resolve correctly
assertThat(idempotentRepository.contains("order-1")).isTrue();
diff --git
a/core/camel-support/src/test/java/org/apache/camel/support/MemoryKeyValueRepositoryTest.java
b/core/camel-support/src/test/java/org/apache/camel/support/MemoryKeyValueRepositoryTest.java
index f8f9465cb255..e72a8f71ae26 100644
---
a/core/camel-support/src/test/java/org/apache/camel/support/MemoryKeyValueRepositoryTest.java
+++
b/core/camel-support/src/test/java/org/apache/camel/support/MemoryKeyValueRepositoryTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.support;
+import java.time.Duration;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@@ -43,7 +44,7 @@ class MemoryKeyValueRepositoryTest {
@Test
void testPutAndGet() {
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", null);
assertThat(repository.get("key1")).isEqualTo("value1");
}
@@ -55,15 +56,15 @@ class MemoryKeyValueRepositoryTest {
@Test
void testPutOverwritesExistingValue() {
- repository.put("key1", "value1", 0);
- repository.put("key1", "value2", 0);
+ repository.put("key1", "value1", null);
+ repository.put("key1", "value2", null);
assertThat(repository.get("key1")).isEqualTo("value2");
}
@Test
void testDelete() {
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", null);
Object deleted = repository.delete("key1");
@@ -78,7 +79,7 @@ class MemoryKeyValueRepositoryTest {
@Test
void testContains() {
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", null);
assertThat(repository.contains("key1")).isTrue();
assertThat(repository.contains("nonexistent")).isFalse();
@@ -86,9 +87,9 @@ class MemoryKeyValueRepositoryTest {
@Test
void testKeys() {
- repository.put("key1", "value1", 0);
- repository.put("key2", "value2", 0);
- repository.put("key3", "value3", 0);
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+ repository.put("key3", "value3", null);
Set<String> keys = repository.keys();
@@ -102,8 +103,8 @@ class MemoryKeyValueRepositoryTest {
@Test
void testClear() {
- repository.put("key1", "value1", 0);
- repository.put("key2", "value2", 0);
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
repository.clear();
@@ -116,10 +117,10 @@ class MemoryKeyValueRepositoryTest {
void testSize() {
assertThat(repository.size()).isZero();
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", null);
assertThat(repository.size()).isEqualTo(1);
- repository.put("key2", "value2", 0);
+ repository.put("key2", "value2", null);
assertThat(repository.size()).isEqualTo(2);
repository.delete("key1");
@@ -128,7 +129,7 @@ class MemoryKeyValueRepositoryTest {
@Test
void testPutIfAbsentNewKey() {
- Object result = repository.putIfAbsent("key1", "value1", 0);
+ Object result = repository.putIfAbsent("key1", "value1", null);
assertThat(result).isNull();
assertThat(repository.get("key1")).isEqualTo("value1");
@@ -136,9 +137,9 @@ class MemoryKeyValueRepositoryTest {
@Test
void testPutIfAbsentExistingKey() {
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", null);
- Object result = repository.putIfAbsent("key1", "value2", 0);
+ Object result = repository.putIfAbsent("key1", "value2", null);
assertThat(result).isEqualTo("value1");
assertThat(repository.get("key1")).isEqualTo("value1");
@@ -147,7 +148,7 @@ class MemoryKeyValueRepositoryTest {
@Test
void testTtlExpiration() {
// Use a very short TTL
- repository.put("key1", "value1", 50);
+ repository.put("key1", "value1", Duration.ofMillis(50));
assertThat(repository.get("key1")).isEqualTo("value1");
assertThat(repository.contains("key1")).isTrue();
@@ -162,8 +163,8 @@ class MemoryKeyValueRepositoryTest {
@Test
void testTtlExpirationOnKeys() {
- repository.put("key1", "value1", 50);
- repository.put("key2", "value2", 0); // no expiration
+ repository.put("key1", "value1", Duration.ofMillis(50));
+ repository.put("key2", "value2", null); // no expiration
await().atMost(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
@@ -174,7 +175,7 @@ class MemoryKeyValueRepositoryTest {
@Test
void testTtlExpirationOnDelete() {
- repository.put("key1", "value1", 50);
+ repository.put("key1", "value1", Duration.ofMillis(50));
await().atMost(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
@@ -185,29 +186,38 @@ class MemoryKeyValueRepositoryTest {
@Test
void testPutIfAbsentWithExpiredEntry() {
- repository.put("key1", "value1", 50);
+ repository.put("key1", "value1", Duration.ofMillis(50));
await().atMost(500, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
// The entry has expired, so putIfAbsent should succeed
- Object result = repository.putIfAbsent("key1", "value2",
0);
+ Object result = repository.putIfAbsent("key1", "value2",
null);
assertThat(result).isNull();
assertThat(repository.get("key1")).isEqualTo("value2");
});
}
+ @Test
+ void testNoTtlWithNull() {
+ repository.put("key1", "value1", null);
+
+ // Entry with null TTL should not expire
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
@Test
void testNoTtlWithZero() {
- repository.put("key1", "value1", 0);
+ repository.put("key1", "value1", Duration.ZERO);
- // Entry with TTL=0 should not expire
+ // Entry with zero TTL should not expire
assertThat(repository.get("key1")).isEqualTo("value1");
assertThat(repository.contains("key1")).isTrue();
}
@Test
void testNoTtlWithNegative() {
- repository.put("key1", "value1", -1);
+ repository.put("key1", "value1", Duration.ofMillis(-1));
// Entry with negative TTL should not expire
assertThat(repository.get("key1")).isEqualTo("value1");
@@ -216,12 +226,79 @@ class MemoryKeyValueRepositoryTest {
@Test
void testStoresDifferentValueTypes() {
- repository.put("string", "hello", 0);
- repository.put("integer", 42, 0);
- repository.put("boolean", Boolean.TRUE, 0);
+ repository.put("string", "hello", null);
+ repository.put("integer", 42, null);
+ repository.put("boolean", Boolean.TRUE, null);
assertThat(repository.get("string")).isEqualTo("hello");
assertThat(repository.get("integer")).isEqualTo(42);
assertThat(repository.get("boolean")).isEqualTo(Boolean.TRUE);
}
+
+ @Test
+ void testReplaceMatchingValue() {
+ repository.put("key1", "value1", null);
+
+ boolean replaced = repository.replace("key1", "value1", "value2",
null);
+
+ assertThat(replaced).isTrue();
+ assertThat(repository.get("key1")).isEqualTo("value2");
+ }
+
+ @Test
+ void testReplaceNonMatchingValue() {
+ repository.put("key1", "value1", null);
+
+ boolean replaced = repository.replace("key1", "wrong", "value2", null);
+
+ assertThat(replaced).isFalse();
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testReplaceMissingKey() {
+ boolean replaced = repository.replace("nonexistent", "value1",
"value2", null);
+
+ assertThat(replaced).isFalse();
+ }
+
+ @Test
+ void testReplaceWithTtl() {
+ repository.put("key1", "value1", null);
+
+ boolean replaced = repository.replace("key1", "value1", "value2",
Duration.ofMillis(500));
+
+ assertThat(replaced).isTrue();
+ assertThat(repository.get("key1")).isEqualTo("value2");
+
+ await().atMost(5, TimeUnit.SECONDS)
+ .untilAsserted(() ->
assertThat(repository.get("key1")).isNull());
+ }
+
+ @Test
+ void testDeleteWithMatchingValue() {
+ repository.put("key1", "value1", null);
+
+ boolean deleted = repository.delete("key1", "value1");
+
+ assertThat(deleted).isTrue();
+ assertThat(repository.get("key1")).isNull();
+ }
+
+ @Test
+ void testDeleteWithNonMatchingValue() {
+ repository.put("key1", "value1", null);
+
+ boolean deleted = repository.delete("key1", "wrong");
+
+ assertThat(deleted).isFalse();
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testDeleteWithMissingKey() {
+ boolean deleted = repository.delete("nonexistent", "value1");
+
+ assertThat(deleted).isFalse();
+ }
}