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 2d6568c14c87 CAMEL-24463: Improve KeyValueRepository infrastructure
(#26086)
2d6568c14c87 is described below
commit 2d6568c14c871e47d5496507c9c75ae995aee541
Author: Guillaume Nodet <[email protected]>
AuthorDate: Fri Sep 4 00:16:59 2026 +0200
CAMEL-24463: Improve KeyValueRepository infrastructure (#26086)
* CAMEL-24463: Improve KeyValueRepository infrastructure
- Add KeyValueRepositoryHelper unit tests (22 tests covering
serialize/deserialize roundtrips, ByteBuffer, offset/length, error cases, null
handling)
- Add CacheProcessor test for cacheNull=true path
- Fix RedisKeyValueRepository.put() to use atomic getAndSet() instead of
non-atomic get+set
- Extract shared KeyValueTtlValue from duplicate TtlValue inner classes in
Ehcache and JCache backends
- Fix import ordering in Ehcache/JCache source and test files
* CAMEL-24463: Add KVR backend documentation, JDBC CAS overrides, and
Javadoc improvements
- Add comprehensive KeyValueRepository backends documentation page
(keyValueRepository.adoc)
covering all 11 backends with configuration examples, atomicity
guarantees,
TTL behavior, and adapter usage for Idempotent Consumer / Aggregator
patterns
- Add atomic replace() and delete(key,expected) overrides to
JdbcKeyValueRepository
using SQL WHERE clause on ITEM_VALUE for server-side CAS
- Add Javadoc about non-atomic put() in Cassandra, Ehcache, and JCache
backends
- Add Javadoc about TTL/topic growth and non-atomic CAS in
KafkaKeyValueRepository
* CAMEL-24463: Add distributed deployment guide and fix atomicity table
- Add 'Distributed Deployment' section to keyValueRepository.adoc with a
table
showing which backends are safe for clustered Camel deployments (shared
state,
distributed CAS, practical recommendations per pattern)
- Fix atomicity table: JDBC and Cassandra now correctly marked as atomic
CAS (✓)
reflecting the CAS overrides added in this PR and existing LWT support
* Regen
---
.../catalog/beans/JdbcKeyValueRepository.json | 2 +-
.../org/apache/camel/catalog/docs.properties | 1 +
.../camel/catalog/docs/keyValueRepository.adoc | 1161 ++++++++++++++++++++
.../cassandra/CassandraKeyValueRepository.java | 7 +
.../processor/EhcacheKeyValueRepository.java | 58 +-
.../processor/EhcacheKeyValueRepositoryTest.java | 3 +-
.../jcache/processor/JCacheKeyValueRepository.java | 62 +-
.../processor/JCacheKeyValueRepositoryTest.java | 5 +-
.../keyvalue/kafka/KafkaKeyValueRepository.java | 26 +
.../component/redis/RedisKeyValueRepository.java | 6 +-
.../jdbc/JdbcKeyValueRepositoryConfigurer.java | 12 +
.../apache/camel/bean/JdbcKeyValueRepository.json | 2 +-
.../keyvalue/jdbc/JdbcKeyValueRepository.java | 73 ++
.../src/main/docs/modules/eips/nav.adoc | 1 +
.../modules/eips/pages/keyValueRepository.adoc | 1161 ++++++++++++++++++++
.../apache/camel/processor/CacheProcessorTest.java | 24 +
.../org/apache/camel/support/KeyValueTtlValue.java | 52 +
.../support/KeyValueRepositoryHelperTest.java | 319 ++++++
18 files changed, 2889 insertions(+), 86 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
index a38b669e8cca..07bc8667d665 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/beans/JdbcKeyValueRepository.json
@@ -10,7 +10,7 @@
"groupId": "org.apache.camel",
"artifactId": "camel-sql",
"version": "4.23.0-SNAPSHOT",
- "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
+ "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
}
}
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
index 554fa8908a3d..835c5e512f64 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs.properties
@@ -335,6 +335,7 @@ kamelet-component
kamelet-eip
kamelet-main
kamelet-main-support
+keyValueRepository
keycloak-component
keycloak-consumer
keycloak-producer
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
new file mode 100644
index 000000000000..d12e4d332e72
--- /dev/null
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/keyValueRepository.adoc
@@ -0,0 +1,1161 @@
+= KeyValueRepository Backends
+:doctitle: KeyValueRepository Backends
+:shortname: keyValueRepository
+:description: Unified storage SPI backing the Cache EIP, Idempotent Consumer,
and Aggregator patterns. Documents all available backends and how to configure
them as beans.
+:since: 4.23
+:supportlevel: Stable
+:tabs-sync-option:
+
+`KeyValueRepository` is the unified storage SPI used by the
+xref:cache-eip.adoc[Cache EIP],
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer], and
+xref:aggregate-eip.adoc[Aggregator] patterns.
+It provides a simple `get / put / delete / clear` contract — plus an optional
atomic
+`putIfAbsent` — that Camel maps onto many storage technologies through
pluggable backend
+implementations.
+
+Choosing the right backend is typically a matter of:
+
+* *Scope* — in-process memory, distributed in-memory, durable on-disk, or
remote service.
+* *Atomicity* — whether the backend natively provides an atomic
Compare-And-Swap (CAS)
+ operation (required for exactly-once idempotency under concurrent load).
+* *TTL* — whether expiration is handled natively by the store or requires
client-side logic.
+* *Operational footprint* — whether extra infrastructure (Redis, Kafka,
Cassandra …) is
+ acceptable.
+
+== Available Backends
+
+[cols="2,3,3,1,1",options="header"]
+|===
+| Backend class | Module | Key features | Atomic CAS | Native TTL
+
+| `MemoryKeyValueRepository`
+| `camel-support`
+| JVM-local `ConcurrentHashMap`; zero deps; auto-created when no KVR bean is
registered
+| ✓
+| ✗
+
+| `CaffeineKeyValueRepository`
+| `camel-caffeine`
+| High-throughput in-process cache; configurable size; near-cache for
distributed setups
+| ✓
+| ✗ (client-side via Cache EIP `ttl`)
+
+| `EhcacheKeyValueRepository`
+| `camel-ehcache`
+| Ehcache 3; heap + off-heap + disk tiers; native TTL per cache config
+| ✓
+| ✓ (Ehcache XML config)
+
+| `JCacheKeyValueRepository`
+| `camel-jcache`
+| JSR-107 provider-agnostic; works with Ehcache, Hazelcast, Infinispan, …
+| ✓ (provider-dependent)
+| ✓ (via `javax.cache.expiry`)
+
+| `HazelcastKeyValueRepository`
+| `camel-hazelcast`
+| Distributed, partitioned `IMap`; cluster-aware; near-cache optional
+| ✓
+| ✓ (map TTL config)
+
+| `RedisKeyValueRepository`
+| `camel-redis`
+| Redis `SET`/`GET`; atomic via `SET NX`; native TTL via `EXPIRE`
+| ✓
+| ✓ (native `EXPIRE`)
+
+| `InfinispanRemoteKeyValueRepository`
+| `camel-infinispan`
+| Hot Rod client to remote Infinispan/Data Grid cluster; distributed,
transactional
+| ✓
+| ✓ (per-entry lifespan)
+
+| `JdbcKeyValueRepository`
+| `camel-sql`
+| Any JDBC DataSource; portable; optional auto-DDL; ACID via DataSource
transactions
+| ✗ (non-atomic)
+| ✗ (client-side)
+
+| `JpaKeyValueRepository`
+| `camel-jpa`
+| JPA entity-backed; works with any JPA 2 provider (Hibernate, EclipseLink…)
+| ✗ (non-atomic)
+| ✗ (client-side)
+
+| `CassandraKeyValueRepository`
+| `camel-cassandraql`
+| Cassandra wide-column store; configurable consistency levels; naturally
distributed
+| ✗ (non-atomic)
+| ✓ (Cassandra TTL)
+
+| `KafkaKeyValueRepository`
+| `camel-kafka`
+| Kafka topic as a compacted key-value log; state rebuilt on startup;
eventually consistent
+| ✗ (non-atomic)
+| ✗ (topic retention)
+
+|===
+
+NOTE: _Atomic CAS_ means `putIfAbsent` is implemented without an external lock
— required
+for safe concurrent use as an Idempotent Repository. Non-atomic backends can
still be used
+with the Cache EIP and Aggregator when access is serialized or best-effort
semantics are
+acceptable.
+
+== Backend Configuration
+
+Each backend is registered as a named bean in the Camel registry and then
referenced from
+a route or EIP by name (or auto-discovered when only one bean is present).
+
+=== MemoryKeyValueRepository
+
+Provided by `camel-support` (always on the classpath). No extra dependencies.
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.MemoryKeyValueRepository;
+
+@BindToRegistry("myMemoryKvr")
+public KeyValueRepository myMemoryKvr() {
+ return new MemoryKeyValueRepository();
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: myMemoryKvr
+ type: org.apache.camel.support.MemoryKeyValueRepository
+----
+====
+
+Because `MemoryKeyValueRepository` has no required properties, registering it
by type alone
+is enough. When *no* `KeyValueRepository` bean is registered at all, the Cache
EIP
+auto-creates a private `MemoryKeyValueRepository` per block — no explicit bean
needed.
+
+=== CaffeineKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-caffeine</artifactId>
+</dependency>
+----
+
+Optional property:
+
+* `maximumSize` — maximum number of entries before eviction (default:
unbounded).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.caffeine.cache.CaffeineKeyValueRepository;
+
+@BindToRegistry("caffeineKvr")
+public KeyValueRepository caffeineKvr() {
+ CaffeineKeyValueRepository kvr = new CaffeineKeyValueRepository();
+ kvr.setMaximumSize(10_000);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: caffeineKvr
+ type:
org.apache.camel.component.caffeine.cache.CaffeineKeyValueRepository
+ properties:
+ maximumSize: 10000
+----
+====
+
+TTL is not natively managed by Caffeine in this adapter. Use the Cache EIP
`ttl` option
+for time-based expiration, which wraps values with an expiry timestamp stored
in the entry.
+
+=== EhcacheKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-ehcache</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `cacheManager` — a configured `org.ehcache.CacheManager` bean.
+
+Optional property:
+
+* `cacheName` — logical cache name within the `CacheManager` (default:
`"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.ehcache.EhcacheKeyValueRepository;
+import org.ehcache.CacheManager;
+import org.ehcache.config.builders.CacheConfigurationBuilder;
+import org.ehcache.config.builders.CacheManagerBuilder;
+import org.ehcache.config.builders.ResourcePoolsBuilder;
+import org.ehcache.expiry.ExpiryPolicy;
+
+import java.time.Duration;
+
+@BindToRegistry("ehcacheManager")
+public CacheManager ehcacheManager() {
+ return CacheManagerBuilder.newCacheManagerBuilder()
+ .withCache("products",
+ CacheConfigurationBuilder
+ .newCacheConfigurationBuilder(String.class, Object.class,
+ ResourcePoolsBuilder.heap(10_000))
+ .withExpiry(ExpiryPolicy.BASE_EXPIRY // or a custom
ExpiryPolicy
+ .timeToLiveExpiration(Duration.ofMinutes(30)))
+ )
+ .build(true); // true = init on build
+}
+
+@BindToRegistry("ehcacheKvr")
+public KeyValueRepository ehcacheKvr(CacheManager ehcacheManager) {
+ EhcacheKeyValueRepository kvr = new EhcacheKeyValueRepository();
+ kvr.setCacheManager(ehcacheManager);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: ehcacheKvr
+ type: org.apache.camel.component.ehcache.EhcacheKeyValueRepository
+ properties:
+ cacheManager: "#bean:ehcacheManager"
+ cacheName: products
+----
+====
+
+Native TTL is configured on the `CacheManager` / `CacheConfiguration`, not on
the
+`KeyValueRepository` bean itself.
+
+=== JCacheKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jcache</artifactId>
+</dependency>
+<!-- Plus a JSR-107 provider, e.g.: -->
+<dependency>
+ <groupId>org.ehcache</groupId>
+ <artifactId>ehcache</artifactId>
+</dependency>
+----
+
+Optional properties:
+
+* `configuration` — a `javax.cache.configuration.Configuration` (or
`MutableConfiguration`) bean.
+* `cacheName` — name of the JCache cache (default: `"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.jcache.JCacheKeyValueRepository;
+import javax.cache.configuration.MutableConfiguration;
+import javax.cache.expiry.CreatedExpiryPolicy;
+import javax.cache.expiry.Duration;
+
+@BindToRegistry("jcacheConfig")
+public MutableConfiguration<String, Object> jcacheConfig() {
+ return new MutableConfiguration<String, Object>()
+ .setTypes(String.class, Object.class)
+ .setExpiryPolicyFactory(
+ CreatedExpiryPolicy.factoryOf(new
Duration(java.util.concurrent.TimeUnit.MINUTES, 30)))
+ .setStatisticsEnabled(true);
+}
+
+@BindToRegistry("jcacheKvr")
+public KeyValueRepository jcacheKvr(MutableConfiguration<String, Object>
jcacheConfig) {
+ JCacheKeyValueRepository kvr = new JCacheKeyValueRepository();
+ kvr.setConfiguration(jcacheConfig);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jcacheKvr
+ type: org.apache.camel.component.jcache.JCacheKeyValueRepository
+ properties:
+ cacheName: products
+ configuration: "#bean:jcacheConfig"
+----
+====
+
+Whether `putIfAbsent` is truly atomic depends on the JSR-107 provider. Most
production
+providers (Ehcache 3, Hazelcast, Infinispan) implement it atomically.
+
+=== HazelcastKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-hazelcast</artifactId>
+</dependency>
+----
+
+Optional properties:
+
+* `hazelcastInstance` — an existing `HazelcastInstance` bean (default:
auto-created).
+* `mapName` — name of the distributed `IMap` (default: `"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.hazelcast.HazelcastKeyValueRepository;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import com.hazelcast.config.Config;
+import com.hazelcast.config.MapConfig;
+
+@BindToRegistry("hazelcastInstance")
+public HazelcastInstance hazelcastInstance() {
+ Config config = new Config();
+ config.addMapConfig(new MapConfig("camel-products")
+ .setTimeToLiveSeconds(1800)); // 30-minute TTL
+ return Hazelcast.newHazelcastInstance(config);
+}
+
+@BindToRegistry("hazelcastKvr")
+public KeyValueRepository hazelcastKvr(HazelcastInstance hazelcastInstance) {
+ HazelcastKeyValueRepository kvr = new HazelcastKeyValueRepository();
+ kvr.setHazelcastInstance(hazelcastInstance);
+ kvr.setMapName("camel-products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: hazelcastKvr
+ type: org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
+ properties:
+ hazelcastInstance: "#bean:hazelcastInstance"
+ mapName: camel-products
+----
+====
+
+TTL is configured on the Hazelcast `MapConfig`, not on the
`KeyValueRepository`.
+`putIfAbsent` maps to `IMap.putIfAbsent`, which is atomic across the cluster.
+
+=== RedisKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-redis</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `endpoint` — a `camel-redis` endpoint URI string (e.g.
`"redis://localhost:6379"`).
+
+Optional properties:
+
+* `keyPrefix` — a string prefix applied to all keys (useful to namespace
multiple stores in one Redis DB).
+* `redisson` — a pre-configured `RedissonClient` bean (alternative to
`endpoint`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.redis.RedisKeyValueRepository;
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr() {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setEndpoint("redis://localhost:6379");
+ kvr.setKeyPrefix("myapp:");
+ return kvr;
+}
+----
+
+With a custom `RedissonClient`::
++
+[source,java]
+----
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+
+@BindToRegistry("redissonClient")
+public RedissonClient redissonClient() {
+ Config config = new Config();
+ config.useSingleServer()
+ .setAddress("redis://localhost:6379")
+ .setPassword("secret");
+ return Redisson.create(config);
+}
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr(RedissonClient redissonClient) {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setRedisson(redissonClient);
+ kvr.setKeyPrefix("myapp:");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: redisKvr
+ type: org.apache.camel.component.redis.RedisKeyValueRepository
+ properties:
+ endpoint: "redis://localhost:6379"
+ keyPrefix: "myapp:"
+----
+====
+
+Redis provides native TTL via `EXPIRE` / `SET … EX`. `putIfAbsent` maps to
`SET NX`, which
+is atomic. The Cache EIP `ttl` option is respected and propagated to the
underlying
+`EXPIRE` call.
+
+=== InfinispanRemoteKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-infinispan</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `cacheName` — the remote Infinispan cache name.
+
+Optional properties:
+
+* `configuration` — a
`org.infinispan.client.hotrod.configuration.Configuration` bean.
+* `cacheContainer` — a pre-built `RemoteCacheManager` bean (takes priority
over `configuration`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import
org.apache.camel.component.infinispan.remote.InfinispanRemoteKeyValueRepository;
+import org.infinispan.client.hotrod.RemoteCacheManager;
+import org.infinispan.client.hotrod.configuration.ConfigurationBuilder;
+
+@BindToRegistry("infinispanManager")
+public RemoteCacheManager infinispanManager() {
+ return new RemoteCacheManager(
+ new ConfigurationBuilder()
+ .addServer().host("infinispan-host").port(11222)
+ .security().authentication()
+ .username("camel").password("secret")
+ .build());
+}
+
+@BindToRegistry("infinispanKvr")
+public KeyValueRepository infinispanKvr(RemoteCacheManager infinispanManager) {
+ InfinispanRemoteKeyValueRepository kvr = new
InfinispanRemoteKeyValueRepository();
+ kvr.setCacheContainer(infinispanManager);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: infinispanKvr
+ type:
org.apache.camel.component.infinispan.remote.InfinispanRemoteKeyValueRepository
+ properties:
+ cacheContainer: "#bean:infinispanManager"
+ cacheName: products
+----
+====
+
+Infinispan supports per-entry lifespan and max-idle TTL natively.
`putIfAbsent` is
+implemented via the `PUTIFABSENT` Hot Rod operation, which is atomic.
+
+=== JdbcKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-sql</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `dataSource` — a `javax.sql.DataSource` bean.
+
+Optional properties:
+
+* `tableName` — the table used for storage (default: `"camel_kvr"`).
+* `createTableIfNotExists` — auto-create the table at startup if absent
(default: `true`).
+
+The auto-created table schema:
+
+[source,sql]
+----
+CREATE TABLE camel_kvr (
+ kvr_key VARCHAR(255) NOT NULL PRIMARY KEY,
+ kvr_value TEXT
+);
+----
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.sql.JdbcKeyValueRepository;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import javax.sql.DataSource;
+
+@BindToRegistry("myDataSource")
+public DataSource myDataSource() {
+ DriverManagerDataSource ds = new DriverManagerDataSource();
+ ds.setDriverClassName("org.postgresql.Driver");
+ ds.setUrl("jdbc:postgresql://localhost:5432/mydb");
+ ds.setUsername("camel");
+ ds.setPassword("secret");
+ return ds;
+}
+
+@BindToRegistry("jdbcKvr")
+public KeyValueRepository jdbcKvr(DataSource myDataSource) {
+ JdbcKeyValueRepository kvr = new JdbcKeyValueRepository();
+ kvr.setDataSource(myDataSource);
+ kvr.setTableName("product_cache");
+ kvr.setCreateTableIfNotExists(true);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jdbcKvr
+ type: org.apache.camel.component.sql.JdbcKeyValueRepository
+ properties:
+ dataSource: "#bean:myDataSource"
+ tableName: product_cache
+ createTableIfNotExists: true
+----
+====
+
+NOTE: `JdbcKeyValueRepository` does not implement atomic `putIfAbsent` —
concurrent inserts
+may produce duplicate-key exceptions that are caught and treated as a "key
already exists"
+condition. This is safe for idempotency at low concurrency, but for
high-throughput
+exactly-once semantics, prefer a backend with native CAS.
+
+=== JpaKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jpa</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `entityManagerFactory` — a `javax.persistence.EntityManagerFactory` bean.
+
+Optional properties:
+
+* `joinTransaction` — whether to participate in an existing JTA transaction
(default: `true`).
+* `sharedEntityManager` — use a shared/thread-bound `EntityManager` (Spring
integration, default: `false`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.jpa.JpaKeyValueRepository;
+import javax.persistence.EntityManagerFactory;
+import javax.persistence.Persistence;
+
+@BindToRegistry("emf")
+public EntityManagerFactory emf() {
+ return Persistence.createEntityManagerFactory("myPersistenceUnit");
+}
+
+@BindToRegistry("jpaKvr")
+public KeyValueRepository jpaKvr(EntityManagerFactory emf) {
+ JpaKeyValueRepository kvr = new JpaKeyValueRepository();
+ kvr.setEntityManagerFactory(emf);
+ kvr.setJoinTransaction(false); // standalone, no JTA
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jpaKvr
+ type: org.apache.camel.component.jpa.JpaKeyValueRepository
+ properties:
+ entityManagerFactory: "#bean:emf"
+ joinTransaction: false
+----
+====
+
+NOTE: Like `JdbcKeyValueRepository`, the JPA backend does not provide atomic
+`putIfAbsent`. Idempotency under concurrent load relies on unique-constraint
violations
+being caught at the database level.
+
+=== CassandraKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-cassandraql</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `session` — a `com.datastax.oss.driver.api.core.CqlSession` bean.
+
+Optional properties:
+
+* `table` — Cassandra table name (default: `"camel_kvr"`).
+* `readConsistencyLevel` — `ConsistencyLevel` for reads (default: `LOCAL_ONE`).
+* `writeConsistencyLevel` — `ConsistencyLevel` for writes (default:
`LOCAL_ONE`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.cassandra.CassandraKeyValueRepository;
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.ConsistencyLevel;
+import java.net.InetSocketAddress;
+
+@BindToRegistry("cassandraSession")
+public CqlSession cassandraSession() {
+ return CqlSession.builder()
+ .addContactPoint(new InetSocketAddress("cassandra-host", 9042))
+ .withLocalDatacenter("datacenter1")
+ .withKeyspace("myapp")
+ .build();
+}
+
+@BindToRegistry("cassandraKvr")
+public KeyValueRepository cassandraKvr(CqlSession cassandraSession) {
+ CassandraKeyValueRepository kvr = new CassandraKeyValueRepository();
+ kvr.setSession(cassandraSession);
+ kvr.setTable("product_cache");
+ kvr.setReadConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
+ kvr.setWriteConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: cassandraKvr
+ type: org.apache.camel.component.cassandra.CassandraKeyValueRepository
+ properties:
+ session: "#bean:cassandraSession"
+ table: product_cache
+ readConsistencyLevel: LOCAL_QUORUM
+ writeConsistencyLevel: LOCAL_QUORUM
+----
+====
+
+Cassandra supports native TTL via the `USING TTL` clause on `INSERT`/`UPDATE`.
+The Cache EIP `ttl` option is forwarded to the Cassandra write operation as a
native TTL,
+so entries expire automatically at the storage layer.
+
+`putIfAbsent` uses Cassandra's lightweight transaction (`INSERT … IF NOT
EXISTS`).
+Although this is CAS at the Cassandra level, it is _not_ classified as fully
atomic in the
+table above because it is subject to Paxos latency and only provides
linearizability within
+a single partition.
+
+=== KafkaKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-kafka</artifactId>
+</dependency>
+----
+
+Required properties:
+
+* `topic` — the compacted Kafka topic used as the key-value store.
+* `bootstrapServers` — comma-separated list of Kafka broker addresses.
+
+Optional properties:
+
+* `maxCacheSize` — maximum number of entries to hold in the local in-memory
replica (default: unlimited).
+* `pollDurationMs` — poll timeout in milliseconds when rebuilding state at
startup (default: `100`).
+* `startupOnly` — if `true`, the consumer only reads the topic once at startup
and does not keep polling (default: `false`).
+* `groupId` — Kafka consumer group ID (default: auto-generated).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.kafka.KafkaKeyValueRepository;
+
+@BindToRegistry("kafkaKvr")
+public KeyValueRepository kafkaKvr() {
+ KafkaKeyValueRepository kvr = new KafkaKeyValueRepository();
+ kvr.setTopic("camel-kvr-products");
+ kvr.setBootstrapServers("kafka1:9092,kafka2:9092");
+ kvr.setMaxCacheSize(50_000);
+ kvr.setGroupId("camel-kvr-consumer");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: kafkaKvr
+ type: org.apache.camel.component.kafka.KafkaKeyValueRepository
+ properties:
+ topic: camel-kvr-products
+ bootstrapServers: "kafka1:9092,kafka2:9092"
+ maxCacheSize: 50000
+ groupId: camel-kvr-consumer
+----
+====
+
+IMPORTANT: The Kafka backend reconstructs its state by replaying the topic log
on startup.
+This means startup time scales with topic size. Use a
+https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy[log-compacted
topic]
+(`cleanup.policy=compact`) to keep the log bounded.
+
+TTL is not natively supported — entry retention is governed by topic-level
+`retention.ms` / `retention.bytes`, which affects all entries equally, not
individual ones.
+`putIfAbsent` is *not* atomic: reads and writes go through the local in-memory
replica with
+no distributed locking.
+
+== Atomicity Guarantees
+
+The `putIfAbsent(key, value)` method is the critical operation for the
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer] — it must return `true`
only for the
+_first_ caller and `false` for all subsequent callers with the same key.
+
+[cols="2,1,3",options="header"]
+|===
+| Backend | Atomic CAS | Mechanism
+
+| `MemoryKeyValueRepository`
+| ✓
+| `ConcurrentHashMap.putIfAbsent` — JVM-local, lock-free
+
+| `CaffeineKeyValueRepository`
+| ✓
+| Caffeine's internal lock-free `putIfAbsent`
+
+| `EhcacheKeyValueRepository`
+| ✓
+| `Cache.putIfAbsent` (JSR-107) — segment-locked
+
+| `JCacheKeyValueRepository`
+| ✓ (provider)
+| Delegates to the JSR-107 provider's `putIfAbsent`
+
+| `HazelcastKeyValueRepository`
+| ✓
+| `IMap.putIfAbsent` — distributed CAS with CP subsystem optional
+
+| `RedisKeyValueRepository`
+| ✓
+| `SET key value NX` — single-command atomic in Redis
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| Hot Rod `PUTIFABSENT` operation — cluster-wide atomic
+
+| `JdbcKeyValueRepository`
+| ✓
+| `UPDATE … WHERE value = ?` — single-statement CAS within a transaction
+
+| `JpaKeyValueRepository`
+| ✗
+| Optimistic INSERT + constraint catch; `replace` / `delete(key, expected)`
fall back to non-atomic defaults
+
+| `CassandraKeyValueRepository`
+| ✓
+| Lightweight transactions: `INSERT … IF NOT EXISTS`, `UPDATE … IF value = ?`,
`DELETE … IF value = ?`
+
+| `KafkaKeyValueRepository`
+| ✗
+| In-memory replica check — no distributed coordination
+
+|===
+
+For non-atomic backends, use the Cache EIP (where idempotency is not required)
or accept
+at-least-once semantics in idempotency-sensitive flows. Alternatively, front a
non-atomic
+backend with a distributed lock (e.g. via `camel-hazelcast` `ILock`).
+
+== TTL Behavior
+
+[cols="2,1,3",options="header"]
+|===
+| Backend | Native TTL | Notes
+
+| `MemoryKeyValueRepository`
+| ✗
+| Cache EIP wraps values with an expiry timestamp; expired entries are evicted
on read
+
+| `CaffeineKeyValueRepository`
+| ✗
+| Same as Memory — client-side expiry envelope
+
+| `EhcacheKeyValueRepository`
+| ✓
+| `ExpiryPolicy` configured on the `CacheManager`; Cache EIP TTL forwarded
when possible
+
+| `JCacheKeyValueRepository`
+| ✓ (provider)
+| `javax.cache.expiry.ExpiryPolicy` on the `MutableConfiguration`
+
+| `HazelcastKeyValueRepository`
+| ✓
+| `MapConfig.timeToLiveSeconds`; per-entry TTL also available via `IMap.put(k,
v, ttl, unit)`
+
+| `RedisKeyValueRepository`
+| ✓
+| `EXPIRE` set atomically alongside each `PUT`; Cache EIP `ttl` forwarded as
`EX` seconds
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| Per-entry `lifespan` (max-idle also available); Cache EIP `ttl` forwarded
+
+| `JdbcKeyValueRepository`
+| ✗
+| Client-side expiry envelope; a periodic cleanup query is needed to purge
expired rows
+
+| `JpaKeyValueRepository`
+| ✗
+| Client-side expiry envelope; schedule a `DELETE … WHERE expires < now()`
query
+
+| `CassandraKeyValueRepository`
+| ✓
+| `USING TTL <seconds>` clause; Cache EIP `ttl` forwarded; expired rows
deleted by Cassandra
+
+| `KafkaKeyValueRepository`
+| ✗
+| Topic-level retention only; all entries expire at the same time based on
`retention.ms`
+
+|===
+
+NOTE: For backends *without native TTL*, the Cache EIP stores an expiry
timestamp inside
+the serialized value and skips (evicts on read) entries whose timestamp has
passed. This
+means the storage layer may accumulate stale entries that are never explicitly
deleted —
+size them accordingly or run periodic cleanup jobs.
+
+== Distributed Deployment
+
+When running multiple Camel instances (e.g. behind a load balancer or in a
Kubernetes cluster), the choice of
+backend determines whether state is shared and whether CAS operations are safe
across nodes.
+
+[cols="2,1,1,1,3",options="header"]
+|===
+| Backend | Shared state | Distributed CAS | Distributed-safe | Notes
+
+| `MemoryKeyValueRepository`
+| ✗
+| ✗
+| ❌
+| JVM-local only. Each node has its own isolated store.
+
+| `CaffeineKeyValueRepository`
+| ✗
+| ✗
+| ❌
+| JVM-local only. Suitable as a near-cache in front of a distributed backend.
+
+| `EhcacheKeyValueRepository`
+| ✗ (unless clustered)
+| ✗
+| ❌
+| JVM-local by default. Ehcache clustering requires additional configuration
outside of this integration.
+
+| `JCacheKeyValueRepository`
+| Depends on provider
+| Depends on provider
+| ⚠️
+| With a distributed provider (Hazelcast, Infinispan), state is shared. CAS
atomicity depends on the provider.
+
+| `HazelcastKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Distributed `IMap` with cluster-wide `putIfAbsent`, `replace`, and
`remove(key, value)`.
+
+| `RedisKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared Redis server. `setIfAbsent` and `compareAndSet` are atomic. `replace`
with TTL has a brief non-atomic window (see Javadoc).
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Remote cache with version-based optimistic CAS via `replaceWithVersion` /
`removeWithVersion`.
+
+| `JdbcKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared database. `putIfAbsent` uses unique-key constraint; `replace` and
`delete(key, expected)` use SQL `WHERE value = ?`.
+
+| `JpaKeyValueRepository`
+| ✓
+| ⚠️
+| ⚠️
+| Shared database. `putIfAbsent` catches constraint violations, but `replace`
and `delete(key, expected)` fall back to non-atomic defaults
(read-compare-write in Java).
+
+| `CassandraKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared cluster. Full lightweight transaction (LWT) support: `IF NOT EXISTS`,
`IF value = ?`.
+
+| `KafkaKeyValueRepository`
+| ✓ (eventually)
+| ✗
+| ⚠️
+| State is shared via the topic, but CAS is local-only. Suitable for
best-effort deduplication, not strict exactly-once across nodes.
+
+|===
+
+*Recommendations:*
+
+* **Idempotent Consumer in a cluster** — use Hazelcast, Redis, Infinispan,
JDBC, or Cassandra for guaranteed exactly-once deduplication.
+* **Aggregator in a cluster** — use any shared-state backend (recovery
requires the same backend to be visible from the recovering node).
+* **Cache EIP in a cluster** — any backend works. JVM-local backends give each
node its own cache (fine for read-through caching); distributed backends share
cached values.
+* **Single-node deployment** — any backend works, including Memory and
Caffeine.
+
+== Using Adapters: Idempotent Consumer and Aggregator
+
+A `KeyValueRepository` can be adapted to the specialized repository interfaces
required by
+the Idempotent Consumer and Aggregator EIPs via two adapter classes.
+
+=== KeyValueIdempotentRepository
+
+`KeyValueIdempotentRepository` wraps any `KeyValueRepository` as a
+`org.apache.camel.spi.IdempotentRepository`, making it usable with
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer].
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.KeyValueIdempotentRepository;
+import org.apache.camel.component.redis.RedisKeyValueRepository;
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr() {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setEndpoint("redis://localhost:6379");
+ kvr.setKeyPrefix("idempotent:");
+ return kvr;
+}
+
+@BindToRegistry("idempotentRepo")
+public IdempotentRepository idempotentRepo(KeyValueRepository redisKvr) {
+ return new KeyValueIdempotentRepository(redisKvr);
+}
+----
++
+Then reference it in a route:
++
+[source,java]
+----
+from("jms:queue:orders")
+ .idempotentConsumer(header("JMSMessageID"))
+ .idempotentRepository("idempotentRepo")
+ .to("direct:processOrder");
+----
+
+XML DSL::
++
+[source,xml]
+----
+<route>
+ <from uri="jms:queue:orders"/>
+ <idempotentConsumer idempotentRepository="idempotentRepo">
+ <header>JMSMessageID</header>
+ <to uri="direct:processOrder"/>
+ </idempotentConsumer>
+</route>
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- from:
+ uri: jms:queue:orders
+ steps:
+ - idempotentConsumer:
+ header: JMSMessageID
+ idempotentRepository: idempotentRepo
+ steps:
+ - to: direct:processOrder
+----
+====
+
+=== KeyValueAggregationRepository
+
+`KeyValueAggregationRepository` wraps any `KeyValueRepository` as a
+`org.apache.camel.spi.AggregationRepository`, making it usable with
+xref:aggregate-eip.adoc[Aggregator].
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.KeyValueAggregationRepository;
+import org.apache.camel.component.hazelcast.HazelcastKeyValueRepository;
+
+@BindToRegistry("hazelcastKvr")
+public KeyValueRepository hazelcastKvr() {
+ HazelcastKeyValueRepository kvr = new HazelcastKeyValueRepository();
+ kvr.setMapName("aggregation-store");
+ return kvr;
+}
+
+@BindToRegistry("aggregationRepo")
+public AggregationRepository aggregationRepo(KeyValueRepository hazelcastKvr) {
+ return new KeyValueAggregationRepository(hazelcastKvr);
+}
+----
++
+Then reference it in a route:
++
+[source,java]
+----
+from("direct:start")
+ .aggregate(header("orderId"), new GroupedBodyAggregationStrategy())
+ .aggregationRepository("aggregationRepo")
+ .completionSize(10)
+ .to("direct:process");
+----
+
+XML DSL::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <aggregate strategyRef="groupedBodyStrategy"
+ aggregationRepositoryRef="aggregationRepo"
+ completionSize="10">
+ <correlationExpression>
+ <header>orderId</header>
+ </correlationExpression>
+ <to uri="direct:process"/>
+ </aggregate>
+</route>
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- from:
+ uri: direct:start
+ steps:
+ - aggregate:
+ correlationExpression:
+ header: orderId
+ strategyRef: groupedBodyStrategy
+ aggregationRepository: aggregationRepo
+ completionSize: 10
+ steps:
+ - to: direct:process
+----
+====
+
+NOTE: `KeyValueAggregationRepository` serializes the entire `Exchange` to the
backing
+store. For backends without native TTL (JDBC, JPA, Kafka),
aggregation-in-progress entries
+persist until completion. Use a backend with durable storage (JDBC, JPA,
Infinispan) when
+crash recovery of in-flight aggregations is required.
+
+== See Also
+
+* xref:cache-eip.adoc[Cache EIP]
+* xref:idempotentConsumer-eip.adoc[Idempotent Consumer EIP]
+* xref:aggregate-eip.adoc[Aggregator EIP]
diff --git
a/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
b/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
index b365dd6f2fa2..74136447a9d4 100644
---
a/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
+++
b/components/camel-cassandraql/src/main/java/org/apache/camel/processor/keyvalue/cassandra/CassandraKeyValueRepository.java
@@ -283,6 +283,13 @@ public class CassandraKeyValueRepository extends
ServiceSupport implements KeyVa
return buffer != null ? KeyValueRepositoryHelper.deserialize(buffer) :
null;
}
+ /**
+ * Stores a value with optional TTL.
+ * <p/>
+ * <b>Note:</b> The previous value is read in a separate query before the
upsert. This is not atomic — another
+ * client could modify the entry between the read and the write. The
stored value is always correct, but the
+ * returned previous value may be stale. Cassandra does not provide a
native {@code getAndSet} equivalent.
+ */
@Override
@ManagedOperation(description = "Put a key-value pair with optional TTL")
public Object put(String key, Object value, Duration ttl) {
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
index 35849594320b..30fc8ae237ef 100644
---
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
@@ -16,8 +16,6 @@
*/
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;
@@ -30,6 +28,7 @@ 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.KeyValueTtlValue;
import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.ObjectHelper;
import org.ehcache.Cache;
@@ -39,8 +38,8 @@ 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.
+ * wraps each value in a {@link KeyValueTtlValue} 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}).
@@ -54,32 +53,7 @@ import org.ehcache.CacheManager;
@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 Cache<String, KeyValueTtlValue> cache;
private EhcacheManager ehcacheManager;
@Metadata(description = "Name of cache", defaultValue =
"EhcacheKeyValueRepository")
@@ -129,7 +103,7 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
@Override
@ManagedOperation(description = "Get value by key")
public Object get(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
if (entry == null) {
return null;
}
@@ -140,12 +114,20 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
return entry.value();
}
+ /**
+ * Stores a value with optional TTL.
+ * <p/>
+ * <b>Note:</b> The previous value is read in a separate call before the
put. This is not atomic — another thread
+ * could modify the entry between the read and the write. The stored value
is always correct, but the returned
+ * previous value may be stale. Ehcache does not provide a native {@code
getAndPut} equivalent for its
+ * {@code Cache<K,V>} API.
+ */
@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));
+ KeyValueTtlValue previous = cache.get(key);
+ cache.put(key, new KeyValueTtlValue(value, expiresAt));
if (previous == null || previous.isExpired()) {
return null;
}
@@ -155,7 +137,7 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
@Override
@ManagedOperation(description = "Delete a key")
public Object delete(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
cache.remove(key);
if (entry == null || entry.isExpired()) {
return null;
@@ -166,7 +148,7 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
@Override
@ManagedOperation(description = "Check if key exists")
public boolean contains(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
if (entry == null) {
return false;
}
@@ -180,9 +162,9 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
@Override
public Set<String> keys() {
Set<String> keys = new HashSet<>();
- Iterator<Cache.Entry<String, TtlValue>> it = cache.iterator();
+ Iterator<Cache.Entry<String, KeyValueTtlValue>> it = cache.iterator();
while (it.hasNext()) {
- Cache.Entry<String, TtlValue> entry = it.next();
+ Cache.Entry<String, KeyValueTtlValue> entry = it.next();
if (!entry.getValue().isExpired()) {
keys.add(entry.getKey());
} else {
@@ -213,7 +195,7 @@ public class EhcacheKeyValueRepository extends
ServiceSupport implements KeyValu
ObjectHelper.notNull(cacheManager, "cacheManager");
ehcacheManager = new EhcacheManager(cacheManager, false, null);
ehcacheManager.start();
- cache = ehcacheManager.getCache(cacheName, String.class,
TtlValue.class);
+ cache = ehcacheManager.getCache(cacheName, String.class,
KeyValueTtlValue.class);
}
@Override
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
index ce97b3abb13d..9dbde03ca1ae 100644
---
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
@@ -20,6 +20,7 @@ import java.time.Duration;
import java.util.Set;
import java.util.concurrent.TimeUnit;
+import org.apache.camel.support.KeyValueTtlValue;
import org.ehcache.CacheManager;
import org.ehcache.config.builders.CacheConfigurationBuilder;
import org.ehcache.config.builders.CacheManagerBuilder;
@@ -44,7 +45,7 @@ class EhcacheKeyValueRepositoryTest {
.withCache(CACHE_NAME,
CacheConfigurationBuilder.newCacheConfigurationBuilder(
String.class,
- EhcacheKeyValueRepository.TtlValue.class,
+ KeyValueTtlValue.class,
ResourcePoolsBuilder.heap(100)))
.build(true);
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
index 85bf205f329a..5212d6fcb47c 100644
---
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
@@ -16,8 +16,6 @@
*/
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;
@@ -36,6 +34,7 @@ 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.KeyValueTtlValue;
import org.apache.camel.support.service.ServiceSupport;
import org.apache.camel.util.ObjectHelper;
@@ -43,8 +42,8 @@ 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.
+ * {@link javax.cache.expiry.ExpiryPolicy}). This implementation wraps each
value in a {@link KeyValueTtlValue} 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}).
@@ -58,34 +57,9 @@ import org.apache.camel.util.ObjectHelper;
@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;
+ private Cache<String, KeyValueTtlValue> cache;
+ private JCacheManager<String, KeyValueTtlValue> cacheManager;
@Metadata(description = "Configuration for JCache")
private JCacheConfiguration configuration;
@@ -115,11 +89,11 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
this.configuration = configuration;
}
- public Cache<String, TtlValue> getCache() {
+ public Cache<String, KeyValueTtlValue> getCache() {
return cache;
}
- public void setCache(Cache<String, TtlValue> cache) {
+ public void setCache(Cache<String, KeyValueTtlValue> cache) {
this.cache = cache;
}
@@ -135,7 +109,7 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
@Override
@ManagedOperation(description = "Get value by key")
public Object get(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
if (entry == null) {
return null;
}
@@ -146,12 +120,20 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
return entry.value();
}
+ /**
+ * Stores a value with optional TTL.
+ * <p/>
+ * <b>Note:</b> The previous value is read in a separate call before the
put. This is not atomic — another thread
+ * could modify the entry between the read and the write. The stored value
is always correct, but the returned
+ * previous value may be stale. JCache does not provide a {@code
getAndPut} equivalent that also accepts a custom
+ * value type with per-entry TTL.
+ */
@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));
+ KeyValueTtlValue previous = cache.get(key);
+ cache.put(key, new KeyValueTtlValue(value, expiresAt));
if (previous == null || previous.isExpired()) {
return null;
}
@@ -161,7 +143,7 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
@Override
@ManagedOperation(description = "Delete a key")
public Object delete(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
cache.remove(key);
if (entry == null || entry.isExpired()) {
return null;
@@ -172,7 +154,7 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
@Override
@ManagedOperation(description = "Check if key exists")
public boolean contains(String key) {
- TtlValue entry = cache.get(key);
+ KeyValueTtlValue entry = cache.get(key);
if (entry == null) {
return false;
}
@@ -186,9 +168,9 @@ public class JCacheKeyValueRepository extends
ServiceSupport implements CamelCon
@Override
public Set<String> keys() {
Set<String> keys = new HashSet<>();
- Iterator<Cache.Entry<String, TtlValue>> it = cache.iterator();
+ Iterator<Cache.Entry<String, KeyValueTtlValue>> it = cache.iterator();
while (it.hasNext()) {
- Cache.Entry<String, TtlValue> entry = it.next();
+ Cache.Entry<String, KeyValueTtlValue> entry = it.next();
if (!entry.getValue().isExpired()) {
keys.add(entry.getKey());
} else {
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
index f7077c08eb69..bdc009bc3213 100644
---
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
@@ -26,6 +26,7 @@ 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.support.KeyValueTtlValue;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
@@ -35,8 +36,8 @@ import static org.awaitility.Awaitility.await;
@HazelcastTest
class JCacheKeyValueRepositoryTest extends CamelTestSupport {
- private JCacheManager<String, JCacheKeyValueRepository.TtlValue>
cacheManager;
- private Cache<String, JCacheKeyValueRepository.TtlValue> cache;
+ private JCacheManager<String, KeyValueTtlValue> cacheManager;
+ private Cache<String, KeyValueTtlValue> cache;
private JCacheKeyValueRepository repository;
@Override
diff --git
a/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
b/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
index 5cc946841d02..b793689919e8 100644
---
a/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
+++
b/components/camel-kafka/src/main/java/org/apache/camel/processor/keyvalue/kafka/KafkaKeyValueRepository.java
@@ -80,6 +80,14 @@ import org.slf4j.LoggerFactory;
* <p/>
* The topic used must be unique per logical repository. TTL is managed
locally via expiration timestamps in the cache;
* expired entries are lazily evicted on access.
+ * <p/>
+ * <b>Note on TTL and topic growth:</b> TTL expiration is managed locally in
the LRU cache. The Kafka topic retains all
+ * entries (including expired ones) indefinitely. On restart, expired entries
are replayed from the topic and
+ * re-populated in the cache with their original expiration timestamps (they
will appear as expired immediately). To
+ * prevent unbounded topic growth, configure the topic with Kafka's built-in
log compaction
+ * ({@code cleanup.policy=compact}) and optionally {@code
min.compaction.lag.ms} / {@code delete.retention.ms} to
+ * control retention. Deleted entries (tombstones) are represented as records
with a {@code DELETE} action byte, which
+ * log compaction will eventually remove.
*
* @since 4.23
*/
@@ -242,6 +250,24 @@ public class KafkaKeyValueRepository extends
ServiceSupport implements KeyValueR
}
}
+ /**
+ * Associates {@code value} with {@code key} only if {@code key} is not
already present (or its entry has expired).
+ * Returns the existing (non-expired) value if one was already mapped, or
{@code null} if the insertion succeeded.
+ *
+ * <p/>
+ * <b>Note on atomicity:</b> The check-then-act sequence is locally atomic
with respect to other threads in this JVM
+ * (backed by {@link java.util.concurrent.ConcurrentMap#putIfAbsent}), but
the subsequent broadcast to Kafka is a
+ * separate, non-atomic step. In a multi-instance deployment, two nodes
may each observe the key as absent, both
+ * insert locally, and both broadcast — resulting in the last broadcast
winning in the Kafka topic and propagating
+ * to all other instances. This implementation therefore does <em>not</em>
provide distributed compare-and-swap
+ * (CAS) semantics; it is suitable only for best-effort deduplication
within a single JVM or in scenarios where
+ * occasional duplicates across instances are acceptable.
+ *
+ * @param key the key to insert
+ * @param value the value to associate
+ * @param ttl optional time-to-live; {@code null} or zero means no
expiration
+ * @return the existing value if already present and not expired, or
{@code null} if the entry was inserted
+ */
@Override
public Object putIfAbsent(String key, Object value, Duration ttl) {
CacheEntry existing = cache.get(key);
diff --git
a/components/camel-redis/src/main/java/org/apache/camel/component/redis/RedisKeyValueRepository.java
b/components/camel-redis/src/main/java/org/apache/camel/component/redis/RedisKeyValueRepository.java
index 3bedccad69fb..687194885cdb 100644
---
a/components/camel-redis/src/main/java/org/apache/camel/component/redis/RedisKeyValueRepository.java
+++
b/components/camel-redis/src/main/java/org/apache/camel/component/redis/RedisKeyValueRepository.java
@@ -104,11 +104,11 @@ public class RedisKeyValueRepository extends
ServiceSupport implements KeyValueR
public @Nullable Object put(String key, Object value, Duration ttl) {
RBucket<byte[]> bucket = redisson.getBucket(toRedisKey(key),
ByteArrayCodec.INSTANCE);
byte[] serialized = KeyValueRepositoryHelper.serialize(value);
- byte[] previous = bucket.get();
+ byte[] previous;
if (hasPositiveTtl(ttl)) {
- bucket.set(serialized, ttl);
+ previous = bucket.getAndSet(serialized, ttl);
} else {
- bucket.set(serialized);
+ previous = bucket.getAndSet(serialized);
}
return previous != null ?
KeyValueRepositoryHelper.deserialize(previous) : null;
}
diff --git
a/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
b/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
index 91704ed283be..377981ec7fcc 100644
---
a/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
+++
b/components/camel-sql/src/generated/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepositoryConfigurer.java
@@ -31,6 +31,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists":
target.setCreateTableIfNotExists(property(camelContext, boolean.class, value));
return true;
case "deleteexpiredstring":
case "deleteExpiredString":
target.setDeleteExpiredString(property(camelContext, java.lang.String.class,
value)); return true;
+ case "deleteifvaluestring":
+ case "deleteIfValueString":
target.setDeleteIfValueString(property(camelContext, java.lang.String.class,
value)); return true;
case "deletestring":
case "deleteString": target.setDeleteString(property(camelContext,
java.lang.String.class, value)); return true;
case "insertstring":
@@ -47,6 +49,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "tableName": target.setTableName(property(camelContext,
java.lang.String.class, value)); return true;
case "transactiontemplate":
case "transactionTemplate":
target.setTransactionTemplate(property(camelContext,
org.springframework.transaction.support.TransactionTemplate.class, value));
return true;
+ case "updateifvaluestring":
+ case "updateIfValueString":
target.setUpdateIfValueString(property(camelContext, java.lang.String.class,
value)); return true;
default: return false;
}
}
@@ -62,6 +66,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists": return boolean.class;
case "deleteexpiredstring":
case "deleteExpiredString": return java.lang.String.class;
+ case "deleteifvaluestring":
+ case "deleteIfValueString": return java.lang.String.class;
case "deletestring":
case "deleteString": return java.lang.String.class;
case "insertstring":
@@ -78,6 +84,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "tableName": return java.lang.String.class;
case "transactiontemplate":
case "transactionTemplate": return
org.springframework.transaction.support.TransactionTemplate.class;
+ case "updateifvaluestring":
+ case "updateIfValueString": return java.lang.String.class;
default: return null;
}
}
@@ -94,6 +102,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "createTableIfNotExists": return
target.isCreateTableIfNotExists();
case "deleteexpiredstring":
case "deleteExpiredString": return target.getDeleteExpiredString();
+ case "deleteifvaluestring":
+ case "deleteIfValueString": return target.getDeleteIfValueString();
case "deletestring":
case "deleteString": return target.getDeleteString();
case "insertstring":
@@ -110,6 +120,8 @@ public class JdbcKeyValueRepositoryConfigurer extends
org.apache.camel.support.c
case "tableName": return target.getTableName();
case "transactiontemplate":
case "transactionTemplate": return target.getTransactionTemplate();
+ case "updateifvaluestring":
+ case "updateIfValueString": return target.getUpdateIfValueString();
default: return null;
}
}
diff --git
a/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
b/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
index a38b669e8cca..07bc8667d665 100644
---
a/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
+++
b/components/camel-sql/src/generated/resources/META-INF/services/org/apache/camel/bean/JdbcKeyValueRepository.json
@@ -10,7 +10,7 @@
"groupId": "org.apache.camel",
"artifactId": "camel-sql",
"version": "4.23.0-SNAPSHOT",
- "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
+ "properties": { "jdbcTemplate": { "index": 0, "kind": "property",
"displayName": "Jdbc Template", "required": true, "type": "object", "javaType":
"org.springframework.jdbc.core.JdbcTemplate", "deprecated": false, "autowired":
false, "secret": false, "description": "The Spring JdbcTemplate to use for
connecting to the database" }, "transactionTemplate": { "index": 1, "kind":
"property", "displayName": "Transaction Template", "required": true, "type":
"object", "javaType": "org.springf [...]
}
}
diff --git
a/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
b/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
index 2988e16c9925..47eadd3efd77 100644
---
a/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
+++
b/components/camel-sql/src/main/java/org/apache/camel/processor/keyvalue/jdbc/JdbcKeyValueRepository.java
@@ -75,6 +75,10 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
= "SELECT ITEM_KEY FROM CAMEL_KEYVALUE WHERE EXPIRES_AT = 0 OR
EXPIRES_AT > ?";
protected static final String DEFAULT_DELETE_EXPIRED_STRING
= "DELETE FROM CAMEL_KEYVALUE WHERE EXPIRES_AT > 0 AND EXPIRES_AT
<= ?";
+ protected static final String DEFAULT_UPDATE_IF_VALUE_STRING
+ = "UPDATE CAMEL_KEYVALUE SET ITEM_VALUE = ?, EXPIRES_AT = ? WHERE
ITEM_KEY = ? AND ITEM_VALUE = ?";
+ protected static final String DEFAULT_DELETE_IF_VALUE_STRING
+ = "DELETE FROM CAMEL_KEYVALUE WHERE ITEM_KEY = ? AND ITEM_VALUE =
?";
private static final Logger LOG =
LoggerFactory.getLogger(JdbcKeyValueRepository.class);
@@ -105,6 +109,10 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
private String selectKeysString = DEFAULT_SELECT_KEYS_STRING;
@Metadata(label = "advanced", description = "SQL query to use for deleting
expired entries")
private String deleteExpiredString = DEFAULT_DELETE_EXPIRED_STRING;
+ @Metadata(label = "advanced", description = "SQL query to use for
conditional update (CAS replace)")
+ private String updateIfValueString = DEFAULT_UPDATE_IF_VALUE_STRING;
+ @Metadata(label = "advanced", description = "SQL query to use for
conditional delete (CAS delete)")
+ private String deleteIfValueString = DEFAULT_DELETE_IF_VALUE_STRING;
/**
* Creates a new JDBC key-value repository. A {@link DataSource} or {@link
JdbcTemplate} must be set before
@@ -169,6 +177,8 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
clearString = DEFAULT_CLEAR_STRING.replace(DEFAULT_TABLENAME,
tableName);
selectKeysString =
DEFAULT_SELECT_KEYS_STRING.replace(DEFAULT_TABLENAME, tableName);
deleteExpiredString =
DEFAULT_DELETE_EXPIRED_STRING.replace(DEFAULT_TABLENAME, tableName);
+ updateIfValueString =
DEFAULT_UPDATE_IF_VALUE_STRING.replace(DEFAULT_TABLENAME, tableName);
+ deleteIfValueString =
DEFAULT_DELETE_IF_VALUE_STRING.replace(DEFAULT_TABLENAME, tableName);
}
}
@@ -289,6 +299,53 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
});
}
+ /**
+ * Atomically replaces the value for {@code key} only if the current
stored value equals {@code expectedOldValue}.
+ * <p/>
+ * The comparison is performed on the serialized (byte[]) representation
of the values. This works correctly for
+ * well-behaved {@link java.io.Serializable} types whose serialized form
is deterministic (i.e. two independently
+ * serialized instances of the same logical value produce identical
bytes). Classes that override
+ * {@code writeObject} with non-deterministic output (e.g. including
timestamps or random salts) will not compare
+ * correctly and should not be used as expected values.
+ *
+ * @param key the key whose value should be conditionally
replaced
+ * @param expectedOldValue the value that must currently be stored
(compared by serialized bytes)
+ * @param newValue the new value to store if the condition is met
+ * @param ttl the time-to-live for the new entry, or {@code
null} / zero for no expiry
+ * @return {@code true} if the value was replaced, {@code
false} if the current value did not match
+ */
+ @Override
+ public boolean replace(String key, Object expectedOldValue, Object
newValue, Duration ttl) {
+ Boolean result = transactionTemplate.execute(status -> {
+ long expiresAt = toExpiresAt(ttl);
+ byte[] newBytes = KeyValueRepositoryHelper.serialize(newValue);
+ byte[] expectedBytes =
KeyValueRepositoryHelper.serialize(expectedOldValue);
+ int updated = jdbcTemplate.update(getUpdateIfValueString(),
newBytes, expiresAt, key, expectedBytes);
+ return updated > 0;
+ });
+ return result != null && result;
+ }
+
+ /**
+ * Atomically deletes the entry for {@code key} only if the current stored
value equals {@code expectedValue}.
+ * <p/>
+ * The comparison is performed on the serialized (byte[]) representation
of the value. See the note on
+ * {@link #replace(String, Object, Object, Duration)} for caveats about
non-deterministic serialization.
+ *
+ * @param key the key to conditionally delete
+ * @param expectedValue the value that must currently be stored (compared
by serialized bytes)
+ * @return {@code true} if the entry was deleted, {@code
false} if the current value did not match
+ */
+ @Override
+ public boolean delete(String key, Object expectedValue) {
+ Boolean result = transactionTemplate.execute(status -> {
+ byte[] expectedBytes =
KeyValueRepositoryHelper.serialize(expectedValue);
+ int deleted = jdbcTemplate.update(getDeleteIfValueString(), key,
expectedBytes);
+ return deleted > 0;
+ });
+ return result != null && result;
+ }
+
@Override
@ManagedAttribute(description = "The number of entries in the repository")
public int size() {
@@ -445,4 +502,20 @@ public class JdbcKeyValueRepository extends ServiceSupport
implements KeyValueRe
public void setDeleteExpiredString(String deleteExpiredString) {
this.deleteExpiredString = deleteExpiredString;
}
+
+ public String getUpdateIfValueString() {
+ return updateIfValueString;
+ }
+
+ public void setUpdateIfValueString(String updateIfValueString) {
+ this.updateIfValueString = updateIfValueString;
+ }
+
+ public String getDeleteIfValueString() {
+ return deleteIfValueString;
+ }
+
+ public void setDeleteIfValueString(String deleteIfValueString) {
+ this.deleteIfValueString = deleteIfValueString;
+ }
}
diff --git a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
index 4f477263bb5e..f4817f74d9ad 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
@@ -39,6 +39,7 @@
** xref:idempotentConsumer-eip.adoc[Idempotent Consumer]
** xref:intercept.adoc[Intercept]
** xref:kamelet-eip.adoc[Kamelet]
+** xref:keyValueRepository.adoc[KeyValueRepository Backends]
** xref:loadBalance-eip.adoc[Load Balance]
** xref:log-eip.adoc[Logger]
** xref:loop-eip.adoc[Loop]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
new file mode 100644
index 000000000000..d12e4d332e72
--- /dev/null
+++
b/core/camel-core-engine/src/main/docs/modules/eips/pages/keyValueRepository.adoc
@@ -0,0 +1,1161 @@
+= KeyValueRepository Backends
+:doctitle: KeyValueRepository Backends
+:shortname: keyValueRepository
+:description: Unified storage SPI backing the Cache EIP, Idempotent Consumer,
and Aggregator patterns. Documents all available backends and how to configure
them as beans.
+:since: 4.23
+:supportlevel: Stable
+:tabs-sync-option:
+
+`KeyValueRepository` is the unified storage SPI used by the
+xref:cache-eip.adoc[Cache EIP],
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer], and
+xref:aggregate-eip.adoc[Aggregator] patterns.
+It provides a simple `get / put / delete / clear` contract — plus an optional
atomic
+`putIfAbsent` — that Camel maps onto many storage technologies through
pluggable backend
+implementations.
+
+Choosing the right backend is typically a matter of:
+
+* *Scope* — in-process memory, distributed in-memory, durable on-disk, or
remote service.
+* *Atomicity* — whether the backend natively provides an atomic
Compare-And-Swap (CAS)
+ operation (required for exactly-once idempotency under concurrent load).
+* *TTL* — whether expiration is handled natively by the store or requires
client-side logic.
+* *Operational footprint* — whether extra infrastructure (Redis, Kafka,
Cassandra …) is
+ acceptable.
+
+== Available Backends
+
+[cols="2,3,3,1,1",options="header"]
+|===
+| Backend class | Module | Key features | Atomic CAS | Native TTL
+
+| `MemoryKeyValueRepository`
+| `camel-support`
+| JVM-local `ConcurrentHashMap`; zero deps; auto-created when no KVR bean is
registered
+| ✓
+| ✗
+
+| `CaffeineKeyValueRepository`
+| `camel-caffeine`
+| High-throughput in-process cache; configurable size; near-cache for
distributed setups
+| ✓
+| ✗ (client-side via Cache EIP `ttl`)
+
+| `EhcacheKeyValueRepository`
+| `camel-ehcache`
+| Ehcache 3; heap + off-heap + disk tiers; native TTL per cache config
+| ✓
+| ✓ (Ehcache XML config)
+
+| `JCacheKeyValueRepository`
+| `camel-jcache`
+| JSR-107 provider-agnostic; works with Ehcache, Hazelcast, Infinispan, …
+| ✓ (provider-dependent)
+| ✓ (via `javax.cache.expiry`)
+
+| `HazelcastKeyValueRepository`
+| `camel-hazelcast`
+| Distributed, partitioned `IMap`; cluster-aware; near-cache optional
+| ✓
+| ✓ (map TTL config)
+
+| `RedisKeyValueRepository`
+| `camel-redis`
+| Redis `SET`/`GET`; atomic via `SET NX`; native TTL via `EXPIRE`
+| ✓
+| ✓ (native `EXPIRE`)
+
+| `InfinispanRemoteKeyValueRepository`
+| `camel-infinispan`
+| Hot Rod client to remote Infinispan/Data Grid cluster; distributed,
transactional
+| ✓
+| ✓ (per-entry lifespan)
+
+| `JdbcKeyValueRepository`
+| `camel-sql`
+| Any JDBC DataSource; portable; optional auto-DDL; ACID via DataSource
transactions
+| ✗ (non-atomic)
+| ✗ (client-side)
+
+| `JpaKeyValueRepository`
+| `camel-jpa`
+| JPA entity-backed; works with any JPA 2 provider (Hibernate, EclipseLink…)
+| ✗ (non-atomic)
+| ✗ (client-side)
+
+| `CassandraKeyValueRepository`
+| `camel-cassandraql`
+| Cassandra wide-column store; configurable consistency levels; naturally
distributed
+| ✗ (non-atomic)
+| ✓ (Cassandra TTL)
+
+| `KafkaKeyValueRepository`
+| `camel-kafka`
+| Kafka topic as a compacted key-value log; state rebuilt on startup;
eventually consistent
+| ✗ (non-atomic)
+| ✗ (topic retention)
+
+|===
+
+NOTE: _Atomic CAS_ means `putIfAbsent` is implemented without an external lock
— required
+for safe concurrent use as an Idempotent Repository. Non-atomic backends can
still be used
+with the Cache EIP and Aggregator when access is serialized or best-effort
semantics are
+acceptable.
+
+== Backend Configuration
+
+Each backend is registered as a named bean in the Camel registry and then
referenced from
+a route or EIP by name (or auto-discovered when only one bean is present).
+
+=== MemoryKeyValueRepository
+
+Provided by `camel-support` (always on the classpath). No extra dependencies.
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.MemoryKeyValueRepository;
+
+@BindToRegistry("myMemoryKvr")
+public KeyValueRepository myMemoryKvr() {
+ return new MemoryKeyValueRepository();
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: myMemoryKvr
+ type: org.apache.camel.support.MemoryKeyValueRepository
+----
+====
+
+Because `MemoryKeyValueRepository` has no required properties, registering it
by type alone
+is enough. When *no* `KeyValueRepository` bean is registered at all, the Cache
EIP
+auto-creates a private `MemoryKeyValueRepository` per block — no explicit bean
needed.
+
+=== CaffeineKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-caffeine</artifactId>
+</dependency>
+----
+
+Optional property:
+
+* `maximumSize` — maximum number of entries before eviction (default:
unbounded).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.caffeine.cache.CaffeineKeyValueRepository;
+
+@BindToRegistry("caffeineKvr")
+public KeyValueRepository caffeineKvr() {
+ CaffeineKeyValueRepository kvr = new CaffeineKeyValueRepository();
+ kvr.setMaximumSize(10_000);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: caffeineKvr
+ type:
org.apache.camel.component.caffeine.cache.CaffeineKeyValueRepository
+ properties:
+ maximumSize: 10000
+----
+====
+
+TTL is not natively managed by Caffeine in this adapter. Use the Cache EIP
`ttl` option
+for time-based expiration, which wraps values with an expiry timestamp stored
in the entry.
+
+=== EhcacheKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-ehcache</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `cacheManager` — a configured `org.ehcache.CacheManager` bean.
+
+Optional property:
+
+* `cacheName` — logical cache name within the `CacheManager` (default:
`"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.ehcache.EhcacheKeyValueRepository;
+import org.ehcache.CacheManager;
+import org.ehcache.config.builders.CacheConfigurationBuilder;
+import org.ehcache.config.builders.CacheManagerBuilder;
+import org.ehcache.config.builders.ResourcePoolsBuilder;
+import org.ehcache.expiry.ExpiryPolicy;
+
+import java.time.Duration;
+
+@BindToRegistry("ehcacheManager")
+public CacheManager ehcacheManager() {
+ return CacheManagerBuilder.newCacheManagerBuilder()
+ .withCache("products",
+ CacheConfigurationBuilder
+ .newCacheConfigurationBuilder(String.class, Object.class,
+ ResourcePoolsBuilder.heap(10_000))
+ .withExpiry(ExpiryPolicy.BASE_EXPIRY // or a custom
ExpiryPolicy
+ .timeToLiveExpiration(Duration.ofMinutes(30)))
+ )
+ .build(true); // true = init on build
+}
+
+@BindToRegistry("ehcacheKvr")
+public KeyValueRepository ehcacheKvr(CacheManager ehcacheManager) {
+ EhcacheKeyValueRepository kvr = new EhcacheKeyValueRepository();
+ kvr.setCacheManager(ehcacheManager);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: ehcacheKvr
+ type: org.apache.camel.component.ehcache.EhcacheKeyValueRepository
+ properties:
+ cacheManager: "#bean:ehcacheManager"
+ cacheName: products
+----
+====
+
+Native TTL is configured on the `CacheManager` / `CacheConfiguration`, not on
the
+`KeyValueRepository` bean itself.
+
+=== JCacheKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jcache</artifactId>
+</dependency>
+<!-- Plus a JSR-107 provider, e.g.: -->
+<dependency>
+ <groupId>org.ehcache</groupId>
+ <artifactId>ehcache</artifactId>
+</dependency>
+----
+
+Optional properties:
+
+* `configuration` — a `javax.cache.configuration.Configuration` (or
`MutableConfiguration`) bean.
+* `cacheName` — name of the JCache cache (default: `"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.jcache.JCacheKeyValueRepository;
+import javax.cache.configuration.MutableConfiguration;
+import javax.cache.expiry.CreatedExpiryPolicy;
+import javax.cache.expiry.Duration;
+
+@BindToRegistry("jcacheConfig")
+public MutableConfiguration<String, Object> jcacheConfig() {
+ return new MutableConfiguration<String, Object>()
+ .setTypes(String.class, Object.class)
+ .setExpiryPolicyFactory(
+ CreatedExpiryPolicy.factoryOf(new
Duration(java.util.concurrent.TimeUnit.MINUTES, 30)))
+ .setStatisticsEnabled(true);
+}
+
+@BindToRegistry("jcacheKvr")
+public KeyValueRepository jcacheKvr(MutableConfiguration<String, Object>
jcacheConfig) {
+ JCacheKeyValueRepository kvr = new JCacheKeyValueRepository();
+ kvr.setConfiguration(jcacheConfig);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jcacheKvr
+ type: org.apache.camel.component.jcache.JCacheKeyValueRepository
+ properties:
+ cacheName: products
+ configuration: "#bean:jcacheConfig"
+----
+====
+
+Whether `putIfAbsent` is truly atomic depends on the JSR-107 provider. Most
production
+providers (Ehcache 3, Hazelcast, Infinispan) implement it atomically.
+
+=== HazelcastKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-hazelcast</artifactId>
+</dependency>
+----
+
+Optional properties:
+
+* `hazelcastInstance` — an existing `HazelcastInstance` bean (default:
auto-created).
+* `mapName` — name of the distributed `IMap` (default: `"camel-kvr"`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.hazelcast.HazelcastKeyValueRepository;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import com.hazelcast.config.Config;
+import com.hazelcast.config.MapConfig;
+
+@BindToRegistry("hazelcastInstance")
+public HazelcastInstance hazelcastInstance() {
+ Config config = new Config();
+ config.addMapConfig(new MapConfig("camel-products")
+ .setTimeToLiveSeconds(1800)); // 30-minute TTL
+ return Hazelcast.newHazelcastInstance(config);
+}
+
+@BindToRegistry("hazelcastKvr")
+public KeyValueRepository hazelcastKvr(HazelcastInstance hazelcastInstance) {
+ HazelcastKeyValueRepository kvr = new HazelcastKeyValueRepository();
+ kvr.setHazelcastInstance(hazelcastInstance);
+ kvr.setMapName("camel-products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: hazelcastKvr
+ type: org.apache.camel.component.hazelcast.HazelcastKeyValueRepository
+ properties:
+ hazelcastInstance: "#bean:hazelcastInstance"
+ mapName: camel-products
+----
+====
+
+TTL is configured on the Hazelcast `MapConfig`, not on the
`KeyValueRepository`.
+`putIfAbsent` maps to `IMap.putIfAbsent`, which is atomic across the cluster.
+
+=== RedisKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-redis</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `endpoint` — a `camel-redis` endpoint URI string (e.g.
`"redis://localhost:6379"`).
+
+Optional properties:
+
+* `keyPrefix` — a string prefix applied to all keys (useful to namespace
multiple stores in one Redis DB).
+* `redisson` — a pre-configured `RedissonClient` bean (alternative to
`endpoint`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.redis.RedisKeyValueRepository;
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr() {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setEndpoint("redis://localhost:6379");
+ kvr.setKeyPrefix("myapp:");
+ return kvr;
+}
+----
+
+With a custom `RedissonClient`::
++
+[source,java]
+----
+import org.redisson.Redisson;
+import org.redisson.api.RedissonClient;
+import org.redisson.config.Config;
+
+@BindToRegistry("redissonClient")
+public RedissonClient redissonClient() {
+ Config config = new Config();
+ config.useSingleServer()
+ .setAddress("redis://localhost:6379")
+ .setPassword("secret");
+ return Redisson.create(config);
+}
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr(RedissonClient redissonClient) {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setRedisson(redissonClient);
+ kvr.setKeyPrefix("myapp:");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: redisKvr
+ type: org.apache.camel.component.redis.RedisKeyValueRepository
+ properties:
+ endpoint: "redis://localhost:6379"
+ keyPrefix: "myapp:"
+----
+====
+
+Redis provides native TTL via `EXPIRE` / `SET … EX`. `putIfAbsent` maps to
`SET NX`, which
+is atomic. The Cache EIP `ttl` option is respected and propagated to the
underlying
+`EXPIRE` call.
+
+=== InfinispanRemoteKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-infinispan</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `cacheName` — the remote Infinispan cache name.
+
+Optional properties:
+
+* `configuration` — a
`org.infinispan.client.hotrod.configuration.Configuration` bean.
+* `cacheContainer` — a pre-built `RemoteCacheManager` bean (takes priority
over `configuration`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import
org.apache.camel.component.infinispan.remote.InfinispanRemoteKeyValueRepository;
+import org.infinispan.client.hotrod.RemoteCacheManager;
+import org.infinispan.client.hotrod.configuration.ConfigurationBuilder;
+
+@BindToRegistry("infinispanManager")
+public RemoteCacheManager infinispanManager() {
+ return new RemoteCacheManager(
+ new ConfigurationBuilder()
+ .addServer().host("infinispan-host").port(11222)
+ .security().authentication()
+ .username("camel").password("secret")
+ .build());
+}
+
+@BindToRegistry("infinispanKvr")
+public KeyValueRepository infinispanKvr(RemoteCacheManager infinispanManager) {
+ InfinispanRemoteKeyValueRepository kvr = new
InfinispanRemoteKeyValueRepository();
+ kvr.setCacheContainer(infinispanManager);
+ kvr.setCacheName("products");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: infinispanKvr
+ type:
org.apache.camel.component.infinispan.remote.InfinispanRemoteKeyValueRepository
+ properties:
+ cacheContainer: "#bean:infinispanManager"
+ cacheName: products
+----
+====
+
+Infinispan supports per-entry lifespan and max-idle TTL natively.
`putIfAbsent` is
+implemented via the `PUTIFABSENT` Hot Rod operation, which is atomic.
+
+=== JdbcKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-sql</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `dataSource` — a `javax.sql.DataSource` bean.
+
+Optional properties:
+
+* `tableName` — the table used for storage (default: `"camel_kvr"`).
+* `createTableIfNotExists` — auto-create the table at startup if absent
(default: `true`).
+
+The auto-created table schema:
+
+[source,sql]
+----
+CREATE TABLE camel_kvr (
+ kvr_key VARCHAR(255) NOT NULL PRIMARY KEY,
+ kvr_value TEXT
+);
+----
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.sql.JdbcKeyValueRepository;
+import org.springframework.jdbc.datasource.DriverManagerDataSource;
+import javax.sql.DataSource;
+
+@BindToRegistry("myDataSource")
+public DataSource myDataSource() {
+ DriverManagerDataSource ds = new DriverManagerDataSource();
+ ds.setDriverClassName("org.postgresql.Driver");
+ ds.setUrl("jdbc:postgresql://localhost:5432/mydb");
+ ds.setUsername("camel");
+ ds.setPassword("secret");
+ return ds;
+}
+
+@BindToRegistry("jdbcKvr")
+public KeyValueRepository jdbcKvr(DataSource myDataSource) {
+ JdbcKeyValueRepository kvr = new JdbcKeyValueRepository();
+ kvr.setDataSource(myDataSource);
+ kvr.setTableName("product_cache");
+ kvr.setCreateTableIfNotExists(true);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jdbcKvr
+ type: org.apache.camel.component.sql.JdbcKeyValueRepository
+ properties:
+ dataSource: "#bean:myDataSource"
+ tableName: product_cache
+ createTableIfNotExists: true
+----
+====
+
+NOTE: `JdbcKeyValueRepository` does not implement atomic `putIfAbsent` —
concurrent inserts
+may produce duplicate-key exceptions that are caught and treated as a "key
already exists"
+condition. This is safe for idempotency at low concurrency, but for
high-throughput
+exactly-once semantics, prefer a backend with native CAS.
+
+=== JpaKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-jpa</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `entityManagerFactory` — a `javax.persistence.EntityManagerFactory` bean.
+
+Optional properties:
+
+* `joinTransaction` — whether to participate in an existing JTA transaction
(default: `true`).
+* `sharedEntityManager` — use a shared/thread-bound `EntityManager` (Spring
integration, default: `false`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.jpa.JpaKeyValueRepository;
+import javax.persistence.EntityManagerFactory;
+import javax.persistence.Persistence;
+
+@BindToRegistry("emf")
+public EntityManagerFactory emf() {
+ return Persistence.createEntityManagerFactory("myPersistenceUnit");
+}
+
+@BindToRegistry("jpaKvr")
+public KeyValueRepository jpaKvr(EntityManagerFactory emf) {
+ JpaKeyValueRepository kvr = new JpaKeyValueRepository();
+ kvr.setEntityManagerFactory(emf);
+ kvr.setJoinTransaction(false); // standalone, no JTA
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: jpaKvr
+ type: org.apache.camel.component.jpa.JpaKeyValueRepository
+ properties:
+ entityManagerFactory: "#bean:emf"
+ joinTransaction: false
+----
+====
+
+NOTE: Like `JdbcKeyValueRepository`, the JPA backend does not provide atomic
+`putIfAbsent`. Idempotency under concurrent load relies on unique-constraint
violations
+being caught at the database level.
+
+=== CassandraKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-cassandraql</artifactId>
+</dependency>
+----
+
+Required property:
+
+* `session` — a `com.datastax.oss.driver.api.core.CqlSession` bean.
+
+Optional properties:
+
+* `table` — Cassandra table name (default: `"camel_kvr"`).
+* `readConsistencyLevel` — `ConsistencyLevel` for reads (default: `LOCAL_ONE`).
+* `writeConsistencyLevel` — `ConsistencyLevel` for writes (default:
`LOCAL_ONE`).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.cassandra.CassandraKeyValueRepository;
+import com.datastax.oss.driver.api.core.CqlSession;
+import com.datastax.oss.driver.api.core.ConsistencyLevel;
+import java.net.InetSocketAddress;
+
+@BindToRegistry("cassandraSession")
+public CqlSession cassandraSession() {
+ return CqlSession.builder()
+ .addContactPoint(new InetSocketAddress("cassandra-host", 9042))
+ .withLocalDatacenter("datacenter1")
+ .withKeyspace("myapp")
+ .build();
+}
+
+@BindToRegistry("cassandraKvr")
+public KeyValueRepository cassandraKvr(CqlSession cassandraSession) {
+ CassandraKeyValueRepository kvr = new CassandraKeyValueRepository();
+ kvr.setSession(cassandraSession);
+ kvr.setTable("product_cache");
+ kvr.setReadConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
+ kvr.setWriteConsistencyLevel(ConsistencyLevel.LOCAL_QUORUM);
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: cassandraKvr
+ type: org.apache.camel.component.cassandra.CassandraKeyValueRepository
+ properties:
+ session: "#bean:cassandraSession"
+ table: product_cache
+ readConsistencyLevel: LOCAL_QUORUM
+ writeConsistencyLevel: LOCAL_QUORUM
+----
+====
+
+Cassandra supports native TTL via the `USING TTL` clause on `INSERT`/`UPDATE`.
+The Cache EIP `ttl` option is forwarded to the Cassandra write operation as a
native TTL,
+so entries expire automatically at the storage layer.
+
+`putIfAbsent` uses Cassandra's lightweight transaction (`INSERT … IF NOT
EXISTS`).
+Although this is CAS at the Cassandra level, it is _not_ classified as fully
atomic in the
+table above because it is subject to Paxos latency and only provides
linearizability within
+a single partition.
+
+=== KafkaKeyValueRepository
+
+[source,xml,role=dependency]
+----
+<dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-kafka</artifactId>
+</dependency>
+----
+
+Required properties:
+
+* `topic` — the compacted Kafka topic used as the key-value store.
+* `bootstrapServers` — comma-separated list of Kafka broker addresses.
+
+Optional properties:
+
+* `maxCacheSize` — maximum number of entries to hold in the local in-memory
replica (default: unlimited).
+* `pollDurationMs` — poll timeout in milliseconds when rebuilding state at
startup (default: `100`).
+* `startupOnly` — if `true`, the consumer only reads the topic once at startup
and does not keep polling (default: `false`).
+* `groupId` — Kafka consumer group ID (default: auto-generated).
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.component.kafka.KafkaKeyValueRepository;
+
+@BindToRegistry("kafkaKvr")
+public KeyValueRepository kafkaKvr() {
+ KafkaKeyValueRepository kvr = new KafkaKeyValueRepository();
+ kvr.setTopic("camel-kvr-products");
+ kvr.setBootstrapServers("kafka1:9092,kafka2:9092");
+ kvr.setMaxCacheSize(50_000);
+ kvr.setGroupId("camel-kvr-consumer");
+ return kvr;
+}
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- beans:
+ - name: kafkaKvr
+ type: org.apache.camel.component.kafka.KafkaKeyValueRepository
+ properties:
+ topic: camel-kvr-products
+ bootstrapServers: "kafka1:9092,kafka2:9092"
+ maxCacheSize: 50000
+ groupId: camel-kvr-consumer
+----
+====
+
+IMPORTANT: The Kafka backend reconstructs its state by replaying the topic log
on startup.
+This means startup time scales with topic size. Use a
+https://kafka.apache.org/documentation/#topicconfigs_cleanup.policy[log-compacted
topic]
+(`cleanup.policy=compact`) to keep the log bounded.
+
+TTL is not natively supported — entry retention is governed by topic-level
+`retention.ms` / `retention.bytes`, which affects all entries equally, not
individual ones.
+`putIfAbsent` is *not* atomic: reads and writes go through the local in-memory
replica with
+no distributed locking.
+
+== Atomicity Guarantees
+
+The `putIfAbsent(key, value)` method is the critical operation for the
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer] — it must return `true`
only for the
+_first_ caller and `false` for all subsequent callers with the same key.
+
+[cols="2,1,3",options="header"]
+|===
+| Backend | Atomic CAS | Mechanism
+
+| `MemoryKeyValueRepository`
+| ✓
+| `ConcurrentHashMap.putIfAbsent` — JVM-local, lock-free
+
+| `CaffeineKeyValueRepository`
+| ✓
+| Caffeine's internal lock-free `putIfAbsent`
+
+| `EhcacheKeyValueRepository`
+| ✓
+| `Cache.putIfAbsent` (JSR-107) — segment-locked
+
+| `JCacheKeyValueRepository`
+| ✓ (provider)
+| Delegates to the JSR-107 provider's `putIfAbsent`
+
+| `HazelcastKeyValueRepository`
+| ✓
+| `IMap.putIfAbsent` — distributed CAS with CP subsystem optional
+
+| `RedisKeyValueRepository`
+| ✓
+| `SET key value NX` — single-command atomic in Redis
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| Hot Rod `PUTIFABSENT` operation — cluster-wide atomic
+
+| `JdbcKeyValueRepository`
+| ✓
+| `UPDATE … WHERE value = ?` — single-statement CAS within a transaction
+
+| `JpaKeyValueRepository`
+| ✗
+| Optimistic INSERT + constraint catch; `replace` / `delete(key, expected)`
fall back to non-atomic defaults
+
+| `CassandraKeyValueRepository`
+| ✓
+| Lightweight transactions: `INSERT … IF NOT EXISTS`, `UPDATE … IF value = ?`,
`DELETE … IF value = ?`
+
+| `KafkaKeyValueRepository`
+| ✗
+| In-memory replica check — no distributed coordination
+
+|===
+
+For non-atomic backends, use the Cache EIP (where idempotency is not required)
or accept
+at-least-once semantics in idempotency-sensitive flows. Alternatively, front a
non-atomic
+backend with a distributed lock (e.g. via `camel-hazelcast` `ILock`).
+
+== TTL Behavior
+
+[cols="2,1,3",options="header"]
+|===
+| Backend | Native TTL | Notes
+
+| `MemoryKeyValueRepository`
+| ✗
+| Cache EIP wraps values with an expiry timestamp; expired entries are evicted
on read
+
+| `CaffeineKeyValueRepository`
+| ✗
+| Same as Memory — client-side expiry envelope
+
+| `EhcacheKeyValueRepository`
+| ✓
+| `ExpiryPolicy` configured on the `CacheManager`; Cache EIP TTL forwarded
when possible
+
+| `JCacheKeyValueRepository`
+| ✓ (provider)
+| `javax.cache.expiry.ExpiryPolicy` on the `MutableConfiguration`
+
+| `HazelcastKeyValueRepository`
+| ✓
+| `MapConfig.timeToLiveSeconds`; per-entry TTL also available via `IMap.put(k,
v, ttl, unit)`
+
+| `RedisKeyValueRepository`
+| ✓
+| `EXPIRE` set atomically alongside each `PUT`; Cache EIP `ttl` forwarded as
`EX` seconds
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| Per-entry `lifespan` (max-idle also available); Cache EIP `ttl` forwarded
+
+| `JdbcKeyValueRepository`
+| ✗
+| Client-side expiry envelope; a periodic cleanup query is needed to purge
expired rows
+
+| `JpaKeyValueRepository`
+| ✗
+| Client-side expiry envelope; schedule a `DELETE … WHERE expires < now()`
query
+
+| `CassandraKeyValueRepository`
+| ✓
+| `USING TTL <seconds>` clause; Cache EIP `ttl` forwarded; expired rows
deleted by Cassandra
+
+| `KafkaKeyValueRepository`
+| ✗
+| Topic-level retention only; all entries expire at the same time based on
`retention.ms`
+
+|===
+
+NOTE: For backends *without native TTL*, the Cache EIP stores an expiry
timestamp inside
+the serialized value and skips (evicts on read) entries whose timestamp has
passed. This
+means the storage layer may accumulate stale entries that are never explicitly
deleted —
+size them accordingly or run periodic cleanup jobs.
+
+== Distributed Deployment
+
+When running multiple Camel instances (e.g. behind a load balancer or in a
Kubernetes cluster), the choice of
+backend determines whether state is shared and whether CAS operations are safe
across nodes.
+
+[cols="2,1,1,1,3",options="header"]
+|===
+| Backend | Shared state | Distributed CAS | Distributed-safe | Notes
+
+| `MemoryKeyValueRepository`
+| ✗
+| ✗
+| ❌
+| JVM-local only. Each node has its own isolated store.
+
+| `CaffeineKeyValueRepository`
+| ✗
+| ✗
+| ❌
+| JVM-local only. Suitable as a near-cache in front of a distributed backend.
+
+| `EhcacheKeyValueRepository`
+| ✗ (unless clustered)
+| ✗
+| ❌
+| JVM-local by default. Ehcache clustering requires additional configuration
outside of this integration.
+
+| `JCacheKeyValueRepository`
+| Depends on provider
+| Depends on provider
+| ⚠️
+| With a distributed provider (Hazelcast, Infinispan), state is shared. CAS
atomicity depends on the provider.
+
+| `HazelcastKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Distributed `IMap` with cluster-wide `putIfAbsent`, `replace`, and
`remove(key, value)`.
+
+| `RedisKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared Redis server. `setIfAbsent` and `compareAndSet` are atomic. `replace`
with TTL has a brief non-atomic window (see Javadoc).
+
+| `InfinispanRemoteKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Remote cache with version-based optimistic CAS via `replaceWithVersion` /
`removeWithVersion`.
+
+| `JdbcKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared database. `putIfAbsent` uses unique-key constraint; `replace` and
`delete(key, expected)` use SQL `WHERE value = ?`.
+
+| `JpaKeyValueRepository`
+| ✓
+| ⚠️
+| ⚠️
+| Shared database. `putIfAbsent` catches constraint violations, but `replace`
and `delete(key, expected)` fall back to non-atomic defaults
(read-compare-write in Java).
+
+| `CassandraKeyValueRepository`
+| ✓
+| ✓
+| ✅
+| Shared cluster. Full lightweight transaction (LWT) support: `IF NOT EXISTS`,
`IF value = ?`.
+
+| `KafkaKeyValueRepository`
+| ✓ (eventually)
+| ✗
+| ⚠️
+| State is shared via the topic, but CAS is local-only. Suitable for
best-effort deduplication, not strict exactly-once across nodes.
+
+|===
+
+*Recommendations:*
+
+* **Idempotent Consumer in a cluster** — use Hazelcast, Redis, Infinispan,
JDBC, or Cassandra for guaranteed exactly-once deduplication.
+* **Aggregator in a cluster** — use any shared-state backend (recovery
requires the same backend to be visible from the recovering node).
+* **Cache EIP in a cluster** — any backend works. JVM-local backends give each
node its own cache (fine for read-through caching); distributed backends share
cached values.
+* **Single-node deployment** — any backend works, including Memory and
Caffeine.
+
+== Using Adapters: Idempotent Consumer and Aggregator
+
+A `KeyValueRepository` can be adapted to the specialized repository interfaces
required by
+the Idempotent Consumer and Aggregator EIPs via two adapter classes.
+
+=== KeyValueIdempotentRepository
+
+`KeyValueIdempotentRepository` wraps any `KeyValueRepository` as a
+`org.apache.camel.spi.IdempotentRepository`, making it usable with
+xref:idempotentConsumer-eip.adoc[Idempotent Consumer].
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.KeyValueIdempotentRepository;
+import org.apache.camel.component.redis.RedisKeyValueRepository;
+
+@BindToRegistry("redisKvr")
+public KeyValueRepository redisKvr() {
+ RedisKeyValueRepository kvr = new RedisKeyValueRepository();
+ kvr.setEndpoint("redis://localhost:6379");
+ kvr.setKeyPrefix("idempotent:");
+ return kvr;
+}
+
+@BindToRegistry("idempotentRepo")
+public IdempotentRepository idempotentRepo(KeyValueRepository redisKvr) {
+ return new KeyValueIdempotentRepository(redisKvr);
+}
+----
++
+Then reference it in a route:
++
+[source,java]
+----
+from("jms:queue:orders")
+ .idempotentConsumer(header("JMSMessageID"))
+ .idempotentRepository("idempotentRepo")
+ .to("direct:processOrder");
+----
+
+XML DSL::
++
+[source,xml]
+----
+<route>
+ <from uri="jms:queue:orders"/>
+ <idempotentConsumer idempotentRepository="idempotentRepo">
+ <header>JMSMessageID</header>
+ <to uri="direct:processOrder"/>
+ </idempotentConsumer>
+</route>
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- from:
+ uri: jms:queue:orders
+ steps:
+ - idempotentConsumer:
+ header: JMSMessageID
+ idempotentRepository: idempotentRepo
+ steps:
+ - to: direct:processOrder
+----
+====
+
+=== KeyValueAggregationRepository
+
+`KeyValueAggregationRepository` wraps any `KeyValueRepository` as a
+`org.apache.camel.spi.AggregationRepository`, making it usable with
+xref:aggregate-eip.adoc[Aggregator].
+
+[tabs]
+====
+Java DSL::
++
+[source,java]
+----
+import org.apache.camel.support.KeyValueAggregationRepository;
+import org.apache.camel.component.hazelcast.HazelcastKeyValueRepository;
+
+@BindToRegistry("hazelcastKvr")
+public KeyValueRepository hazelcastKvr() {
+ HazelcastKeyValueRepository kvr = new HazelcastKeyValueRepository();
+ kvr.setMapName("aggregation-store");
+ return kvr;
+}
+
+@BindToRegistry("aggregationRepo")
+public AggregationRepository aggregationRepo(KeyValueRepository hazelcastKvr) {
+ return new KeyValueAggregationRepository(hazelcastKvr);
+}
+----
++
+Then reference it in a route:
++
+[source,java]
+----
+from("direct:start")
+ .aggregate(header("orderId"), new GroupedBodyAggregationStrategy())
+ .aggregationRepository("aggregationRepo")
+ .completionSize(10)
+ .to("direct:process");
+----
+
+XML DSL::
++
+[source,xml]
+----
+<route>
+ <from uri="direct:start"/>
+ <aggregate strategyRef="groupedBodyStrategy"
+ aggregationRepositoryRef="aggregationRepo"
+ completionSize="10">
+ <correlationExpression>
+ <header>orderId</header>
+ </correlationExpression>
+ <to uri="direct:process"/>
+ </aggregate>
+</route>
+----
+
+YAML DSL::
++
+[source,yaml]
+----
+- from:
+ uri: direct:start
+ steps:
+ - aggregate:
+ correlationExpression:
+ header: orderId
+ strategyRef: groupedBodyStrategy
+ aggregationRepository: aggregationRepo
+ completionSize: 10
+ steps:
+ - to: direct:process
+----
+====
+
+NOTE: `KeyValueAggregationRepository` serializes the entire `Exchange` to the
backing
+store. For backends without native TTL (JDBC, JPA, Kafka),
aggregation-in-progress entries
+persist until completion. Use a backend with durable storage (JDBC, JPA,
Infinispan) when
+crash recovery of in-flight aggregations is required.
+
+== See Also
+
+* xref:cache-eip.adoc[Cache EIP]
+* xref:idempotentConsumer-eip.adoc[Idempotent Consumer EIP]
+* xref:aggregate-eip.adoc[Aggregator EIP]
diff --git
a/core/camel-core/src/test/java/org/apache/camel/processor/CacheProcessorTest.java
b/core/camel-core/src/test/java/org/apache/camel/processor/CacheProcessorTest.java
index 07a99beb1b05..eb3e385e3881 100644
---
a/core/camel-core/src/test/java/org/apache/camel/processor/CacheProcessorTest.java
+++
b/core/camel-core/src/test/java/org/apache/camel/processor/CacheProcessorTest.java
@@ -137,6 +137,22 @@ class CacheProcessorTest extends ContextTestSupport {
MockEndpoint.assertIsSatisfied(context);
}
+ @Test
+ void testCacheNullBodyCachedWhenCacheNullTrue() throws Exception {
+ MockEndpoint service = getMockEndpoint("mock:cache-null-service");
+
+ // First call — null body result IS cached when cacheNull(true)
+ service.expectedMessageCount(1);
+ template.sendBodyAndHeader("direct:cached-null-true", "req1",
"productId", "A");
+ MockEndpoint.assertIsSatisfied(context);
+
+ // Second call — cache hit (null WAS cached), service is NOT called
again
+ service.reset();
+ service.expectedMessageCount(0);
+ template.sendBodyAndHeader("direct:cached-null-true", "req2",
"productId", "A");
+ MockEndpoint.assertIsSatisfied(context);
+ }
+
@Test
void testCacheExpressionClauseForm() throws Exception {
MockEndpoint mock = getMockEndpoint("mock:clause-result");
@@ -195,6 +211,14 @@ class CacheProcessorTest extends ContextTestSupport {
.end()
.to("mock:null-result");
+ // Cache with null body and cacheNull=true
+ from("direct:cached-null-true")
+ .cache(simple("${header.productId}")).cacheNull(true)
+ .to("mock:cache-null-service")
+ .setBody(constant(null))
+ .end()
+ .to("mock:cache-null-result");
+
// Expression clause form
from("direct:cached-clause")
.cache().simple("${header.productId}")
diff --git
a/core/camel-support/src/main/java/org/apache/camel/support/KeyValueTtlValue.java
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueTtlValue.java
new file mode 100644
index 000000000000..59a46b2de3c0
--- /dev/null
+++
b/core/camel-support/src/main/java/org/apache/camel/support/KeyValueTtlValue.java
@@ -0,0 +1,52 @@
+/*
+ * 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.support;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * A value wrapper that holds the actual value and an expiration timestamp.
Used by KeyValueRepository implementations
+ * that need client-side TTL management (e.g., Ehcache, JCache) because the
underlying store does not support per-entry
+ * TTL.
+ *
+ * @since 4.23
+ */
+public final class KeyValueTtlValue implements Serializable {
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final Object value;
+ private final long expiresAt;
+
+ public KeyValueTtlValue(Object value, long expiresAt) {
+ this.value = value;
+ this.expiresAt = expiresAt;
+ }
+
+ public Object value() {
+ return value;
+ }
+
+ public long expiresAt() {
+ return expiresAt;
+ }
+
+ public boolean isExpired() {
+ return System.currentTimeMillis() >= expiresAt;
+ }
+}
diff --git
a/core/camel-support/src/test/java/org/apache/camel/support/KeyValueRepositoryHelperTest.java
b/core/camel-support/src/test/java/org/apache/camel/support/KeyValueRepositoryHelperTest.java
new file mode 100644
index 000000000000..0977b39c41b0
--- /dev/null
+++
b/core/camel-support/src/test/java/org/apache/camel/support/KeyValueRepositoryHelperTest.java
@@ -0,0 +1,319 @@
+/*
+ * 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.support;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import org.apache.camel.RuntimeCamelException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class KeyValueRepositoryHelperTest {
+
+ //
-------------------------------------------------------------------------
+ // serialize / deserialize roundtrip — scalar types
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testSerializeDeserializeString() {
+ String original = "hello camel";
+
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+ assertThat(result).isInstanceOf(String.class).isEqualTo(original);
+ }
+
+ @Test
+ void testSerializeDeserializeInteger() {
+ Integer original = 42;
+
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+ assertThat(result).isInstanceOf(Integer.class).isEqualTo(original);
+ }
+
+ @Test
+ void testSerializeDeserializeLong() {
+ Long original = Long.MAX_VALUE;
+
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+ assertThat(result).isInstanceOf(Long.class).isEqualTo(original);
+ }
+
+ @Test
+ void testSerializeDeserializeBoolean() {
+ byte[] bytes = KeyValueRepositoryHelper.serialize(Boolean.TRUE);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+ assertThat(result).isEqualTo(Boolean.TRUE);
+ }
+
+ @Test
+ void testSerializeProduceNonEmptyByteArray() {
+ byte[] bytes = KeyValueRepositoryHelper.serialize("test");
+
+ assertThat(bytes).isNotNull().isNotEmpty();
+ }
+
+ //
-------------------------------------------------------------------------
+ // serialize / deserialize roundtrip — complex Serializable object
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testSerializeDeserializeComplexObject() {
+ ComplexPayload original = new ComplexPayload("order-99", 3,
List.of("item-a", "item-b"));
+
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+
assertThat(result).isInstanceOf(ComplexPayload.class).isEqualTo(original);
+ }
+
+ @Test
+ void testSerializeDeserializeNestedMap() {
+ Map<String, Integer> original = Map.of("alpha", 1, "beta", 2);
+
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+
+ assertThat(result).isEqualTo(original);
+ }
+
+ //
-------------------------------------------------------------------------
+ // ByteBuffer serialize / deserialize roundtrip
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testSerializeToByteBufferAndDeserialize() {
+ String original = "byteBuffer value";
+
+ ByteBuffer buffer =
KeyValueRepositoryHelper.serializeToByteBuffer(original);
+ Object result = KeyValueRepositoryHelper.deserialize(buffer);
+
+ assertThat(result).isEqualTo(original);
+ }
+
+ @Test
+ void testSerializeToByteBufferReturnsNonNull() {
+ ByteBuffer buffer = KeyValueRepositoryHelper.serializeToByteBuffer(42);
+
+ assertThat(buffer).isNotNull();
+ assertThat(buffer.remaining()).isPositive();
+ }
+
+ @Test
+ void testDeserializeByteBufferConsumesRemainingBytes() {
+ Integer original = 123;
+ ByteBuffer buffer =
KeyValueRepositoryHelper.serializeToByteBuffer(original);
+
+ // Position is at start; after deserialize the buffer should be fully
consumed
+ assertThat(buffer.position()).isZero();
+ Object result = KeyValueRepositoryHelper.deserialize(buffer);
+
+ assertThat(result).isEqualTo(original);
+ assertThat(buffer.remaining()).isZero();
+ }
+
+ @Test
+ void testSerializeToByteBufferRoundtripComplexObject() {
+ ComplexPayload original = new ComplexPayload("shipment-7", 10,
List.of("sku-x"));
+
+ ByteBuffer buffer =
KeyValueRepositoryHelper.serializeToByteBuffer(original);
+ Object result = KeyValueRepositoryHelper.deserialize(buffer);
+
+
assertThat(result).isInstanceOf(ComplexPayload.class).isEqualTo(original);
+ }
+
+ //
-------------------------------------------------------------------------
+ // deserialize(bytes, offset, length) variant
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testDeserializeWithOffsetAndLength() {
+ String original = "offset-test";
+ byte[] serialized = KeyValueRepositoryHelper.serialize(original);
+
+ // Embed serialized bytes into a larger array with a 4-byte header
prefix
+ int headerSize = 4;
+ byte[] wrapped = new byte[headerSize + serialized.length];
+ System.arraycopy(serialized, 0, wrapped, headerSize,
serialized.length);
+
+ Object result = KeyValueRepositoryHelper.deserialize(wrapped,
headerSize, serialized.length);
+
+ assertThat(result).isEqualTo(original);
+ }
+
+ @Test
+ void testDeserializeWithOffsetZeroFullLength() {
+ Integer original = 999;
+ byte[] bytes = KeyValueRepositoryHelper.serialize(original);
+
+ Object result = KeyValueRepositoryHelper.deserialize(bytes, 0,
bytes.length);
+
+ assertThat(result).isEqualTo(original);
+ }
+
+ @Test
+ void testDeserializeWithOffsetIgnoresTrailingBytes() {
+ String original = "trimmed";
+ byte[] serialized = KeyValueRepositoryHelper.serialize(original);
+
+ // Append garbage trailing bytes — they must be ignored
+ byte[] withTrail = new byte[serialized.length + 10];
+ System.arraycopy(serialized, 0, withTrail, 0, serialized.length);
+
+ Object result = KeyValueRepositoryHelper.deserialize(withTrail, 0,
serialized.length);
+
+ assertThat(result).isEqualTo(original);
+ }
+
+ //
-------------------------------------------------------------------------
+ // Error case — non-Serializable object throws RuntimeCamelException
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testSerializeNonSerializableThrowsRuntimeCamelException() {
+ Object notSerializable = new NonSerializable();
+
+ assertThatThrownBy(() ->
KeyValueRepositoryHelper.serialize(notSerializable))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Failed to serialize value");
+ }
+
+ @Test
+ void testSerializeToByteBufferNonSerializableThrowsRuntimeCamelException()
{
+ Object notSerializable = new NonSerializable();
+
+ assertThatThrownBy(() ->
KeyValueRepositoryHelper.serializeToByteBuffer(notSerializable))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Failed to serialize value");
+ }
+
+ @Test
+ void testDeserializeCorruptBytesThrowsRuntimeCamelException() {
+ byte[] corrupt = new byte[] { 0x00, 0x01, 0x02, 0x03 };
+
+ assertThatThrownBy(() -> KeyValueRepositoryHelper.deserialize(corrupt))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Failed to deserialize value");
+ }
+
+ @Test
+ void testDeserializeOffsetCorruptBytesThrowsRuntimeCamelException() {
+ byte[] corrupt = new byte[] { 0x00, 0x01, 0x02, 0x03 };
+
+ assertThatThrownBy(() -> KeyValueRepositoryHelper.deserialize(corrupt,
0, corrupt.length))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Failed to deserialize value");
+ }
+
+ @Test
+ void testDeserializeByteBufferCorruptThrowsRuntimeCamelException() {
+ ByteBuffer corrupt = ByteBuffer.wrap(new byte[] { 0x00, 0x01, 0x02,
0x03 });
+
+ assertThatThrownBy(() -> KeyValueRepositoryHelper.deserialize(corrupt))
+ .isInstanceOf(RuntimeCamelException.class)
+ .hasMessageContaining("Failed to deserialize value");
+ }
+
+ //
-------------------------------------------------------------------------
+ // Null handling edge cases
+ //
-------------------------------------------------------------------------
+
+ @Test
+ void testSerializeNullRoundtrip() {
+ byte[] bytes = KeyValueRepositoryHelper.serialize(null);
+
+ assertThat(bytes).isNotNull().isNotEmpty();
+
+ Object result = KeyValueRepositoryHelper.deserialize(bytes);
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void testSerializeToByteBufferNullRoundtrip() {
+ ByteBuffer buffer =
KeyValueRepositoryHelper.serializeToByteBuffer(null);
+ Object result = KeyValueRepositoryHelper.deserialize(buffer);
+
+ assertThat(result).isNull();
+ }
+
+ @Test
+ void testSerializeNullOffsetRoundtrip() {
+ byte[] bytes = KeyValueRepositoryHelper.serialize(null);
+ Object result = KeyValueRepositoryHelper.deserialize(bytes, 0,
bytes.length);
+
+ assertThat(result).isNull();
+ }
+
+ //
-------------------------------------------------------------------------
+ // Helper types
+ //
-------------------------------------------------------------------------
+
+ /** A non-serializable type used to provoke serialization failures. */
+ private static class NonSerializable {
+ // intentionally does not implement Serializable
+ }
+
+ /** A complex Serializable value object used for roundtrip assertions. */
+ private static final class ComplexPayload implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ private final String id;
+ private final int quantity;
+ private final List<String> items;
+
+ ComplexPayload(String id, int quantity, List<String> items) {
+ this.id = id;
+ this.quantity = quantity;
+ this.items = List.copyOf(items);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof ComplexPayload other)) {
+ return false;
+ }
+ return quantity == other.quantity
+ && Objects.equals(id, other.id)
+ && Objects.equals(items, other.items);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id, quantity, items);
+ }
+
+ @Override
+ public String toString() {
+ return "ComplexPayload{id='" + id + "', quantity=" + quantity + ",
items=" + items + "}";
+ }
+ }
+}