shashank created CAMEL-25215:
--------------------------------

             Summary: camel-ehcache, camel-jcache - EhcacheKeyValueRepository 
and JCacheKeyValueRepository use the non-atomic default putIfAbsent, so 
KeyValueIdempotentRepository can add the same message id twice
                 Key: CAMEL-25215
                 URL: https://issues.apache.org/jira/browse/CAMEL-25215
             Project: Camel
          Issue Type: Bug
          Components: camel-jcache, camel-ehcache
            Reporter: shashank


{{KeyValueRepository}} (new in 4.23, CAMEL-24463) has default implementations 
of {{putIfAbsent}}, {{replace}} and {{delete(key, expectedValue)}} that are 
documented as not atomic ({{get}} followed by {{put}} or {{delete}}), and 
implementations backed by a store with atomic operations should override them. 
The Caffeine, Hazelcast, Infinispan, Cassandra, JDBC, JPA, Kafka and Redis 
repositories override {{putIfAbsent}} (the operation the idempotent adapter 
uses) with an atomic operation of their store; Caffeine, Hazelcast, Infinispan, 
Cassandra and JDBC override all three. {{EhcacheKeyValueRepository}} and 
{{JCacheKeyValueRepository}} do not, although Ehcache and JCache have an atomic 
{{putIfAbsent}}, {{replace(k, old, new)}} and {{remove(k, v)}}.

{{KeyValueIdempotentRepository.add}} is {{repository.putIfAbsent(key, TRUE) == 
null}} (:87), and the Idempotent Consumer EIP (eager, the default) calls 
{{add}} without a lock. With one of these two repositories, two exchanges with 
the same message id that reach the EIP at the same time (concurrent consumers, 
a parallel split) can both read "absent" and both be added, so the duplicate is 
processed. This is the defect of CAMEL-25156 (Caffeine) and CAMEL-25155 (Spring 
Redis) in the key-value adapter. The {{putIfAbsent}} operation of the 
state-store component has the same race. With {{JCacheKeyValueRepository}} on a 
clustered JCache provider (Hazelcast, Infinispan) the race is between nodes as 
well.

A related race: {{get}}, {{contains}} and {{keys}} remove an expired entry 
lazily with an unconditional {{cache.remove(key)}}, so a concurrent writer's 
new entry for the key can be removed.

h3. Reproduction

For each repository, two threads call 
{{KeyValueIdempotentRepository.add("message-1")}} on a repository whose cache 
is wrapped (a dynamic proxy) so that {{get}} returns only when both threads 
have read the key: both calls return true ({{[true, true]}}). 3 of 3 runs for 
both repositories. Expected: one true, one false.

h3. Proposed fix

Override the three operations in both repositories with the atomic operations 
of the cache:
* {{putIfAbsent}}: the cache's {{putIfAbsent}}; an existing entry that has 
expired (these repositories expire entries lazily) counts as absent and is 
replaced with {{replace(key, expired, new)}}, retrying if it changed in the 
meantime.
* {{replace}} and {{delete(key, expectedValue)}}: read the entry, compare its 
value, then {{replace(key, current, new)}} / {{remove(key, current)}}, retrying 
if it changed in the meantime.
* the lazy removal of an expired entry uses {{remove(key, expiredEntry)}}.
* {{KeyValueTtlValue}} gets {{equals}} and {{hashCode}} based on a random token 
set per write and the expiry time, not on the wrapped value. A cache that 
stores copies of its values (the JCache default {{storeByValue=true}}, Ehcache 
with a value copier or an off-heap tier) compares the stored copy with the 
expected entry, and a wrapped value may have no value equality (a {{byte[]}}, a 
POJO without {{equals}}). The token survives the copy, so a compare-and-swap 
fails only when another thread changed the entry, and the retry loops end. An 
equality on the wrapped value would make {{putIfAbsent}} spin forever on an 
expired {{byte[]}} entry in such a cache.

With the fix both tests return one true and one false (3 of 3). New tests: the 
concurrency test per repository; {{putIfAbsent}} with a new, an existing and an 
expired key (the existing tests had none for {{putIfAbsent}}); and a by-value 
test per repository (Ehcache with a serializing value copier; a JCache whose 
{{replace}} and {{remove(k, v)}} compare the stored copy with {{equals}}, as 
the Hazelcast provider of the module tests compares serialized bytes) with an 
expired {{byte[]}} entry. With an equality on the wrapped value, 
{{putIfAbsent}} does not return within 5 seconds and {{get}} leaves the expired 
entry in the cache (3 of 3 for both repositories). With the fix camel-ehcache 
passes 69 tests, camel-jcache 91, and the key-value tests of camel-support (69) 
and camel-core (20) pass.

Affected: main only (4.23.0-SNAPSHOT); the key-value repositories are not 
released yet.

Duplicate check (2026-09-30): JIRA text "EhcacheKeyValueRepository", 
"JCacheKeyValueRepository", "KeyValueIdempotentRepository" (CAMEL-24463, 
CAMEL-23239, CAMEL-24953, CAMEL-24954, none about atomicity); GitHub pull 
requests "EhcacheKeyValueRepository", "JCacheKeyValueRepository", 
"KeyValueTtlValue", "KeyValueRepository putIfAbsent": only #25816 and #26086 
that added them. No open pull request touches these files.

_Filed with Claude Code on behalf of allthingssecurity._




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to