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 5a29c8a2e742 CAMEL-24463: Add KeyValueRepository implementations for
Caffeine, Ehcache, JCache, and Hazelcast (#25816)
5a29c8a2e742 is described below
commit 5a29c8a2e74227e4f28938302764f8e85a641be3
Author: Guillaume Nodet <[email protected]>
AuthorDate: Wed Sep 2 10:42:54 2026 +0200
CAMEL-24463: Add KeyValueRepository implementations for Caffeine, Ehcache,
JCache, and Hazelcast (#25816)
CAMEL-24463: Add KeyValueRepository implementations for Caffeine, Ehcache,
JCache, and Hazelcast
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
.../org/apache/camel/catalog/beans.properties | 4 +
.../catalog/beans/CaffeineKeyValueRepository.json | 16 ++
.../catalog/beans/EhcacheKeyValueRepository.json | 16 ++
.../catalog/beans/HazelcastKeyValueRepository.json | 16 ++
.../catalog/beans/JCacheKeyValueRepository.json | 16 ++
components/camel-caffeine/pom.xml | 11 +
.../CaffeineKeyValueRepositoryConfigurer.java | 51 ++++
.../services/org/apache/camel/bean.properties | 2 +-
.../camel/bean/CaffeineKeyValueRepository.json | 16 ++
...t.caffeine.processor.CaffeineKeyValueRepository | 2 +
.../processor/CaffeineKeyValueRepository.java | 228 ++++++++++++++++
.../processor/CaffeineKeyValueRepositoryTest.java | 303 +++++++++++++++++++++
components/camel-ehcache/pom.xml | 11 +
.../EhcacheKeyValueRepositoryConfigurer.java | 57 ++++
.../services/org/apache/camel/bean.properties | 2 +-
.../camel/bean/EhcacheKeyValueRepository.json | 16 ++
...ent.ehcache.processor.EhcacheKeyValueRepository | 2 +
.../processor/EhcacheKeyValueRepository.java | 225 +++++++++++++++
.../processor/EhcacheKeyValueRepositoryTest.java | 269 ++++++++++++++++++
components/camel-hazelcast/pom.xml | 6 +
.../HazelcastKeyValueRepositoryConfigurer.java | 57 ++++
.../services/org/apache/camel/bean.properties | 2 +-
.../camel/bean/HazelcastKeyValueRepository.json | 16 ++
...component.hazelcast.HazelcastKeyValueRepository | 2 +
.../hazelcast/HazelcastKeyValueRepository.java | 226 +++++++++++++++
.../hazelcast/HazelcastKeyValueRepositoryTest.java | 300 ++++++++++++++++++++
components/camel-jcache/pom.xml | 11 +
.../JCacheKeyValueRepositoryConfigurer.java | 54 ++++
.../services/org/apache/camel/bean.properties | 2 +-
.../camel/bean/JCacheKeyValueRepository.json | 16 ++
...onent.jcache.processor.JCacheKeyValueRepository | 2 +
.../jcache/processor/JCacheKeyValueRepository.java | 236 ++++++++++++++++
.../processor/JCacheKeyValueRepositoryTest.java | 269 ++++++++++++++++++
33 files changed, 2458 insertions(+), 4 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans.properties
index a7cd67fa6ec4..24bfb7f4b7ac 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans.properties
@@ -1,6 +1,7 @@
AcceptAllHeaderFilterStrategy
CaffeineAggregationRepository
CaffeineIdempotentRepository
+CaffeineKeyValueRepository
CassandraAggregationRepository
CassandraIdempotentRepository
ConsulClusterService
@@ -10,6 +11,7 @@ DurationRoutePolicy
DurationRoutePolicyFactory
EhcacheAggregationRepository
EhcacheIdempotentRepository
+EhcacheKeyValueRepository
ElasticsearchBulkRequestAggregationStrategy
FileIdempotentRepository
FileLockClusterService
@@ -18,6 +20,7 @@ GroupedExchangeAggregationStrategy
GroupedMessageAggregationStrategy
HazelcastAggregationRepository
HazelcastIdempotentRepository
+HazelcastKeyValueRepository
InfinispanEmbeddedAggregationRepository
InfinispanEmbeddedClusterService
InfinispanEmbeddedIdempotentRepository
@@ -27,6 +30,7 @@ InfinispanRemoteIdempotentRepository
InfinispanRemoteKeyValueRepository
JCacheAggregationRepository
JCacheIdempotentRepository
+JCacheKeyValueRepository
JGroupsRaftClusterService
JdbcAggregationRepository
JdbcMessageIdRepository
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/CaffeineKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/CaffeineKeyValueRepository.json
new file mode 100644
index 000000000000..be8644b3e7cf
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/CaffeineKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "CaffeineKeyValueRepository",
+ "javaType":
"org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Caffeine Key Value Repository",
+ "description": "A Caffeine-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-caffeine",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "maximumSize": { "index": 0, "kind": "property",
"displayName": "Maximum Size", "required": false, "type": "integer",
"javaType": "int", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": 0, "description": "Maximum number of entries in the cache. 0 or
negative means unbounded." } }
+ }
+}
+
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/EhcacheKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/EhcacheKeyValueRepository.json
new file mode 100644
index 000000000000..1d137c8f34d5
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/EhcacheKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "EhcacheKeyValueRepository",
+ "javaType":
"org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Ehcache Key Value Repository",
+ "description": "An Ehcache-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-ehcache",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "cacheName": { "index": 0, "kind": "property",
"displayName": "Cache Name", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": "EhcacheKeyValueRepository", "description": "Name of cache" },
"cacheManager": { "index": 1, "kind": "property", "displayName": "Cache
Manager", "required": false, "type": "object", "javaType":
"org.ehcache.CacheManager", "deprecated": false, "autowired [...]
+ }
+}
+
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/HazelcastKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/HazelcastKeyValueRepository.json
new file mode 100644
index 000000000000..22d363a3f59c
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/HazelcastKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "HazelcastKeyValueRepository",
+ "javaType":
"org.apache.camel.component.hazelcast.HazelcastKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Hazelcast Key Value Repository",
+ "description": "A Hazelcast-based KeyValueRepository with native per-entry
TTL support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-hazelcast",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "mapName": { "index": 0, "kind": "property",
"displayName": "Map Name", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": "HazelcastKeyValueRepository", "description": "Name of the
Hazelcast map to use" }, "hazelcastInstance": { "index": 1, "kind": "property",
"displayName": "Hazelcast Instance", "required": false, "type": "object",
"javaType": "com.hazelcast.core.HazelcastIns [...]
+ }
+}
+
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JCacheKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JCacheKeyValueRepository.json
new file mode 100644
index 000000000000..3150cfb87a19
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JCacheKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "JCacheKeyValueRepository",
+ "javaType":
"org.apache.camel.component.jcache.processor.JCacheKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "JCache Key Value Repository",
+ "description": "A JCache-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-jcache",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "configuration": { "index": 0, "kind": "property",
"displayName": "Configuration", "required": false, "type": "object",
"javaType": "org.apache.camel.component.jcache.JCacheConfiguration",
"deprecated": false, "autowired": false, "secret": false, "description":
"Configuration for JCache" } }
+ }
+}
+
diff --git a/components/camel-caffeine/pom.xml
b/components/camel-caffeine/pom.xml
index b0af285493b6..5d2cb25228b6 100644
--- a/components/camel-caffeine/pom.xml
+++ b/components/camel-caffeine/pom.xml
@@ -51,6 +51,17 @@
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-spring-junit6</artifactId>
diff --git
a/components/camel-caffeine/src/generated/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryConfigurer.java
b/components/camel-caffeine/src/generated/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryConfigurer.java
new file mode 100644
index 000000000000..0ad0789e29bc
--- /dev/null
+++
b/components/camel-caffeine/src/generated/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryConfigurer.java
@@ -0,0 +1,51 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.component.caffeine.processor;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import
org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class CaffeineKeyValueRepositoryConfigurer extends
org.apache.camel.support.component.PropertyConfigurerSupport implements
GeneratedPropertyConfigurer, PropertyConfigurerGetter {
+
+ @Override
+ public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
+
org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository target
= (org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository)
obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "maximumsize":
+ case "maximumSize": target.setMaximumSize(property(camelContext,
int.class, value)); return true;
+ default: return false;
+ }
+ }
+
+ @Override
+ public Class<?> getOptionType(String name, boolean ignoreCase) {
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "maximumsize":
+ case "maximumSize": return int.class;
+ default: return null;
+ }
+ }
+
+ @Override
+ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+
org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository target
= (org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository)
obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "maximumsize":
+ case "maximumSize": return target.getMaximumSize();
+ default: return null;
+ }
+ }
+}
+
diff --git
a/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
index 0d8d2081b358..b69e2a390cf0 100644
---
a/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
+++
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
@@ -1,5 +1,5 @@
# Generated by camel build tools - do NOT edit this file!
-bean=CaffeineAggregationRepository CaffeineIdempotentRepository
+bean=CaffeineAggregationRepository CaffeineIdempotentRepository
CaffeineKeyValueRepository
groupId=org.apache.camel
artifactId=camel-caffeine
version=4.23.0-SNAPSHOT
diff --git
a/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean/CaffeineKeyValueRepository.json
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean/CaffeineKeyValueRepository.json
new file mode 100644
index 000000000000..be8644b3e7cf
--- /dev/null
+++
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/bean/CaffeineKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "CaffeineKeyValueRepository",
+ "javaType":
"org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Caffeine Key Value Repository",
+ "description": "A Caffeine-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-caffeine",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "maximumSize": { "index": 0, "kind": "property",
"displayName": "Maximum Size", "required": false, "type": "integer",
"javaType": "int", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": 0, "description": "Maximum number of entries in the cache. 0 or
negative means unbounded." } }
+ }
+}
+
diff --git
a/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository
new file mode 100644
index 000000000000..fe577a069252
--- /dev/null
+++
b/components/camel-caffeine/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepository
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.caffeine.processor.CaffeineKeyValueRepositoryConfigurer
diff --git
a/components/camel-caffeine/src/main/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepository.java
b/components/camel-caffeine/src/main/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepository.java
new file mode 100644
index 000000000000..9b38ef707b22
--- /dev/null
+++
b/components/camel-caffeine/src/main/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepository.java
@@ -0,0 +1,228 @@
+/*
+ * 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.caffeine.processor;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentMap;
+
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.Expiry;
+import org.apache.camel.api.management.ManagedAttribute;
+import org.apache.camel.api.management.ManagedOperation;
+import org.apache.camel.api.management.ManagedResource;
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.KeyValueRepository;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.support.service.ServiceSupport;
+
+/**
+ * A {@link KeyValueRepository} implementation backed by a Caffeine {@link
Cache} with per-entry TTL support.
+ * <p/>
+ * Caffeine's {@link Expiry} API is used to assign a TTL to each entry
individually. Entries without a TTL (null, zero,
+ * or negative duration) are stored with {@link Long#MAX_VALUE} as their
duration so they effectively never expire.
+ * <p/>
+ * This single implementation can serve as idempotent repository, aggregation
repository, and state store via the
+ * adapters in {@code camel-support} ({@code KeyValueIdempotentRepository} and
{@code KeyValueAggregationRepository}).
+ *
+ * @since 4.23
+ */
+@Metadata(label = "bean",
+ description = "A Caffeine-based KeyValueRepository with per-entry
TTL support.",
+ annotations = {
"interfaceName=org.apache.camel.spi.KeyValueRepository" })
+@Configurer(metadataOnly = true)
+@ManagedResource(description = "Caffeine based key-value repository")
+public class CaffeineKeyValueRepository extends ServiceSupport implements
KeyValueRepository {
+
+ /**
+ * Internal value wrapper that carries the per-entry TTL so the custom
{@link Expiry} can read it at insertion time.
+ */
+ private static final class TtlValue {
+
+ private final Object value;
+ private final long ttlNanos;
+
+ TtlValue(Object value, long ttlNanos) {
+ this.value = value;
+ this.ttlNanos = ttlNanos;
+ }
+
+ Object value() {
+ return value;
+ }
+
+ long ttlNanos() {
+ return ttlNanos;
+ }
+ }
+
+ private Cache<String, TtlValue> cache;
+
+ @Metadata(description = "Maximum number of entries in the cache. 0 or
negative means unbounded.",
+ defaultValue = "0")
+ private int maximumSize;
+
+ /**
+ * Creates a new Caffeine-backed key-value repository with default
settings (unbounded, no global TTL).
+ */
+ public CaffeineKeyValueRepository() {
+ }
+
+ /**
+ * Creates a new Caffeine-backed key-value repository wrapping a pre-built
cache.
+ * <p/>
+ * This is primarily for testing. The supplied cache must use {@link
TtlValue} as its value type.
+ */
+ CaffeineKeyValueRepository(Cache<String, TtlValue> cache) {
+ this.cache = cache;
+ }
+
+ public int getMaximumSize() {
+ return maximumSize;
+ }
+
+ /**
+ * Sets the maximum number of entries in the cache. 0 or negative means
unbounded.
+ */
+ public void setMaximumSize(int maximumSize) {
+ this.maximumSize = maximumSize;
+ }
+
+ @Override
+ @ManagedOperation(description = "Get value by key")
+ public Object get(String key) {
+ TtlValue entry = cache.getIfPresent(key);
+ return entry != null ? entry.value() : null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Put a key-value pair with optional TTL")
+ public Object put(String key, Object value, Duration ttl) {
+ long ttlNanos = hasPositiveTtl(ttl) ? ttl.toNanos() : Long.MAX_VALUE;
+ TtlValue previous = cache.asMap().put(key, new TtlValue(value,
ttlNanos));
+ return previous != null ? previous.value() : null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Delete a key")
+ public Object delete(String key) {
+ TtlValue previous = cache.asMap().remove(key);
+ return previous != null ? previous.value() : null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Check if key exists")
+ public boolean contains(String key) {
+ return cache.getIfPresent(key) != null;
+ }
+
+ @Override
+ public Set<String> keys() {
+ cache.cleanUp();
+ return Set.copyOf(cache.asMap().keySet());
+ }
+
+ @Override
+ @ManagedOperation(description = "Clear all entries")
+ public void clear() {
+ cache.invalidateAll();
+ }
+
+ @Override
+ public Object putIfAbsent(String key, Object value, Duration ttl) {
+ long ttlNanos = hasPositiveTtl(ttl) ? ttl.toNanos() : Long.MAX_VALUE;
+ ConcurrentMap<String, TtlValue> map = cache.asMap();
+ TtlValue newEntry = new TtlValue(value, ttlNanos);
+ TtlValue existing = map.putIfAbsent(key, newEntry);
+ return existing != null ? existing.value() : null;
+ }
+
+ @Override
+ public boolean replace(String key, Object expectedOldValue, Object
newValue, Duration ttl) {
+ long ttlNanos = hasPositiveTtl(ttl) ? ttl.toNanos() : Long.MAX_VALUE;
+ boolean[] replaced = { false };
+ cache.asMap().computeIfPresent(key, (k, current) -> {
+ if (Objects.equals(current.value(), expectedOldValue)) {
+ replaced[0] = true;
+ return new TtlValue(newValue, ttlNanos);
+ }
+ return current;
+ });
+ return replaced[0];
+ }
+
+ @Override
+ public boolean delete(String key, Object expectedValue) {
+ boolean[] removed = { false };
+ cache.asMap().computeIfPresent(key, (k, current) -> {
+ if (Objects.equals(current.value(), expectedValue)) {
+ removed[0] = true;
+ return null; // returning null removes the entry
+ }
+ return current;
+ });
+ return removed[0];
+ }
+
+ @Override
+ @ManagedAttribute(description = "The number of entries in the repository")
+ public int size() {
+ cache.cleanUp();
+ return (int) cache.estimatedSize();
+ }
+
+ private static boolean hasPositiveTtl(Duration ttl) {
+ return ttl != null && !ttl.isZero() && !ttl.isNegative();
+ }
+
+ @Override
+ protected void doStart() throws Exception {
+ if (cache == null) {
+ Caffeine<Object, Object> builder = Caffeine.newBuilder();
+ if (maximumSize > 0) {
+ builder.maximumSize(maximumSize);
+ }
+ // Use a custom Expiry to support per-entry TTL
+ builder.expireAfter(new Expiry<String, TtlValue>() {
+ @Override
+ public long expireAfterCreate(String key, TtlValue value, long
currentTime) {
+ return value.ttlNanos();
+ }
+
+ @Override
+ public long expireAfterUpdate(String key, TtlValue value, long
currentTime, long currentDuration) {
+ return value.ttlNanos();
+ }
+
+ @Override
+ public long expireAfterRead(String key, TtlValue value, long
currentTime, long currentDuration) {
+ return currentDuration;
+ }
+ });
+ cache = builder.build();
+ }
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ if (cache != null) {
+ cache.invalidateAll();
+ }
+ }
+}
diff --git
a/components/camel-caffeine/src/test/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryTest.java
b/components/camel-caffeine/src/test/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryTest.java
new file mode 100644
index 000000000000..fe70ec6d794b
--- /dev/null
+++
b/components/camel-caffeine/src/test/java/org/apache/camel/component/caffeine/processor/CaffeineKeyValueRepositoryTest.java
@@ -0,0 +1,303 @@
+/*
+ * 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.caffeine.processor;
+
+import java.time.Duration;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+class CaffeineKeyValueRepositoryTest {
+
+ private CaffeineKeyValueRepository repository;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ repository = new CaffeineKeyValueRepository();
+ repository.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ repository.stop();
+ }
+
+ @Test
+ void testPutAndGet() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testGetMissingKeyReturnsNull() {
+ assertThat(repository.get("nonexistent")).isNull();
+ }
+
+ @Test
+ void testPutOverwritesExistingValue() {
+ repository.put("key1", "value1", null);
+ repository.put("key1", "value2", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value2");
+ }
+
+ @Test
+ void testPutReturnsPreviousValue() {
+ repository.put("key1", "value1", null);
+ Object previous = repository.put("key1", "value2", null);
+
+ assertThat(previous).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutReturnsNullForNewKey() {
+ Object previous = repository.put("key1", "value1", null);
+
+ assertThat(previous).isNull();
+ }
+
+ @Test
+ void testDelete() {
+ repository.put("key1", "value1", null);
+
+ Object deleted = repository.delete("key1");
+
+ assertThat(deleted).isEqualTo("value1");
+ assertThat(repository.get("key1")).isNull();
+ }
+
+ @Test
+ void testDeleteMissingKeyReturnsNull() {
+ assertThat(repository.delete("nonexistent")).isNull();
+ }
+
+ @Test
+ void testContains() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.contains("key1")).isTrue();
+ assertThat(repository.contains("nonexistent")).isFalse();
+ }
+
+ @Test
+ void testKeys() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+ repository.put("key3", "value3", null);
+
+ Set<String> keys = repository.keys();
+
+ assertThat(keys).containsExactlyInAnyOrder("key1", "key2", "key3");
+ }
+
+ @Test
+ void testKeysEmpty() {
+ assertThat(repository.keys()).isEmpty();
+ }
+
+ @Test
+ void testClear() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+
+ repository.clear();
+
+ assertThat(repository.size()).isZero();
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.get("key2")).isNull();
+ }
+
+ @Test
+ void testSize() {
+ assertThat(repository.size()).isZero();
+
+ repository.put("key1", "value1", null);
+ assertThat(repository.size()).isEqualTo(1);
+
+ repository.put("key2", "value2", null);
+ assertThat(repository.size()).isEqualTo(2);
+
+ repository.delete("key1");
+ assertThat(repository.size()).isEqualTo(1);
+ }
+
+ @Test
+ void testPutIfAbsentNewKey() {
+ Object result = repository.putIfAbsent("key1", "value1", null);
+
+ assertThat(result).isNull();
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutIfAbsentExistingKey() {
+ repository.put("key1", "value1", null);
+
+ Object result = repository.putIfAbsent("key1", "value2", null);
+
+ assertThat(result).isEqualTo("value1");
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testTtlExpiration() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.contains("key1")).isFalse();
+ });
+ }
+
+ @Test
+ void testTtlExpirationOnKeys() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+ repository.put("key2", "value2", null); // no expiration
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.keys()).containsExactly("key2");
+ assertThat(repository.size()).isEqualTo(1);
+ });
+ }
+
+ @Test
+ void testNoTtlWithZero() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testNoTtlWithNegative() {
+ repository.put("key1", "value1", Duration.ofMillis(-1));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testStoresDifferentValueTypes() {
+ 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 testMaximumSize() throws Exception {
+ repository.stop();
+
+ CaffeineKeyValueRepository bounded = new CaffeineKeyValueRepository();
+ bounded.setMaximumSize(2);
+ bounded.start();
+
+ try {
+ bounded.put("key1", "value1", null);
+ bounded.put("key2", "value2", null);
+ bounded.put("key3", "value3", null);
+
+ // Caffeine eviction is asynchronous, so wait for it
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() ->
assertThat(bounded.size()).isLessThanOrEqualTo(2));
+ } finally {
+ bounded.stop();
+ }
+ }
+
+ @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();
+ }
+}
diff --git a/components/camel-ehcache/pom.xml b/components/camel-ehcache/pom.xml
index cabf04fbe1d4..8a2d7b0f503f 100644
--- a/components/camel-ehcache/pom.xml
+++ b/components/camel-ehcache/pom.xml
@@ -64,6 +64,17 @@
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-spring-junit6</artifactId>
diff --git
a/components/camel-ehcache/src/generated/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryConfigurer.java
b/components/camel-ehcache/src/generated/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryConfigurer.java
new file mode 100644
index 000000000000..f78fcc64ff50
--- /dev/null
+++
b/components/camel-ehcache/src/generated/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryConfigurer.java
@@ -0,0 +1,57 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.component.ehcache.processor;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class EhcacheKeyValueRepositoryConfigurer extends
org.apache.camel.support.component.PropertyConfigurerSupport implements
GeneratedPropertyConfigurer, PropertyConfigurerGetter {
+
+ @Override
+ public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
+ org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository
target =
(org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachemanager":
+ case "cacheManager": target.setCacheManager(property(camelContext,
org.ehcache.CacheManager.class, value)); return true;
+ case "cachename":
+ case "cacheName": target.setCacheName(property(camelContext,
java.lang.String.class, value)); return true;
+ default: return false;
+ }
+ }
+
+ @Override
+ public Class<?> getOptionType(String name, boolean ignoreCase) {
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachemanager":
+ case "cacheManager": return org.ehcache.CacheManager.class;
+ case "cachename":
+ case "cacheName": return java.lang.String.class;
+ default: return null;
+ }
+ }
+
+ @Override
+ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+ org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository
target =
(org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachemanager":
+ case "cacheManager": return target.getCacheManager();
+ case "cachename":
+ case "cacheName": return target.getCacheName();
+ default: return null;
+ }
+ }
+}
+
diff --git
a/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
index 5a1bfa5e46c5..61d3832ae548 100644
---
a/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
+++
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
@@ -1,5 +1,5 @@
# Generated by camel build tools - do NOT edit this file!
-bean=EhcacheAggregationRepository EhcacheIdempotentRepository
+bean=EhcacheAggregationRepository EhcacheIdempotentRepository
EhcacheKeyValueRepository
groupId=org.apache.camel
artifactId=camel-ehcache
version=4.23.0-SNAPSHOT
diff --git
a/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean/EhcacheKeyValueRepository.json
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean/EhcacheKeyValueRepository.json
new file mode 100644
index 000000000000..1d137c8f34d5
--- /dev/null
+++
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/bean/EhcacheKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "EhcacheKeyValueRepository",
+ "javaType":
"org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Ehcache Key Value Repository",
+ "description": "An Ehcache-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-ehcache",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "cacheName": { "index": 0, "kind": "property",
"displayName": "Cache Name", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": "EhcacheKeyValueRepository", "description": "Name of cache" },
"cacheManager": { "index": 1, "kind": "property", "displayName": "Cache
Manager", "required": false, "type": "object", "javaType":
"org.ehcache.CacheManager", "deprecated": false, "autowired [...]
+ }
+}
+
diff --git
a/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository
new file mode 100644
index 000000000000..2dd8f4b0c1be
--- /dev/null
+++
b/components/camel-ehcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepository
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.ehcache.processor.EhcacheKeyValueRepositoryConfigurer
diff --git
a/components/camel-ehcache/src/main/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepository.java
b/components/camel-ehcache/src/main/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepository.java
new file mode 100644
index 000000000000..35849594320b
--- /dev/null
+++
b/components/camel-ehcache/src/main/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepository.java
@@ -0,0 +1,225 @@
+/*
+ * 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.ehcache.processor;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+import org.apache.camel.api.management.ManagedAttribute;
+import org.apache.camel.api.management.ManagedOperation;
+import org.apache.camel.api.management.ManagedResource;
+import org.apache.camel.component.ehcache.EhcacheManager;
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.KeyValueRepository;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.ObjectHelper;
+import org.ehcache.Cache;
+import org.ehcache.CacheManager;
+
+/**
+ * A {@link KeyValueRepository} implementation backed by an Ehcache {@link
Cache}.
+ * <p/>
+ * Ehcache does not support per-entry TTL natively (TTL is set at the cache
configuration level). This implementation
+ * wraps each value in a {@link TtlValue} that records the entry's expiration
timestamp. Expired entries are removed
+ * lazily on access and during key scans, similar to how {@code
MemoryKeyValueRepository} handles TTL.
+ * <p/>
+ * This single implementation can serve as idempotent repository, aggregation
repository, and state store via the
+ * adapters in {@code camel-support} ({@code KeyValueIdempotentRepository} and
{@code KeyValueAggregationRepository}).
+ *
+ * @since 4.23
+ */
+@Metadata(label = "bean",
+ description = "An Ehcache-based KeyValueRepository with per-entry
TTL support.",
+ annotations = {
"interfaceName=org.apache.camel.spi.KeyValueRepository" })
+@Configurer(metadataOnly = true)
+@ManagedResource(description = "Ehcache based key-value repository")
+public class EhcacheKeyValueRepository extends ServiceSupport implements
KeyValueRepository {
+
+ /**
+ * Internal value wrapper that holds the actual value and an expiration
timestamp.
+ */
+ static final class TtlValue implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final Object value;
+ private final long expiresAt;
+
+ TtlValue(Object value, long expiresAt) {
+ this.value = value;
+ this.expiresAt = expiresAt;
+ }
+
+ Object value() {
+ return value;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ private Cache<String, TtlValue> cache;
+ private EhcacheManager ehcacheManager;
+
+ @Metadata(description = "Name of cache", defaultValue =
"EhcacheKeyValueRepository")
+ private String cacheName = EhcacheKeyValueRepository.class.getSimpleName();
+
+ @Metadata(description = "The Ehcache CacheManager to use")
+ private CacheManager cacheManager;
+
+ /**
+ * Creates a new Ehcache-backed key-value repository. A {@link
CacheManager} must be provided before starting.
+ */
+ public EhcacheKeyValueRepository() {
+ }
+
+ /**
+ * Creates a new Ehcache-backed key-value repository using the given cache
manager.
+ */
+ public EhcacheKeyValueRepository(CacheManager cacheManager) {
+ this(cacheManager, EhcacheKeyValueRepository.class.getSimpleName());
+ }
+
+ /**
+ * Creates a new Ehcache-backed key-value repository using the given cache
manager and cache name.
+ */
+ public EhcacheKeyValueRepository(CacheManager cacheManager, String
cacheName) {
+ this.cacheManager = cacheManager;
+ this.cacheName = cacheName;
+ }
+
+ public void setCacheName(String cacheName) {
+ this.cacheName = cacheName;
+ }
+
+ @ManagedAttribute(description = "The cache name")
+ public String getCacheName() {
+ return cacheName;
+ }
+
+ public CacheManager getCacheManager() {
+ return cacheManager;
+ }
+
+ public void setCacheManager(CacheManager cacheManager) {
+ this.cacheManager = cacheManager;
+ }
+
+ @Override
+ @ManagedOperation(description = "Get value by key")
+ public Object get(String key) {
+ TtlValue entry = cache.get(key);
+ if (entry == null) {
+ return null;
+ }
+ if (entry.isExpired()) {
+ cache.remove(key);
+ return null;
+ }
+ return entry.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Put a key-value pair with optional TTL")
+ public Object put(String key, Object value, Duration ttl) {
+ long expiresAt = hasPositiveTtl(ttl) ? System.currentTimeMillis() +
ttl.toMillis() : Long.MAX_VALUE;
+ TtlValue previous = cache.get(key);
+ cache.put(key, new TtlValue(value, expiresAt));
+ if (previous == null || previous.isExpired()) {
+ return null;
+ }
+ return previous.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Delete a key")
+ public Object delete(String key) {
+ TtlValue entry = cache.get(key);
+ cache.remove(key);
+ if (entry == null || entry.isExpired()) {
+ return null;
+ }
+ return entry.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Check if key exists")
+ public boolean contains(String key) {
+ TtlValue entry = cache.get(key);
+ if (entry == null) {
+ return false;
+ }
+ if (entry.isExpired()) {
+ cache.remove(key);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public Set<String> keys() {
+ Set<String> keys = new HashSet<>();
+ Iterator<Cache.Entry<String, TtlValue>> it = cache.iterator();
+ while (it.hasNext()) {
+ Cache.Entry<String, TtlValue> entry = it.next();
+ if (!entry.getValue().isExpired()) {
+ keys.add(entry.getKey());
+ } else {
+ cache.remove(entry.getKey());
+ }
+ }
+ return Set.copyOf(keys);
+ }
+
+ @Override
+ @ManagedOperation(description = "Clear all entries")
+ public void clear() {
+ cache.clear();
+ }
+
+ @Override
+ @ManagedAttribute(description = "The number of entries in the repository")
+ public int size() {
+ return keys().size();
+ }
+
+ private static boolean hasPositiveTtl(Duration ttl) {
+ return ttl != null && !ttl.isZero() && !ttl.isNegative();
+ }
+
+ @Override
+ protected void doStart() throws Exception {
+ ObjectHelper.notNull(cacheManager, "cacheManager");
+ ehcacheManager = new EhcacheManager(cacheManager, false, null);
+ ehcacheManager.start();
+ cache = ehcacheManager.getCache(cacheName, String.class,
TtlValue.class);
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ if (ehcacheManager != null) {
+ ehcacheManager.stop();
+ }
+ }
+}
diff --git
a/components/camel-ehcache/src/test/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryTest.java
b/components/camel-ehcache/src/test/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryTest.java
new file mode 100644
index 000000000000..ce97b3abb13d
--- /dev/null
+++
b/components/camel-ehcache/src/test/java/org/apache/camel/component/ehcache/processor/EhcacheKeyValueRepositoryTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.ehcache.processor;
+
+import java.time.Duration;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.ehcache.CacheManager;
+import org.ehcache.config.builders.CacheConfigurationBuilder;
+import org.ehcache.config.builders.CacheManagerBuilder;
+import org.ehcache.config.builders.ResourcePoolsBuilder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+class EhcacheKeyValueRepositoryTest {
+
+ private static final String CACHE_NAME = "test-kvrepo";
+
+ private CacheManager cacheManager;
+ private EhcacheKeyValueRepository repository;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ cacheManager = CacheManagerBuilder.newCacheManagerBuilder()
+ .withCache(CACHE_NAME,
+ CacheConfigurationBuilder.newCacheConfigurationBuilder(
+ String.class,
+ EhcacheKeyValueRepository.TtlValue.class,
+ ResourcePoolsBuilder.heap(100)))
+ .build(true);
+
+ repository = new EhcacheKeyValueRepository(cacheManager, CACHE_NAME);
+ repository.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ repository.stop();
+ if (cacheManager != null) {
+ cacheManager.close();
+ }
+ }
+
+ @Test
+ void testPutAndGet() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testGetMissingKeyReturnsNull() {
+ assertThat(repository.get("nonexistent")).isNull();
+ }
+
+ @Test
+ void testPutOverwritesExistingValue() {
+ repository.put("key1", "value1", null);
+ repository.put("key1", "value2", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value2");
+ }
+
+ @Test
+ void testPutReturnsPreviousValue() {
+ repository.put("key1", "value1", null);
+ Object previous = repository.put("key1", "value2", null);
+
+ assertThat(previous).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutReturnsNullForNewKey() {
+ Object previous = repository.put("key1", "value1", null);
+
+ assertThat(previous).isNull();
+ }
+
+ @Test
+ void testDelete() {
+ repository.put("key1", "value1", null);
+
+ Object deleted = repository.delete("key1");
+
+ assertThat(deleted).isEqualTo("value1");
+ assertThat(repository.get("key1")).isNull();
+ }
+
+ @Test
+ void testDeleteMissingKeyReturnsNull() {
+ assertThat(repository.delete("nonexistent")).isNull();
+ }
+
+ @Test
+ void testContains() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.contains("key1")).isTrue();
+ assertThat(repository.contains("nonexistent")).isFalse();
+ }
+
+ @Test
+ void testKeys() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+ repository.put("key3", "value3", null);
+
+ Set<String> keys = repository.keys();
+
+ assertThat(keys).containsExactlyInAnyOrder("key1", "key2", "key3");
+ }
+
+ @Test
+ void testKeysEmpty() {
+ assertThat(repository.keys()).isEmpty();
+ }
+
+ @Test
+ void testClear() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+
+ repository.clear();
+
+ assertThat(repository.size()).isZero();
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.get("key2")).isNull();
+ }
+
+ @Test
+ void testSize() {
+ assertThat(repository.size()).isZero();
+
+ repository.put("key1", "value1", null);
+ assertThat(repository.size()).isEqualTo(1);
+
+ repository.put("key2", "value2", null);
+ assertThat(repository.size()).isEqualTo(2);
+
+ repository.delete("key1");
+ assertThat(repository.size()).isEqualTo(1);
+ }
+
+ @Test
+ void testTtlExpiration() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.contains("key1")).isFalse();
+ });
+ }
+
+ @Test
+ void testTtlExpirationOnKeys() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+ repository.put("key2", "value2", null); // no expiration
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.keys()).containsExactly("key2");
+ assertThat(repository.size()).isEqualTo(1);
+ });
+ }
+
+ @Test
+ void testNoTtlWithZero() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testNoTtlWithNegative() {
+ repository.put("key1", "value1", Duration.ofMillis(-1));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testStoresDifferentValueTypes() {
+ 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 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();
+ }
+}
diff --git a/components/camel-hazelcast/pom.xml
b/components/camel-hazelcast/pom.xml
index 341bcb071cfd..4c50ffb9ea34 100644
--- a/components/camel-hazelcast/pom.xml
+++ b/components/camel-hazelcast/pom.xml
@@ -84,6 +84,12 @@
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility-version}</version>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<profiles>
diff --git
a/components/camel-hazelcast/src/generated/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryConfigurer.java
b/components/camel-hazelcast/src/generated/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryConfigurer.java
new file mode 100644
index 000000000000..d2b8190ec5d7
--- /dev/null
+++
b/components/camel-hazelcast/src/generated/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryConfigurer.java
@@ -0,0 +1,57 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.component.hazelcast;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import org.apache.camel.component.hazelcast.HazelcastKeyValueRepository;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class HazelcastKeyValueRepositoryConfigurer extends
org.apache.camel.support.component.PropertyConfigurerSupport implements
GeneratedPropertyConfigurer, PropertyConfigurerGetter {
+
+ @Override
+ public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
+ org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
target = (org.apache.camel.component.hazelcast.HazelcastKeyValueRepository) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "hazelcastinstance":
+ case "hazelcastInstance":
target.setHazelcastInstance(property(camelContext,
com.hazelcast.core.HazelcastInstance.class, value)); return true;
+ case "mapname":
+ case "mapName": target.setMapName(property(camelContext,
java.lang.String.class, value)); return true;
+ default: return false;
+ }
+ }
+
+ @Override
+ public Class<?> getOptionType(String name, boolean ignoreCase) {
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "hazelcastinstance":
+ case "hazelcastInstance": return
com.hazelcast.core.HazelcastInstance.class;
+ case "mapname":
+ case "mapName": return java.lang.String.class;
+ default: return null;
+ }
+ }
+
+ @Override
+ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+ org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
target = (org.apache.camel.component.hazelcast.HazelcastKeyValueRepository) obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "hazelcastinstance":
+ case "hazelcastInstance": return target.getHazelcastInstance();
+ case "mapname":
+ case "mapName": return target.getMapName();
+ default: return null;
+ }
+ }
+}
+
diff --git
a/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
index 8c6cfb5558bd..2a367586072a 100644
---
a/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
+++
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
@@ -1,5 +1,5 @@
# Generated by camel build tools - do NOT edit this file!
-bean=HazelcastAggregationRepository HazelcastIdempotentRepository
+bean=HazelcastAggregationRepository HazelcastIdempotentRepository
HazelcastKeyValueRepository
groupId=org.apache.camel
artifactId=camel-hazelcast
version=4.23.0-SNAPSHOT
diff --git
a/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean/HazelcastKeyValueRepository.json
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean/HazelcastKeyValueRepository.json
new file mode 100644
index 000000000000..22d363a3f59c
--- /dev/null
+++
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/bean/HazelcastKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "HazelcastKeyValueRepository",
+ "javaType":
"org.apache.camel.component.hazelcast.HazelcastKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "Hazelcast Key Value Repository",
+ "description": "A Hazelcast-based KeyValueRepository with native per-entry
TTL support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-hazelcast",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "mapName": { "index": 0, "kind": "property",
"displayName": "Map Name", "required": false, "type": "string", "javaType":
"java.lang.String", "deprecated": false, "autowired": false, "secret": false,
"defaultValue": "HazelcastKeyValueRepository", "description": "Name of the
Hazelcast map to use" }, "hazelcastInstance": { "index": 1, "kind": "property",
"displayName": "Hazelcast Instance", "required": false, "type": "object",
"javaType": "com.hazelcast.core.HazelcastIns [...]
+ }
+}
+
diff --git
a/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
new file mode 100644
index 000000000000..aead6a891a1b
--- /dev/null
+++
b/components/camel-hazelcast/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.hazelcast.HazelcastKeyValueRepositoryConfigurer
diff --git
a/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepository.java
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepository.java
new file mode 100644
index 000000000000..fd8df970d4da
--- /dev/null
+++
b/components/camel-hazelcast/src/main/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepository.java
@@ -0,0 +1,226 @@
+/*
+ * 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.hazelcast;
+
+import java.time.Duration;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import com.hazelcast.config.Config;
+import com.hazelcast.config.XmlConfigBuilder;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import com.hazelcast.map.IMap;
+import org.apache.camel.api.management.ManagedAttribute;
+import org.apache.camel.api.management.ManagedOperation;
+import org.apache.camel.api.management.ManagedResource;
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.KeyValueRepository;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.support.KeyValueRepositoryHelper;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.ObjectHelper;
+
+/**
+ * A {@link KeyValueRepository} implementation backed by a Hazelcast {@link
IMap} with native per-entry TTL support.
+ * <p/>
+ * Hazelcast natively supports per-entry TTL via {@link IMap#put(Object,
Object, long, TimeUnit)} and
+ * {@link IMap#putIfAbsent(Object, Object, long, TimeUnit)}, making it a
natural fit for the {@code KeyValueRepository}
+ * contract without requiring wrapper objects or lazy eviction.
+ * <p/>
+ * Values are serialized through {@link KeyValueRepositoryHelper} (plain Java
serialization) and stored as raw
+ * {@code byte[]} in the Hazelcast map. This ensures a consistent
serialization format across all persistent
+ * {@link KeyValueRepository} implementations and avoids coupling to
Hazelcast's built-in serialization.
+ * <p/>
+ * This single implementation can serve as idempotent repository, aggregation
repository, and state store via the
+ * adapters in {@code camel-support} ({@code KeyValueIdempotentRepository} and
{@code KeyValueAggregationRepository}).
+ * <p/>
+ * If no external {@link HazelcastInstance} is provided, a local instance is
created on start and shut down on stop.
+ *
+ * @since 4.23
+ */
+@Metadata(label = "bean",
+ description = "A Hazelcast-based KeyValueRepository with native
per-entry TTL support.",
+ annotations = {
"interfaceName=org.apache.camel.spi.KeyValueRepository" })
+@Configurer(metadataOnly = true)
+@ManagedResource(description = "Hazelcast based key-value repository")
+public class HazelcastKeyValueRepository extends ServiceSupport implements
KeyValueRepository {
+
+ private boolean useLocalHzInstance;
+ private IMap<String, byte[]> map;
+
+ @Metadata(description = "Name of the Hazelcast map to use", defaultValue =
"HazelcastKeyValueRepository")
+ private String mapName = HazelcastKeyValueRepository.class.getSimpleName();
+
+ @Metadata(description = "To use an existing Hazelcast instance instead of
creating a local one")
+ private HazelcastInstance hazelcastInstance;
+
+ /**
+ * Creates a new Hazelcast-backed key-value repository with default
settings (local instance, default map name).
+ */
+ public HazelcastKeyValueRepository() {
+ }
+
+ /**
+ * Creates a new Hazelcast-backed key-value repository using the given
Hazelcast instance.
+ */
+ public HazelcastKeyValueRepository(HazelcastInstance hazelcastInstance) {
+ this(hazelcastInstance,
HazelcastKeyValueRepository.class.getSimpleName());
+ }
+
+ /**
+ * Creates a new Hazelcast-backed key-value repository using the given
Hazelcast instance and map name.
+ */
+ public HazelcastKeyValueRepository(HazelcastInstance hazelcastInstance,
String mapName) {
+ this.hazelcastInstance = hazelcastInstance;
+ this.mapName = mapName;
+ }
+
+ public void setMapName(String mapName) {
+ this.mapName = mapName;
+ }
+
+ @ManagedAttribute(description = "The Hazelcast map name")
+ public String getMapName() {
+ return mapName;
+ }
+
+ public HazelcastInstance getHazelcastInstance() {
+ return hazelcastInstance;
+ }
+
+ public void setHazelcastInstance(HazelcastInstance hazelcastInstance) {
+ this.hazelcastInstance = hazelcastInstance;
+ }
+
+ @Override
+ @ManagedOperation(description = "Get value by key")
+ public Object get(String key) {
+ byte[] bytes = map.get(key);
+ return bytes != null ? KeyValueRepositoryHelper.deserialize(bytes) :
null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Put a key-value pair with optional TTL")
+ public Object put(String key, Object value, Duration ttl) {
+ byte[] serialized = KeyValueRepositoryHelper.serialize(value);
+ byte[] previous;
+ if (hasPositiveTtl(ttl)) {
+ previous = map.put(key, serialized, ttl.toMillis(),
TimeUnit.MILLISECONDS);
+ } else {
+ previous = map.put(key, serialized);
+ }
+ return previous != null ?
KeyValueRepositoryHelper.deserialize(previous) : null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Delete a key")
+ public Object delete(String key) {
+ byte[] bytes = map.remove(key);
+ return bytes != null ? KeyValueRepositoryHelper.deserialize(bytes) :
null;
+ }
+
+ @Override
+ @ManagedOperation(description = "Check if key exists")
+ public boolean contains(String key) {
+ return map.containsKey(key);
+ }
+
+ @Override
+ public Set<String> keys() {
+ return Collections.unmodifiableSet(map.keySet());
+ }
+
+ @Override
+ @ManagedOperation(description = "Clear all entries")
+ public void clear() {
+ map.clear();
+ }
+
+ @Override
+ public Object putIfAbsent(String key, Object value, Duration ttl) {
+ byte[] serialized = KeyValueRepositoryHelper.serialize(value);
+ byte[] existing;
+ if (hasPositiveTtl(ttl)) {
+ existing = map.putIfAbsent(key, serialized, ttl.toMillis(),
TimeUnit.MILLISECONDS);
+ } else {
+ existing = map.putIfAbsent(key, serialized);
+ }
+ return existing != null ?
KeyValueRepositoryHelper.deserialize(existing) : null;
+ }
+
+ @Override
+ public boolean replace(String key, Object expectedOldValue, Object
newValue, Duration ttl) {
+ byte[] expectedBytes =
KeyValueRepositoryHelper.serialize(expectedOldValue);
+ byte[] newBytes = KeyValueRepositoryHelper.serialize(newValue);
+ if (!hasPositiveTtl(ttl)) {
+ return map.replace(key, expectedBytes, newBytes);
+ }
+ // IMap.replace(K, V, V) does not support TTL, so use distributed lock
for atomicity
+ map.lock(key);
+ try {
+ byte[] current = map.get(key);
+ if (!Arrays.equals(current, expectedBytes)) {
+ return false;
+ }
+ map.put(key, newBytes, ttl.toMillis(), TimeUnit.MILLISECONDS);
+ return true;
+ } finally {
+ map.unlock(key);
+ }
+ }
+
+ @Override
+ public boolean delete(String key, Object expectedValue) {
+ byte[] expectedBytes =
KeyValueRepositoryHelper.serialize(expectedValue);
+ return map.remove(key, expectedBytes);
+ }
+
+ @Override
+ @ManagedAttribute(description = "The number of entries in the repository")
+ public int size() {
+ return map.size();
+ }
+
+ private static boolean hasPositiveTtl(Duration ttl) {
+ return ttl != null && !ttl.isZero() && !ttl.isNegative();
+ }
+
+ @Override
+ protected void doStart() throws Exception {
+ if (hazelcastInstance == null) {
+ Config cfg = new XmlConfigBuilder().build();
+ cfg.setProperty("hazelcast.version.check.enabled", "false");
+ HazelcastSerializationFilterHelper.applyDefault(cfg);
+ hazelcastInstance = Hazelcast.newHazelcastInstance(cfg);
+ useLocalHzInstance = true;
+ } else {
+ ObjectHelper.notNull(hazelcastInstance, "hazelcastInstance");
+ }
+ map = hazelcastInstance.getMap(mapName);
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ if (useLocalHzInstance && hazelcastInstance != null) {
+ hazelcastInstance.getLifecycleService().shutdown();
+ hazelcastInstance = null;
+ }
+ }
+}
diff --git
a/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryTest.java
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryTest.java
new file mode 100644
index 000000000000..e69f606b3f59
--- /dev/null
+++
b/components/camel-hazelcast/src/test/java/org/apache/camel/component/hazelcast/HazelcastKeyValueRepositoryTest.java
@@ -0,0 +1,300 @@
+/*
+ * 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.hazelcast;
+
+import java.time.Duration;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import com.hazelcast.config.Config;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import com.hazelcast.map.IMap;
+import org.apache.camel.test.infra.hazelcast.services.HazelcastService;
+import org.apache.camel.test.infra.hazelcast.services.HazelcastServiceFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class HazelcastKeyValueRepositoryTest {
+
+ @RegisterExtension
+ static HazelcastService hazelcastService =
HazelcastServiceFactory.createSingletonService();
+
+ private static final String MAP_NAME = "kvrepo-test";
+
+ private HazelcastInstance hazelcastInstance;
+ private IMap<String, Object> map;
+ private HazelcastKeyValueRepository repository;
+
+ @BeforeAll
+ void setupHazelcast() throws Exception {
+ Config config = hazelcastService.createConfiguration(null, 0, null,
MAP_NAME);
+ hazelcastInstance = Hazelcast.newHazelcastInstance(config);
+ repository = new HazelcastKeyValueRepository(hazelcastInstance,
MAP_NAME);
+ repository.start();
+ map = hazelcastInstance.getMap(MAP_NAME);
+ }
+
+ @AfterAll
+ void teardownHazelcast() throws Exception {
+ if (repository != null) {
+ repository.stop();
+ }
+ if (hazelcastInstance != null) {
+ hazelcastInstance.getLifecycleService().terminate();
+ }
+ }
+
+ @BeforeEach
+ void clearMap() {
+ map.clear();
+ }
+
+ @Test
+ void testPutAndGet() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testGetMissingKeyReturnsNull() {
+ assertThat(repository.get("nonexistent")).isNull();
+ }
+
+ @Test
+ void testPutOverwritesExistingValue() {
+ repository.put("key1", "value1", null);
+ repository.put("key1", "value2", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value2");
+ }
+
+ @Test
+ void testPutReturnsPreviousValue() {
+ repository.put("key1", "value1", null);
+ Object previous = repository.put("key1", "value2", null);
+
+ assertThat(previous).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutReturnsNullForNewKey() {
+ Object previous = repository.put("key1", "value1", null);
+
+ assertThat(previous).isNull();
+ }
+
+ @Test
+ void testDelete() {
+ repository.put("key1", "value1", null);
+
+ Object deleted = repository.delete("key1");
+
+ assertThat(deleted).isEqualTo("value1");
+ assertThat(repository.get("key1")).isNull();
+ }
+
+ @Test
+ void testDeleteMissingKeyReturnsNull() {
+ assertThat(repository.delete("nonexistent")).isNull();
+ }
+
+ @Test
+ void testContains() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.contains("key1")).isTrue();
+ assertThat(repository.contains("nonexistent")).isFalse();
+ }
+
+ @Test
+ void testKeys() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+ repository.put("key3", "value3", null);
+
+ Set<String> keys = repository.keys();
+
+ assertThat(keys).containsExactlyInAnyOrder("key1", "key2", "key3");
+ }
+
+ @Test
+ void testKeysEmpty() {
+ assertThat(repository.keys()).isEmpty();
+ }
+
+ @Test
+ void testClear() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+
+ repository.clear();
+
+ assertThat(repository.size()).isZero();
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.get("key2")).isNull();
+ }
+
+ @Test
+ void testSize() {
+ assertThat(repository.size()).isZero();
+
+ repository.put("key1", "value1", null);
+ assertThat(repository.size()).isEqualTo(1);
+
+ repository.put("key2", "value2", null);
+ assertThat(repository.size()).isEqualTo(2);
+
+ repository.delete("key1");
+ assertThat(repository.size()).isEqualTo(1);
+ }
+
+ @Test
+ void testPutIfAbsentNewKey() {
+ Object result = repository.putIfAbsent("key1", "value1", null);
+
+ assertThat(result).isNull();
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutIfAbsentExistingKey() {
+ repository.put("key1", "value1", null);
+
+ Object result = repository.putIfAbsent("key1", "value2", null);
+
+ assertThat(result).isEqualTo("value1");
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testTtlExpiration() {
+ repository.put("key1", "value1", Duration.ofMillis(2000));
+
+ assertThat(repository.contains("key1")).isTrue();
+
+ // Hazelcast TTL uses its own eviction mechanism
+ await().atMost(10, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.contains("key1")).isFalse();
+ });
+ }
+
+ @Test
+ void testNoTtlWithZero() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testNoTtlWithNegative() {
+ repository.put("key1", "value1", Duration.ofMillis(-1));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testStoresDifferentValueTypes() {
+ 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(2000));
+
+ assertThat(replaced).isTrue();
+ assertThat(repository.get("key1")).isEqualTo("value2");
+
+ await().atMost(10, 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();
+ }
+}
diff --git a/components/camel-jcache/pom.xml b/components/camel-jcache/pom.xml
index 352c816338af..dcfd131a9d89 100644
--- a/components/camel-jcache/pom.xml
+++ b/components/camel-jcache/pom.xml
@@ -53,6 +53,17 @@
<artifactId>camel-test-junit6</artifactId>
<scope>test</scope>
</dependency>
+ <dependency>
+ <groupId>org.assertj</groupId>
+ <artifactId>assertj-core</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.awaitility</groupId>
+ <artifactId>awaitility</artifactId>
+ <version>${awaitility-version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.camel</groupId>
<artifactId>camel-test-spring-junit6</artifactId>
diff --git
a/components/camel-jcache/src/generated/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryConfigurer.java
b/components/camel-jcache/src/generated/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryConfigurer.java
new file mode 100644
index 000000000000..f62511caec00
--- /dev/null
+++
b/components/camel-jcache/src/generated/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryConfigurer.java
@@ -0,0 +1,54 @@
+/* Generated by camel build tools - do NOT edit this file! */
+package org.apache.camel.component.jcache.processor;
+
+import javax.annotation.processing.Generated;
+import java.util.Map;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.spi.ExtendedPropertyConfigurerGetter;
+import org.apache.camel.spi.PropertyConfigurerGetter;
+import org.apache.camel.spi.ConfigurerStrategy;
+import org.apache.camel.spi.GeneratedPropertyConfigurer;
+import org.apache.camel.util.CaseInsensitiveMap;
+import org.apache.camel.component.jcache.processor.JCacheKeyValueRepository;
+
+/**
+ * Generated by camel build tools - do NOT edit this file!
+ */
+@Generated("org.apache.camel.maven.packaging.GenerateConfigurerMojo")
+@SuppressWarnings("unchecked")
+public class JCacheKeyValueRepositoryConfigurer extends
org.apache.camel.support.component.PropertyConfigurerSupport implements
GeneratedPropertyConfigurer, PropertyConfigurerGetter {
+
+ @Override
+ public boolean configure(CamelContext camelContext, Object obj, String
name, Object value, boolean ignoreCase) {
+ org.apache.camel.component.jcache.processor.JCacheKeyValueRepository
target = (org.apache.camel.component.jcache.processor.JCacheKeyValueRepository)
obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachename":
+ case "cacheName": target.setCacheName(property(camelContext,
java.lang.String.class, value)); return true;
+ case "configuration": target.setConfiguration(property(camelContext,
org.apache.camel.component.jcache.JCacheConfiguration.class, value)); return
true;
+ default: return false;
+ }
+ }
+
+ @Override
+ public Class<?> getOptionType(String name, boolean ignoreCase) {
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachename":
+ case "cacheName": return java.lang.String.class;
+ case "configuration": return
org.apache.camel.component.jcache.JCacheConfiguration.class;
+ default: return null;
+ }
+ }
+
+ @Override
+ public Object getOptionValue(Object obj, String name, boolean ignoreCase) {
+ org.apache.camel.component.jcache.processor.JCacheKeyValueRepository
target = (org.apache.camel.component.jcache.processor.JCacheKeyValueRepository)
obj;
+ switch (ignoreCase ? name.toLowerCase() : name) {
+ case "cachename":
+ case "cacheName": return target.getCacheName();
+ case "configuration": return target.getConfiguration();
+ default: return null;
+ }
+ }
+}
+
diff --git
a/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
index 0f17c694e720..a065140f1885 100644
---
a/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
+++
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean.properties
@@ -1,5 +1,5 @@
# Generated by camel build tools - do NOT edit this file!
-bean=JCacheAggregationRepository JCacheIdempotentRepository
+bean=JCacheAggregationRepository JCacheIdempotentRepository
JCacheKeyValueRepository
groupId=org.apache.camel
artifactId=camel-jcache
version=4.23.0-SNAPSHOT
diff --git
a/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean/JCacheKeyValueRepository.json
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean/JCacheKeyValueRepository.json
new file mode 100644
index 000000000000..3150cfb87a19
--- /dev/null
+++
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/bean/JCacheKeyValueRepository.json
@@ -0,0 +1,16 @@
+{
+ "bean": {
+ "kind": "bean",
+ "name": "JCacheKeyValueRepository",
+ "javaType":
"org.apache.camel.component.jcache.processor.JCacheKeyValueRepository",
+ "interfaceType": "org.apache.camel.spi.KeyValueRepository",
+ "title": "JCache Key Value Repository",
+ "description": "A JCache-based KeyValueRepository with per-entry TTL
support.",
+ "deprecated": false,
+ "groupId": "org.apache.camel",
+ "artifactId": "camel-jcache",
+ "version": "4.23.0-SNAPSHOT",
+ "properties": { "configuration": { "index": 0, "kind": "property",
"displayName": "Configuration", "required": false, "type": "object",
"javaType": "org.apache.camel.component.jcache.JCacheConfiguration",
"deprecated": false, "autowired": false, "secret": false, "description":
"Configuration for JCache" } }
+ }
+}
+
diff --git
a/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.jcache.processor.JCacheKeyValueRepository
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.jcache.processor.JCacheKeyValueRepository
new file mode 100644
index 000000000000..98e42d3639b4
--- /dev/null
+++
b/components/camel-jcache/src/generated/resources/META-INF/services/org/apache/camel/configurer/org.apache.camel.component.jcache.processor.JCacheKeyValueRepository
@@ -0,0 +1,2 @@
+# Generated by camel build tools - do NOT edit this file!
+class=org.apache.camel.component.jcache.processor.JCacheKeyValueRepositoryConfigurer
diff --git
a/components/camel-jcache/src/main/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepository.java
b/components/camel-jcache/src/main/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepository.java
new file mode 100644
index 000000000000..85bf205f329a
--- /dev/null
+++
b/components/camel-jcache/src/main/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepository.java
@@ -0,0 +1,236 @@
+/*
+ * 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.jcache.processor;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.Duration;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+
+import javax.cache.Cache;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.CamelContextAware;
+import org.apache.camel.api.management.ManagedAttribute;
+import org.apache.camel.api.management.ManagedOperation;
+import org.apache.camel.api.management.ManagedResource;
+import org.apache.camel.component.jcache.JCacheConfiguration;
+import org.apache.camel.component.jcache.JCacheHelper;
+import org.apache.camel.component.jcache.JCacheManager;
+import org.apache.camel.spi.Configurer;
+import org.apache.camel.spi.KeyValueRepository;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.util.ObjectHelper;
+
+/**
+ * A {@link KeyValueRepository} implementation backed by a JCache (JSR-107)
{@link Cache}.
+ * <p/>
+ * JCache does not support per-entry TTL natively (expiry is configured at the
cache level via
+ * {@link javax.cache.expiry.ExpiryPolicy}). This implementation wraps each
value in a {@link TtlValue} that records the
+ * entry's expiration timestamp. Expired entries are removed lazily on access
and during key scans.
+ * <p/>
+ * This single implementation can serve as idempotent repository, aggregation
repository, and state store via the
+ * adapters in {@code camel-support} ({@code KeyValueIdempotentRepository} and
{@code KeyValueAggregationRepository}).
+ *
+ * @since 4.23
+ */
+@Metadata(label = "bean",
+ description = "A JCache-based KeyValueRepository with per-entry TTL
support.",
+ annotations = {
"interfaceName=org.apache.camel.spi.KeyValueRepository" })
+@Configurer(metadataOnly = true)
+@ManagedResource(description = "JCache based key-value repository")
+public class JCacheKeyValueRepository extends ServiceSupport implements
CamelContextAware, KeyValueRepository {
+
+ /**
+ * Internal value wrapper that holds the actual value and an expiration
timestamp.
+ */
+ static final class TtlValue implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final Object value;
+ private final long expiresAt;
+
+ TtlValue(Object value, long expiresAt) {
+ this.value = value;
+ this.expiresAt = expiresAt;
+ }
+
+ Object value() {
+ return value;
+ }
+
+ boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+ }
+
+ private CamelContext camelContext;
+ private Cache<String, TtlValue> cache;
+ private JCacheManager<String, TtlValue> cacheManager;
+
+ @Metadata(description = "Configuration for JCache")
+ private JCacheConfiguration configuration;
+
+ /**
+ * Creates a new JCache-backed key-value repository with a default
configuration.
+ */
+ public JCacheKeyValueRepository() {
+ this.configuration = new JCacheConfiguration();
+ }
+
+ @Override
+ public CamelContext getCamelContext() {
+ return camelContext;
+ }
+
+ @Override
+ public void setCamelContext(CamelContext camelContext) {
+ this.camelContext = camelContext;
+ }
+
+ public JCacheConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ public void setConfiguration(JCacheConfiguration configuration) {
+ this.configuration = configuration;
+ }
+
+ public Cache<String, TtlValue> getCache() {
+ return cache;
+ }
+
+ public void setCache(Cache<String, TtlValue> cache) {
+ this.cache = cache;
+ }
+
+ public void setCacheName(String cacheName) {
+ configuration.setCacheName(cacheName);
+ }
+
+ @ManagedAttribute(description = "The cache name")
+ public String getCacheName() {
+ return configuration.getCacheName();
+ }
+
+ @Override
+ @ManagedOperation(description = "Get value by key")
+ public Object get(String key) {
+ TtlValue entry = cache.get(key);
+ if (entry == null) {
+ return null;
+ }
+ if (entry.isExpired()) {
+ cache.remove(key);
+ return null;
+ }
+ return entry.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Put a key-value pair with optional TTL")
+ public Object put(String key, Object value, Duration ttl) {
+ long expiresAt = hasPositiveTtl(ttl) ? System.currentTimeMillis() +
ttl.toMillis() : Long.MAX_VALUE;
+ TtlValue previous = cache.get(key);
+ cache.put(key, new TtlValue(value, expiresAt));
+ if (previous == null || previous.isExpired()) {
+ return null;
+ }
+ return previous.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Delete a key")
+ public Object delete(String key) {
+ TtlValue entry = cache.get(key);
+ cache.remove(key);
+ if (entry == null || entry.isExpired()) {
+ return null;
+ }
+ return entry.value();
+ }
+
+ @Override
+ @ManagedOperation(description = "Check if key exists")
+ public boolean contains(String key) {
+ TtlValue entry = cache.get(key);
+ if (entry == null) {
+ return false;
+ }
+ if (entry.isExpired()) {
+ cache.remove(key);
+ return false;
+ }
+ return true;
+ }
+
+ @Override
+ public Set<String> keys() {
+ Set<String> keys = new HashSet<>();
+ Iterator<Cache.Entry<String, TtlValue>> it = cache.iterator();
+ while (it.hasNext()) {
+ Cache.Entry<String, TtlValue> entry = it.next();
+ if (!entry.getValue().isExpired()) {
+ keys.add(entry.getKey());
+ } else {
+ cache.remove(entry.getKey());
+ }
+ }
+ return Set.copyOf(keys);
+ }
+
+ @Override
+ @ManagedOperation(description = "Clear all entries")
+ public void clear() {
+ cache.clear();
+ }
+
+ @Override
+ @ManagedAttribute(description = "The number of entries in the repository")
+ public int size() {
+ return keys().size();
+ }
+
+ private static boolean hasPositiveTtl(Duration ttl) {
+ return ttl != null && !ttl.isZero() && !ttl.isNegative();
+ }
+
+ @Override
+ protected void doStart() throws Exception {
+ ObjectHelper.notNull(camelContext, "camelContext");
+ ObjectHelper.notNull(configuration, "configuration");
+
+ if (cache != null) {
+ cacheManager = new JCacheManager<>(cache);
+ } else {
+ cacheManager = JCacheHelper.createManager(getCamelContext(),
configuration);
+ cache = cacheManager.getCache();
+ }
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ if (cacheManager != null) {
+ cacheManager.close();
+ }
+ }
+}
diff --git
a/components/camel-jcache/src/test/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryTest.java
b/components/camel-jcache/src/test/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryTest.java
new file mode 100644
index 000000000000..f7077c08eb69
--- /dev/null
+++
b/components/camel-jcache/src/test/java/org/apache/camel/component/jcache/processor/JCacheKeyValueRepositoryTest.java
@@ -0,0 +1,269 @@
+/*
+ * 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.jcache.processor;
+
+import java.time.Duration;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import javax.cache.Cache;
+
+import org.apache.camel.component.jcache.JCacheConfiguration;
+import org.apache.camel.component.jcache.JCacheHelper;
+import org.apache.camel.component.jcache.JCacheManager;
+import org.apache.camel.component.jcache.support.HazelcastTest;
+import org.apache.camel.test.junit6.CamelTestSupport;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
+
+@HazelcastTest
+class JCacheKeyValueRepositoryTest extends CamelTestSupport {
+
+ private JCacheManager<String, JCacheKeyValueRepository.TtlValue>
cacheManager;
+ private Cache<String, JCacheKeyValueRepository.TtlValue> cache;
+ private JCacheKeyValueRepository repository;
+
+ @Override
+ public void doPostSetup() throws Exception {
+ cacheManager = JCacheHelper.createManager(context, new
JCacheConfiguration("kvrepo-test"));
+ cache = cacheManager.getCache();
+
+ repository = new JCacheKeyValueRepository();
+ repository.setCamelContext(context);
+ repository.setCache(cache);
+ repository.start();
+ }
+
+ @Override
+ public void doPostTearDown() throws Exception {
+ if (repository != null) {
+ repository.stop();
+ }
+ if (cacheManager != null) {
+ cacheManager.close();
+ }
+ }
+
+ @Test
+ void testPutAndGet() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ }
+
+ @Test
+ void testGetMissingKeyReturnsNull() {
+ assertThat(repository.get("nonexistent")).isNull();
+ }
+
+ @Test
+ void testPutOverwritesExistingValue() {
+ repository.put("key1", "value1", null);
+ repository.put("key1", "value2", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value2");
+ }
+
+ @Test
+ void testPutReturnsPreviousValue() {
+ repository.put("key1", "value1", null);
+ Object previous = repository.put("key1", "value2", null);
+
+ assertThat(previous).isEqualTo("value1");
+ }
+
+ @Test
+ void testPutReturnsNullForNewKey() {
+ Object previous = repository.put("key1", "value1", null);
+
+ assertThat(previous).isNull();
+ }
+
+ @Test
+ void testDelete() {
+ repository.put("key1", "value1", null);
+
+ Object deleted = repository.delete("key1");
+
+ assertThat(deleted).isEqualTo("value1");
+ assertThat(repository.get("key1")).isNull();
+ }
+
+ @Test
+ void testDeleteMissingKeyReturnsNull() {
+ assertThat(repository.delete("nonexistent")).isNull();
+ }
+
+ @Test
+ void testContains() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.contains("key1")).isTrue();
+ assertThat(repository.contains("nonexistent")).isFalse();
+ }
+
+ @Test
+ void testKeys() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+ repository.put("key3", "value3", null);
+
+ Set<String> keys = repository.keys();
+
+ assertThat(keys).containsExactlyInAnyOrder("key1", "key2", "key3");
+ }
+
+ @Test
+ void testKeysEmpty() {
+ assertThat(repository.keys()).isEmpty();
+ }
+
+ @Test
+ void testClear() {
+ repository.put("key1", "value1", null);
+ repository.put("key2", "value2", null);
+
+ repository.clear();
+
+ assertThat(repository.size()).isZero();
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.get("key2")).isNull();
+ }
+
+ @Test
+ void testSize() {
+ assertThat(repository.size()).isZero();
+
+ repository.put("key1", "value1", null);
+ assertThat(repository.size()).isEqualTo(1);
+
+ repository.put("key2", "value2", null);
+ assertThat(repository.size()).isEqualTo(2);
+
+ repository.delete("key1");
+ assertThat(repository.size()).isEqualTo(1);
+ }
+
+ @Test
+ void testTtlExpiration() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.get("key1")).isNull();
+ assertThat(repository.contains("key1")).isFalse();
+ });
+ }
+
+ @Test
+ void testTtlExpirationOnKeys() {
+ repository.put("key1", "value1", Duration.ofMillis(100));
+ repository.put("key2", "value2", null); // no expiration
+
+ await().atMost(2, TimeUnit.SECONDS)
+ .untilAsserted(() -> {
+ assertThat(repository.keys()).containsExactly("key2");
+ assertThat(repository.size()).isEqualTo(1);
+ });
+ }
+
+ @Test
+ void testNoTtlWithZero() {
+ repository.put("key1", "value1", null);
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testNoTtlWithNegative() {
+ repository.put("key1", "value1", Duration.ofMillis(-1));
+
+ assertThat(repository.get("key1")).isEqualTo("value1");
+ assertThat(repository.contains("key1")).isTrue();
+ }
+
+ @Test
+ void testStoresDifferentValueTypes() {
+ 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 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();
+ }
+}