Croway commented on PR #2046:
URL: 
https://github.com/apache/camel-spring-boot/pull/2046#issuecomment-6262539043

   Thanks for the work on this, @ramu11. I reviewed the PR against CAMEL-24757 
and reproduced the main concerns with a test that starts a real Spring Boot 
context (`CamelAutoConfiguration` + `CacheAutoConfiguration` + this 
auto-configuration, real Caffeine cache, compared against a control with the 
property off). All 6 scenarios below reproduce on the current head (98be59f).
   
   ### Findings
   
   **1. Enabling the property breaks existing `optimisticLocking()` 
aggregators**
   The global `KeyValueRepository` bean is picked up by 
`AggregateProcessor.doStart()` through `findSingleByType`, so an aggregator 
with no explicit repository gets `KeyValueAggregationRepository` instead of 
`MemoryAggregationRepository(optimisticLocking)`. 
`KeyValueAggregationRepository` does not implement 
`OptimisticLockingAggregationRepository`, so startup fails with 
`IllegalArgumentException: Optimistic locking cannot be enabled ...`. With the 
property off, the same route starts.
   
   **2. `keys()` is effectively unsupported on every production provider, and 
that breaks `completionTimeout` at startup**
   `keys()`/`size()` work only when `getNativeCache()` is a `ConcurrentMap`, 
which in practice means `spring.cache.type=simple`. With a real 
`spring.cache.type=caffeine`, any `aggregate(...).completionTimeout(...)` route 
without its own repository fails CamelContext startup 
(`FailedToStartRouteException` caused by `UnsupportedOperationException`). The 
existing tests only use a fake `NonConcurrentNativeCache`, not a real provider.
   The JIRA issue says `keys()`/`size()` always throw 
`UnsupportedOperationException`, and the review comment there pointed out that 
casting to the native cache breaks the abstraction. Either way, please state 
clearly in the docs that enumeration is not supported on Caffeine, Redis or 
JCache, instead of only in the detailed "Enumeration" section.
   
   Correction to the docs: the "Aggregator Impact" paragraph says `RecoverTask` 
logs a WARN every 5s when `keys()` throws. That doesn't happen. Recovery is 
never scheduled for an auto-discovered repository, because `doStart()` assigns 
the field directly instead of calling `setAggregationRepository()`, so 
`isRecoverableRepository` stays `false`. That's a Camel core bug outside this 
PR, but the docs shouldn't describe a recovery behaviour that doesn't exist.
   
   **3. Two `CacheManager` beans make the application fail to start**
   `@ConditionalOnBean(CacheManager.class)` matches when there are several 
candidates, but the injected parameter needs exactly one, so creating 
`springCacheKeyValueRepository` fails with `NoUniqueBeanDefinitionException`. 
`@ConditionalOnSingleCandidate(CacheManager.class)` would fix it.
   
   **4. Cache EIP TTL is silently dropped**
   With `.cache(header("id")).ttl(200)`, the default `MemoryKeyValueRepository` 
expires the entry after 200ms. With this adapter, the entry is still served 
after 1s and never expires (one WARN per instance). With the default `simple` 
cache, that also means unbounded growth.
   
   **5. Store names collapse into one cache, and the JIRA example doesn't work**
   CAMEL-24757 proposes `storeName` → `CacheManager.getCache(storeName)`. The 
PR binds everything to `camel-kvr`, so:
   - `state-store:orders` key `1` is returned by `state-store:users` key `1` 
(without the PR, `StateStoreComponent` keeps one repository per store name)
   - the JIRA's own example (`spring.cache.cache-names: myStore`) fails every 
operation with `IllegalStateException: Spring Cache 'camel-kvr' not found`
   
   ### Design question
   
   Issues 1, 2, 4 and 5 all come from registering a single global 
`KeyValueRepository` bean, which every stateful EIP auto-discovers. The JIRA 
scope was the State Store component only. An alternative would be a small Camel 
core change that lets `StateStoreComponent` take a per-store backend factory 
(for example `Function<String, KeyValueRepository>`), which camel-spring-boot 
could then set with `name -> new SpringCacheKeyValueRepository(cacheManager, 
name)`. That would give the 1:1 cache mapping from the JIRA and isolate stores, 
and Aggregate, Idempotent Consumer and Cache EIP would no longer be affected. 
Since `camel.spring.cache.state-store.enabled` and the global bean become 
public behaviour once released, it seems worth settling this before merge. What 
do you think?
   
   ### Smaller points
   - Since the feature targets Spring Boot 4.x / Spring Framework 7: Spring 7's 
`Cache` still has no key-enumeration API, but Caffeine's native cache exposes 
`asMap()` (a `ConcurrentMap`) and JCache's native cache is `Iterable`, so both 
could support `keys()` behind `ClassUtils.isPresent` checks. Redis would remain 
unsupported.
   - A configurable cache name (for example 
`camel.spring.cache.state-store.cache-name`) would help users whose 
`spring.cache.cache-names` is fixed.
   - `put()` and `delete()` read and then write in two separate calls, so the 
returned old value isn't atomic.
   - The class Javadoc uses a fully qualified `{@link 
org.springframework.cache.concurrent.ConcurrentMapCache}`. The project 
conventions ask for imports instead.
   
   ### Request: an integration test with Redis
   The main motivation in CAMEL-24757 is reusing Spring Data Redis (Lettuce) 
through `spring.cache.type=redis`, but nothing exercises a non-in-memory 
provider. Could you add an IT with a real Redis (`camel-test-infra-redis` from 
apache/camel provides the container) that covers at least:
   - state-store `put`/`get`/`putIfAbsent`/`delete`/`contains` through 
`RedisCacheManager`
   - `keys`/`size` throwing `UnsupportedOperationException`
   - value serialization, for example an Idempotent Consumer and an Aggregator 
round-trip, since `RedisCache` uses JDK serialization by default and 
`DefaultExchangeHolder`/message bodies must be serializable
   - TTL behaviour with a cache-level `spring.cache.redis.time-to-live`
   
   <details>
   <summary>Reproducer: 
<code>SpringCacheKeyValueRepositoryReviewReproducerTest.java</code> (6 tests, 
all pass on 98be59f)</summary>
   
   Extra test dependencies in `core/camel-spring-boot/pom.xml`:
   
   ```xml
   <dependency>
       <groupId>com.github.ben-manes.caffeine</groupId>
       <artifactId>caffeine</artifactId>
       <version>3.2.4</version>
       <scope>test</scope>
   </dependency>
   <dependency>
       <groupId>org.apache.camel</groupId>
       <artifactId>camel-state-store</artifactId>
       <version>${camel-version}</version>
       <scope>test</scope>
   </dependency>
   ```
   
   ```java
   package org.apache.camel.spring.boot.statestore;
   
   import java.time.Duration;
   import java.util.List;
   import java.util.Map;
   import java.util.concurrent.CopyOnWriteArrayList;
   import java.util.concurrent.TimeUnit;
   
   import ch.qos.logback.classic.Logger;
   import ch.qos.logback.classic.spi.ILoggingEvent;
   import ch.qos.logback.core.AppenderBase;
   import org.apache.camel.CamelContext;
   import org.apache.camel.ProducerTemplate;
   import org.apache.camel.builder.RouteBuilder;
   import org.apache.camel.processor.aggregate.AggregateProcessor;
   import org.apache.camel.processor.aggregate.GroupedBodyAggregationStrategy;
   import org.apache.camel.spi.KeyValueRepository;
   import org.apache.camel.spring.boot.CamelAutoConfiguration;
   import org.junit.jupiter.api.Test;
   import org.slf4j.LoggerFactory;
   import org.springframework.boot.autoconfigure.AutoConfigurations;
   import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
   import org.springframework.boot.test.context.runner.ApplicationContextRunner;
   import org.springframework.cache.CacheManager;
   import org.springframework.cache.annotation.ProxyCachingConfiguration;
   import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
   
   import static org.assertj.core.api.Assertions.assertThat;
   import static org.assertj.core.api.Assertions.catchThrowable;
   import static org.awaitility.Awaitility.await;
   
   /**
    * Review reproducers for PR #2046. Each scenario runs a real Spring Boot 
context with CamelAutoConfiguration,
    * CacheAutoConfiguration and the new auto-configuration. Every test also 
runs a control with the property disabled.
    */
   class SpringCacheKeyValueRepositoryReviewReproducerTest {
   
       private final ApplicationContextRunner runner = new 
ApplicationContextRunner()
               .withConfiguration(AutoConfigurations.of(
                       CamelAutoConfiguration.class,
                       CacheAutoConfiguration.class,
                       SpringCacheKeyValueRepositoryAutoConfiguration.class))
               .withUserConfiguration(ProxyCachingConfiguration.class);
   
       // ---- 1. optimisticLocking aggregator breaks once the property is 
enabled ----
   
       static RouteBuilder optimisticLockingRoute() {
           return new RouteBuilder() {
               @Override
               public void configure() {
                   from("direct:agg").routeId("agg")
                           .aggregate(header("id"), new 
GroupedBodyAggregationStrategy())
                           .completionSize(2).optimisticLocking()
                           .to("mock:out");
               }
           };
       }
   
       @Test
       void issue1OptimisticLockingAggregatorFailsToStart() {
           runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::optimisticLockingRoute)
                   .withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=camel-kvr")
                   
.withPropertyValues("camel.spring.cache.state-store.enabled=false")
                   .run(ctx -> assertThat(ctx).hasNotFailed());
   
           runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::optimisticLockingRoute)
                   .withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=camel-kvr")
                   
.withPropertyValues("camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       assertThat(ctx).hasFailed();
                       assertThat(ctx.getStartupFailure()).rootCause()
                               .isInstanceOf(IllegalArgumentException.class)
                               .hasMessageContaining("Optimistic locking cannot 
be enabled");
                   });
       }
   
       // ---- 2. keys() unsupported on a real Caffeine cache ----
   
       static RouteBuilder completionTimeoutRoute() {
           return new RouteBuilder() {
               @Override
               public void configure() {
                   from("direct:agg").routeId("agg")
                           .aggregate(header("id"), new 
GroupedBodyAggregationStrategy())
                           .completionTimeout(1000)
                           .to("mock:out");
               }
           };
       }
   
       static RouteBuilder completionSizeRoute() {
           return new RouteBuilder() {
               @Override
               public void configure() {
                   from("direct:agg").routeId("agg")
                           .aggregate(header("id"), new 
GroupedBodyAggregationStrategy())
                           .completionSize(2)
                           .to("mock:out");
               }
           };
       }
   
       @Test
       void issue2CaffeineKeysUnsupportedBreaksCompletionTimeoutStartup() {
           runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::completionTimeoutRoute)
                   .withPropertyValues("spring.cache.type=caffeine", 
"spring.cache.cache-names=camel-kvr",
                           "camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       assertThat(ctx).hasFailed();
                       assertThat(ctx.getStartupFailure()).rootCause()
                               
.isInstanceOf(UnsupportedOperationException.class);
                   });
       }
   
       @Test
       void issue2RecoveryIsNeverScheduledForAutoDiscoveredRepository() {
           Logger logger = (Logger) 
LoggerFactory.getLogger(AggregateProcessor.class);
           CapturingAppender appender = new CapturingAppender();
           appender.start();
           logger.addAppender(appender);
           try {
               runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::completionSizeRoute)
                       .withPropertyValues("spring.cache.type=caffeine", 
"spring.cache.cache-names=camel-kvr",
                               "camel.spring.cache.state-store.enabled=true")
                       .run(ctx -> {
                           assertThat(ctx).hasNotFailed();
                           
assertThat(appender.events).extracting(ILoggingEvent::getFormattedMessage)
                                   .anyMatch(m -> m.startsWith("Auto-discovered 
KeyValueRepository"))
                                   .noneMatch(m -> m.contains("scheduling 
recover checker"));
                       });
           } finally {
               logger.detachAppender(appender);
           }
       }
   
       // ---- 3. Two CacheManager beans: condition matches, injection fails 
----
   
       @Test
       void issue3TwoCacheManagersFailApplicationStartup() {
           new ApplicationContextRunner()
                   
.withConfiguration(AutoConfigurations.of(SpringCacheKeyValueRepositoryAutoConfiguration.class))
                   .withBean("local", CacheManager.class, () -> new 
ConcurrentMapCacheManager("camel-kvr"))
                   .withBean("remote", CacheManager.class, () -> new 
ConcurrentMapCacheManager("camel-kvr"))
                   
.withPropertyValues("camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       assertThat(ctx).hasFailed();
                       assertThat(ctx.getStartupFailure())
                               
.hasStackTraceContaining("NoUniqueBeanDefinitionException")
                               
.hasStackTraceContaining("springCacheKeyValueRepository");
                   });
       }
   
       // ---- 4. Cache EIP TTL silently ignored ----
   
       static RouteBuilder cacheEipRoute() {
           return new RouteBuilder() {
               @Override
               public void configure() {
                   from("direct:cached").routeId("cached")
                           .cache(header("id")).ttl(200)
                               .setBody(simple("computed-${header.n}"))
                           .end();
               }
           };
       }
   
       @Test
       void issue4CacheEipTtlIgnored() {
           // control: default MemoryKeyValueRepository honours the 200ms TTL
           runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::cacheEipRoute)
                   .withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=camel-kvr",
                           "camel.spring.cache.state-store.enabled=false")
                   .run(ctx -> {
                       ProducerTemplate pt = 
ctx.getBean(CamelContext.class).createProducerTemplate();
                       assertThat(call(pt, 1)).isEqualTo("computed-1");
                       await().atMost(5, 
TimeUnit.SECONDS).pollDelay(Duration.ofMillis(250))
                               .untilAsserted(() -> assertThat(call(pt, 
2)).isEqualTo("computed-2"));
                   });
   
           // with the adapter the entry never expires
           runner.withBean(RouteBuilder.class, 
SpringCacheKeyValueRepositoryReviewReproducerTest::cacheEipRoute)
                   .withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=camel-kvr",
                           "camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       ProducerTemplate pt = 
ctx.getBean(CamelContext.class).createProducerTemplate();
                       assertThat(call(pt, 1)).isEqualTo("computed-1");
                       await().during(Duration.ofSeconds(1)).atMost(3, 
TimeUnit.SECONDS)
                               .until(() -> "computed-1".equals(call(pt, 2)));
                   });
       }
   
       // ---- 5. state-store store names collapse into one shared cache ----
   
       @Test
       void issue5StoreNamesShareOneCache() {
           runner.withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=camel-kvr",
                   "camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       ProducerTemplate pt = 
ctx.getBean(CamelContext.class).createProducerTemplate();
                       pt.sendBodyAndHeader("state-store:orders?operation=put", 
"order-1", "CamelStateStoreKey", "1");
                       Object fromUsers = 
pt.requestBodyAndHeader("state-store:users?operation=get", null,
                               "CamelStateStoreKey", "1");
                       assertThat(fromUsers).isEqualTo("order-1");
                   });
   
           // JIRA's documented example: cache-names=myStore -> camel-kvr 
missing -> every operation fails
           runner.withPropertyValues("spring.cache.type=simple", 
"spring.cache.cache-names=myStore",
                   "camel.spring.cache.state-store.enabled=true")
                   .run(ctx -> {
                       ProducerTemplate pt = 
ctx.getBean(CamelContext.class).createProducerTemplate();
                       assertThat(catchThrowable(() -> pt.sendBodyAndHeader(
                               "state-store:myStore?operation=put", "v", 
"CamelStateStoreKey", "k")))
                               
.hasRootCauseInstanceOf(IllegalStateException.class)
                               .hasStackTraceContaining("'camel-kvr' not 
found");
                   });
       }
   
       private static Object call(ProducerTemplate pt, int n) {
           return pt.requestBodyAndHeaders("direct:cached", null, Map.of("id", 
"k", "n", n));
       }
   
       static final class CapturingAppender extends AppenderBase<ILoggingEvent> 
{
           final List<ILoggingEvent> events = new CopyOnWriteArrayList<>();
   
           @Override
           protected void append(ILoggingEvent event) {
               events.add(event);
           }
       }
   }
   ```
   
   </details>
   
   _Claude Code on behalf of Federico Mariani_
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to