From cdddfe0ee567d27c7c44f65718a75d28a26c06fb Mon Sep 17 00:00:00 2001 From: Tim Yates Date: Mon, 20 Nov 2023 20:09:03 +0000 Subject: [PATCH] Add ExpressionLanguage condition to cache annotations (#692) This adds a conditional field to `@Cacheable`, `@CachePut` and `@CacheInvalidate`. Whenever the cache interceptor is invoked via a method annotated with one of these, the conditional is evaluated to a boolean as to whether caching should be performed. As this would slow down existing apps, or those not using conditional, we check to see if the annotations contain a conditional, and if not the additional processing is shortcircuited to go back to the old behavior. Closes #572 --- buildSrc/build.gradle | 4 +- buildSrc/settings.gradle | 7 + .../cache/ConditionalCacheSpec.groovy | 27 ++ .../micronaut/cache/ConditionalService.java | 29 ++ .../cache/annotation/CacheInvalidate.java | 12 +- .../micronaut/cache/annotation/CachePut.java | 11 +- .../micronaut/cache/annotation/Cacheable.java | 11 +- .../cache/interceptor/CacheInterceptor.java | 130 +++++++-- .../cache/jcache/CacheConditionalSpec.groovy | 264 ++++++++++++++++++ .../cache/jcache/JCacheSyncCacheSpec.groovy | 16 +- gradle.properties | 13 + gradle/libs.versions.toml | 7 + settings.gradle | 8 + src/main/docs/guide/annotations.adoc | 11 + .../docs/guide/annotations/conditional.adoc | 5 + src/main/docs/guide/cache-abstraction.adoc | 77 ----- src/main/docs/guide/caffeine.adoc | 47 ++++ src/main/docs/guide/hazelcast.adoc | 14 +- src/main/docs/guide/toc.yml | 6 +- test-suite-caffeine-groovy/build.gradle.kts | 24 ++ .../io/micronaut/cache/CaffeineTest.groovy | 73 +++++ .../micronaut/cache/ConditionalService.groovy | 34 +++ .../io/micronaut/cache/CounterService.groovy | 37 +++ .../micronaut/cache/MyRemovalHandler.groovy | 14 + .../cache/RemovalListenerImpl.groovy | 24 ++ .../micronaut/cache/SimpleController.groovy | 32 +++ .../src/test/resources/logback.xml | 18 ++ test-suite-caffeine-java/build.gradle.kts | 25 ++ .../java/io/micronaut/cache/CaffeineTest.java | 69 +++++ .../micronaut/cache/ConditionalService.java | 30 ++ .../io/micronaut/cache/CounterService.java | 44 +++ .../io/micronaut/cache/MyRemovalHandler.java | 21 ++ .../micronaut/cache/RemovalListenerImpl.java | 24 ++ .../io/micronaut/cache/SimpleController.java | 32 +++ .../src/test/resources/logback.xml | 18 ++ test-suite-caffeine-kotlin/build.gradle.kts | 32 +++ .../kotlin/io/micronaut/cache/CaffeineTest.kt | 61 ++++ .../io/micronaut/cache/ConditionalService.kt | 26 ++ .../io/micronaut/cache/CounterService.kt | 32 +++ .../io/micronaut/cache/MyRemovalHandler.kt | 14 + .../io/micronaut/cache/RemovalListenerImpl.kt | 15 + .../io/micronaut/cache/SimpleController.kt | 20 ++ .../src/test/resources/logback.xml | 18 ++ test-suite-hazelcast-groovy/build.gradle.kts | 26 ++ .../io/micronaut/cache/CounterService.groovy | 37 +++ .../cache/HazelcastAdditionalSettings.groovy | 21 ++ .../io/micronaut/cache/HazelcastSpec.groovy | 67 +++++ .../micronaut/cache/SimpleController.groovy | 32 +++ .../src/test/resources/logback.xml | 18 ++ test-suite-hazelcast-java/build.gradle.kts | 27 ++ .../io/micronaut/cache/CounterService.java | 44 +++ .../cache/HazelcastAdditionalSettings.java | 22 ++ .../io/micronaut/cache/HazelcastTest.java | 62 ++++ .../io/micronaut/cache/SimpleController.java | 32 +++ .../src/test/resources/logback.xml | 18 ++ test-suite-hazelcast-kotlin/build.gradle.kts | 34 +++ .../io/micronaut/cache/CounterService.kt | 32 +++ .../cache/HazelcastAdditionalSettings.kt | 17 ++ .../io/micronaut/cache/HazelcastTest.kt | 54 ++++ .../io/micronaut/cache/SimpleController.kt | 20 ++ .../src/test/resources/logback.xml | 18 ++ 61 files changed, 1876 insertions(+), 141 deletions(-) create mode 100644 buildSrc/settings.gradle create mode 100644 cache-caffeine/src/test/groovy/io/micronaut/cache/ConditionalCacheSpec.groovy create mode 100644 cache-caffeine/src/test/java/io/micronaut/cache/ConditionalService.java create mode 100644 cache-core/src/test/groovy/io/micronaut/cache/jcache/CacheConditionalSpec.groovy create mode 100644 src/main/docs/guide/annotations.adoc create mode 100644 src/main/docs/guide/annotations/conditional.adoc create mode 100644 src/main/docs/guide/caffeine.adoc create mode 100644 test-suite-caffeine-groovy/build.gradle.kts create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CaffeineTest.groovy create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/ConditionalService.groovy create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/MyRemovalHandler.groovy create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/RemovalListenerImpl.groovy create mode 100644 test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy create mode 100644 test-suite-caffeine-groovy/src/test/resources/logback.xml create mode 100644 test-suite-caffeine-java/build.gradle.kts create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/CaffeineTest.java create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/ConditionalService.java create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/CounterService.java create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/MyRemovalHandler.java create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/RemovalListenerImpl.java create mode 100644 test-suite-caffeine-java/src/test/java/io/micronaut/cache/SimpleController.java create mode 100644 test-suite-caffeine-java/src/test/resources/logback.xml create mode 100644 test-suite-caffeine-kotlin/build.gradle.kts create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CaffeineTest.kt create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/ConditionalService.kt create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/MyRemovalHandler.kt create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/RemovalListenerImpl.kt create mode 100644 test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt create mode 100644 test-suite-caffeine-kotlin/src/test/resources/logback.xml create mode 100644 test-suite-hazelcast-groovy/build.gradle.kts create mode 100644 test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy create mode 100644 test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastAdditionalSettings.groovy create mode 100644 test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastSpec.groovy create mode 100644 test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy create mode 100644 test-suite-hazelcast-groovy/src/test/resources/logback.xml create mode 100644 test-suite-hazelcast-java/build.gradle.kts create mode 100644 test-suite-hazelcast-java/src/test/java/io/micronaut/cache/CounterService.java create mode 100644 test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastAdditionalSettings.java create mode 100644 test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastTest.java create mode 100644 test-suite-hazelcast-java/src/test/java/io/micronaut/cache/SimpleController.java create mode 100644 test-suite-hazelcast-java/src/test/resources/logback.xml create mode 100644 test-suite-hazelcast-kotlin/build.gradle.kts create mode 100644 test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt create mode 100644 test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastAdditionalSettings.kt create mode 100644 test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastTest.kt create mode 100644 test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt create mode 100644 test-suite-hazelcast-kotlin/src/test/resources/logback.xml diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 9ac88d873..aff3316ce 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -3,9 +3,11 @@ plugins { } repositories { + gradlePluginPortal() mavenCentral() } dependencies { - implementation "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin:0.9.28" + implementation(libs.gradle.graal) + implementation(libs.gradle.kotlin) } diff --git a/buildSrc/settings.gradle b/buildSrc/settings.gradle new file mode 100644 index 000000000..6f31e6ef7 --- /dev/null +++ b/buildSrc/settings.gradle @@ -0,0 +1,7 @@ +dependencyResolutionManagement { + versionCatalogs { + libs { + from(files("../gradle/libs.versions.toml")) + } + } +} diff --git a/cache-caffeine/src/test/groovy/io/micronaut/cache/ConditionalCacheSpec.groovy b/cache-caffeine/src/test/groovy/io/micronaut/cache/ConditionalCacheSpec.groovy new file mode 100644 index 000000000..7bb5e03be --- /dev/null +++ b/cache-caffeine/src/test/groovy/io/micronaut/cache/ConditionalCacheSpec.groovy @@ -0,0 +1,27 @@ +package io.micronaut.cache + +import io.micronaut.context.annotation.Property +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +@MicronautTest +@Property(name = "spec.name", value = "ConditionalCacheSpec") +class ConditionalCacheSpec extends Specification { + + @Inject + ConditionalService service + + void "test conditional cache"() { + when: + List results = (1..10).collect { service.get(it) } + sleep(10) + List secondResults = (1..10).collect { service.get(it) } + + then: "condition results in the last 5 results being cached" + results.drop(5) == secondResults.drop(5) + + and: "the first 5 results are not cached" + results.take(5).every {!secondResults.contains(it) } + } +} diff --git a/cache-caffeine/src/test/java/io/micronaut/cache/ConditionalService.java b/cache-caffeine/src/test/java/io/micronaut/cache/ConditionalService.java new file mode 100644 index 000000000..ecb8dca28 --- /dev/null +++ b/cache-caffeine/src/test/java/io/micronaut/cache/ConditionalService.java @@ -0,0 +1,29 @@ +package io.micronaut.cache; + +import io.micronaut.cache.annotation.CacheConfig; +import io.micronaut.cache.annotation.Cacheable; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; + +@Singleton +@CacheConfig(cacheNames = {"conditional"}) +@Requires(property = "spec.name", value = "ConditionalCacheSpec") +public class ConditionalService { + + DocRepo repository = new DocRepo(); + + // tag::conditional[] + @Cacheable(condition = "#{id > 5}") + public String get(Integer id) { + return repository.get(id); + } + // end::conditional[] + + // here to make the docs look nice when we extract the function above + private static class DocRepo { + + String get(Integer id) { + return "test " + id + " " + System.currentTimeMillis(); + } + } +} diff --git a/cache-core/src/main/java/io/micronaut/cache/annotation/CacheInvalidate.java b/cache-core/src/main/java/io/micronaut/cache/annotation/CacheInvalidate.java index e9caba5ea..472ec7232 100644 --- a/cache-core/src/main/java/io/micronaut/cache/annotation/CacheInvalidate.java +++ b/cache-core/src/main/java/io/micronaut/cache/annotation/CacheInvalidate.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 original authors + * Copyright 2017-2023 original authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -89,4 +89,14 @@ * @return True if should be done asynchronously */ boolean async() default false; + + /** + * Evaluated expression that can be used to indicate whether the value should be cached. + * Will be evaluated each time the method is called, and if the condition evaluates to false the cache will not be used. + * + * @see Evaluated Expressions. + * @return The condition + * @since 4.2.0 + */ + String condition() default ""; } diff --git a/cache-core/src/main/java/io/micronaut/cache/annotation/CachePut.java b/cache-core/src/main/java/io/micronaut/cache/annotation/CachePut.java index 883de9152..5802817fd 100644 --- a/cache-core/src/main/java/io/micronaut/cache/annotation/CachePut.java +++ b/cache-core/src/main/java/io/micronaut/cache/annotation/CachePut.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 original authors + * Copyright 2017-2023 original authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -97,4 +97,13 @@ * @return True if cache writes should be done asynchronously */ boolean async() default false; + + /** + * Evaluated expression that can be used to indicate whether the value should be cached. + * Will be evaluated each time the method is called, and if the condition evaluates to false the cache will not be used. + * @see Evaluated Expressions. + * @return The condition + * @since 4.2.0 + */ + String condition() default ""; } diff --git a/cache-core/src/main/java/io/micronaut/cache/annotation/Cacheable.java b/cache-core/src/main/java/io/micronaut/cache/annotation/Cacheable.java index e6fddecdf..b7f748ca8 100644 --- a/cache-core/src/main/java/io/micronaut/cache/annotation/Cacheable.java +++ b/cache-core/src/main/java/io/micronaut/cache/annotation/Cacheable.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 original authors + * Copyright 2017-2023 original authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -87,4 +87,13 @@ * @return True if an atomic operation should be attempted */ boolean atomic() default false; + + /** + * Evaluated expression that can be used to indicate whether the value should be cached. + * Will be evaluated each time the method is called, and if the condition evaluates to false the cache will not be used. + * @see Evaluated Expressions. + * @return The condition + * @since 4.2.0 + */ + String condition() default ""; } diff --git a/cache-core/src/main/java/io/micronaut/cache/interceptor/CacheInterceptor.java b/cache-core/src/main/java/io/micronaut/cache/interceptor/CacheInterceptor.java index f2fcc86a4..51712865f 100644 --- a/cache-core/src/main/java/io/micronaut/cache/interceptor/CacheInterceptor.java +++ b/cache-core/src/main/java/io/micronaut/cache/interceptor/CacheInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 original authors + * Copyright 2017-2023 original authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ import io.micronaut.context.BeanContext; import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.annotation.AnnotationValueResolver; +import io.micronaut.core.annotation.NonNull; import io.micronaut.core.reflect.InstantiationUtils; import io.micronaut.core.type.Argument; import io.micronaut.core.type.ReturnType; @@ -47,6 +48,7 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.lang.annotation.Annotation; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -77,6 +79,7 @@ public class CacheInterceptor implements MethodInterceptor { private static final String MEMBER_ASYNC = "async"; private static final Logger LOG = LoggerFactory.getLogger(CacheInterceptor.class); private static final String MEMBER_ATOMIC = "atomic"; + private static final String MEMBER_CONDITION = "condition"; private static final String MEMBER_PARAMETERS = "parameters"; private static final String MEMBER_ALL = "all"; private static final String MEMBER_KEY_GENERATOR = "keyGenerator"; @@ -119,6 +122,7 @@ public int getOrder() { public Object intercept(MethodInvocationContext context) { if (context.hasStereotype(CacheAnnotation.class)) { InterceptedMethod interceptedMethod = InterceptedMethod.of(context, beanContext.getConversionService()); + try { ReturnType returnType = context.getReturnType(); Argument returnTypeValue = interceptedMethod.returnTypeValue(); @@ -133,7 +137,8 @@ public Object intercept(MethodInvocationContext context) { CacheOperation cacheOperation = getCacheOperation(context, returnType.isVoid() || returnTypeValue .equalsType(Argument.VOID_OBJECT)); - if (cacheOperation.cacheable) { + + if (cacheOperation.cacheable && isCacheableDueToCondition(context)) { if (returnType.isSingleResult()) { return interceptSingle(context, interceptedMethod, returnTypeValue, cacheOperation); } else { @@ -180,8 +185,7 @@ private Object interceptSingle(MethodInvocationContext context, if (result.isPresent()) { // cache hit, return result if (LOG.isDebugEnabled()) { - LOG.debug("Value found in cache [" + cacheOperation.cacheableCacheName + "] for " - + "invocation: " + context); + LOG.debug("Value found in cache [{}] for invocation: {}", cacheOperation.cacheableCacheName, context); } return Mono.just(result.get()); } else { @@ -200,11 +204,11 @@ private Object interceptSingle(MethodInvocationContext context, }).switchIfEmpty(Mono.defer(() -> { if (LOG.isTraceEnabled()) { LOG.trace( - "Invalidating the key [{}] of the cache [{}] since the result of invocation [{}] was " - + "null", - key, - asyncCache.getName(), - context); + "Invalidating the key [{}] of the cache [{}] since the result of invocation [{}] was null", + key, + asyncCache.getName(), + context + ); } return Mono.fromCompletionStage(asyncCacheInvalidate(asyncCache, key, errorHandler)) .then(Mono.empty()); @@ -219,7 +223,7 @@ private Mono handleSingleWriteOperations(MethodInvocationContext cachingMono) { if (cacheOperation.hasWriteOperations()) { - List> putOperations = cacheOperation.putOperations; + List> putOperations = cacheOperation.getPutOperations(context); if (CollectionUtils.isNotEmpty(putOperations)) { for (AnnotationValue putOperation : putOperations) { String[] cacheNames = cacheOperation.getCachePutNames(putOperation); @@ -227,17 +231,17 @@ private Mono handleSingleWriteOperations(MethodInvocationContext Mono.fromCompletionStage( - putAsync(context, cacheOperation, putOperation, cacheNames, result, asyncCacheErrorHandler) + putAsync(context, cacheOperation, putOperation, cacheNames, result, asyncCacheErrorHandler) )); } else { cachingMono = cachingMono.flatMap(result -> Mono.fromCompletionStage( - putAsync(context, cacheOperation, putOperation, cacheNames, result, asyncCacheErrorHandler) + putAsync(context, cacheOperation, putOperation, cacheNames, result, asyncCacheErrorHandler) ).thenReturn(result)); } } } } - List> invalidateOperations = cacheOperation.invalidateOperations; + List> invalidateOperations = cacheOperation.getInvalidateOperations(context); if (CollectionUtils.isNotEmpty(invalidateOperations)) { for (AnnotationValue invalidateOperation : invalidateOperations) { String[] cacheNames = cacheOperation.getCacheInvalidateNames(invalidateOperation); @@ -317,11 +321,11 @@ private Object interceptMulti(MethodInvocationContext context, }).switchIfEmpty(Mono.defer(() -> { if (LOG.isTraceEnabled()) { LOG.trace( - "Invalidating the key [{}] of the cache [{}] since the result of invocation [{}] was " - + "null", - key, - asyncCache.getName(), - context); + "Invalidating the key [{}] of the cache [{}] since the result of invocation [{}] was null", + key, + asyncCache.getName(), + context + ); } return Mono.fromCompletionStage(asyncCacheInvalidate(asyncCache, key, errorHandler)) .then(Mono.empty()); @@ -336,7 +340,7 @@ private Flux handleMultiWriteOperations(MethodInvocationContext cachingFlux) { if (cacheOperation.hasWriteOperations()) { - List> putOperations = cacheOperation.putOperations; + List> putOperations = cacheOperation.getPutOperations(context); if (CollectionUtils.isNotEmpty(putOperations)) { for (AnnotationValue putOperation : putOperations) { String[] cacheNames = cacheOperation.getCachePutNames(putOperation); @@ -354,7 +358,7 @@ private Flux handleMultiWriteOperations(MethodInvocationContext> invalidateOperations = cacheOperation.invalidateOperations; + List> invalidateOperations = cacheOperation.getInvalidateOperations(context); if (CollectionUtils.isNotEmpty(invalidateOperations)) { for (AnnotationValue invalidateOperation : invalidateOperations) { String[] cacheNames = cacheOperation.getCacheInvalidateNames(invalidateOperation); @@ -378,7 +382,8 @@ protected Object interceptSync(MethodInvocationContext context, ReturnType re final ValueWrapper wrapper = new ValueWrapper(); CacheOperation cacheOperation = getCacheOperation(context, returnType.isVoid()); - if (cacheOperation.cacheable) { + boolean cacheableCondition = isCacheableDueToCondition(context); + if (cacheOperation.cacheable && cacheableCondition) { Object key = getCacheableKey(context, cacheOperation); Argument returnArgument = returnType.asArgument(); if (context.isTrue(Cacheable.class, MEMBER_ATOMIC)) { @@ -401,8 +406,8 @@ protected Object interceptSync(MethodInvocationContext context, ReturnType re } } else { String[] cacheNames = resolveCacheNames( - cacheOperation.defaultCacheNames, - context.stringValues(Cacheable.class, MEMBER_CACHE_NAMES) + cacheOperation.defaultCacheNames, + context.stringValues(Cacheable.class, MEMBER_CACHE_NAMES) ); boolean cacheHit = false; for (String cacheName : cacheNames) { @@ -411,7 +416,7 @@ protected Object interceptSync(MethodInvocationContext context, ReturnType re Optional optional = syncCache.get(key, returnArgument); if (optional.isPresent()) { if (LOG.isDebugEnabled()) { - LOG.debug("Value found in cache [" + cacheName + "] for invocation: " + context); + LOG.debug("Value found in cache [{}] for invocation: {}", cacheName, context); } cacheHit = true; wrapper.value = optional.get(); @@ -425,7 +430,7 @@ protected Object interceptSync(MethodInvocationContext context, ReturnType re } if (!cacheHit) { if (LOG.isDebugEnabled()) { - LOG.debug("Value not found in cache for invocation: " + context); + LOG.debug("Value not found in cache for invocation: {}", context); } doProceed(context, wrapper); syncPut(cacheNames, key, wrapper.value); @@ -439,14 +444,14 @@ protected Object interceptSync(MethodInvocationContext context, ReturnType re } } - List> cachePuts = cacheOperation.putOperations; + List> cachePuts = cacheOperation.getPutOperations(context); if (CollectionUtils.isNotEmpty(cachePuts)) { for (AnnotationValue cachePut : cachePuts) { processCachePut(context, wrapper, cachePut, cacheOperation); } } - List> cacheInvalidates = cacheOperation.invalidateOperations; + List> cacheInvalidates = cacheOperation.getInvalidateOperations(context); if (CollectionUtils.isNotEmpty(cacheInvalidates)) { for (AnnotationValue cacheInvalidate : cacheInvalidates) { processCacheEvict(context, cacheOperation, cacheInvalidate); @@ -472,8 +477,9 @@ protected CompletionStage interceptAsCompletableFuture(MethodInvocationContex CacheOperation cacheOperation = getCacheOperation(context, returnTypeObject.isVoid() || requiredType .equalsType(Argument.VOID_OBJECT)); + boolean cacheableCondition = isCacheableDueToCondition(context); CompletionStage returnFuture; - if (cacheOperation.cacheable) { + if (cacheOperation.cacheable && cacheableCondition) { AsyncCache asyncCache = cacheManager.getCache(cacheOperation.cacheableCacheName).async(); Object key = getCacheableKey(context, cacheOperation); returnFuture = asyncCacheGet(asyncCache, key, requiredType, errorHandler) @@ -524,6 +530,17 @@ protected CompletionStage interceptAsCompletableFuture(MethodInvocationContex return returnFuture; } + private boolean isCacheableDueToCondition(MethodInvocationContext context) { + if (!context.isPresent(Cacheable.class, MEMBER_CONDITION)) { + return true; + } + boolean expressionResult = context.booleanValue(Cacheable.class, MEMBER_CONDITION).orElse(false); + if (!expressionResult && LOG.isDebugEnabled()) { + LOG.debug("Cacheable condition evaluated to false for invocation: {}", context); + } + return expressionResult; + } + /** * Saving inside the cache. * @@ -557,7 +574,7 @@ private CacheOperation getCacheOperation(MethodInvocationContext private CompletionStage processFuturePutOperations(MethodInvocationContext context, CacheOperation cacheOperation, CompletionStage value) { - List> putOperations = cacheOperation.putOperations; + List> putOperations = cacheOperation.getPutOperations(context); if (CollectionUtils.isNotEmpty(putOperations)) { for (AnnotationValue putOperation : putOperations) { String[] cacheNames = cacheOperation.getCachePutNames(putOperation); @@ -586,7 +603,7 @@ private CompletionStage processFuturePutOperations(MethodInvocationContext processFutureInvalidateOperations(MethodInvocationContext context, CacheOperation cacheOperation, CompletionStage value) { - List> invalidateOperations = cacheOperation.invalidateOperations; + List> invalidateOperations = cacheOperation.getInvalidateOperations(context); if (CollectionUtils.isNotEmpty(invalidateOperations)) { for (AnnotationValue invalidateOperation : invalidateOperations) { String[] cacheNames = cacheOperation.getCacheInvalidateNames(invalidateOperation); @@ -959,7 +976,11 @@ private class CacheOperation { final String[] defaultCacheNames; final boolean cacheable; String cacheableCacheName; + + boolean putHasCondition; // if any of the put operations has a condition, then we need to filter List> putOperations; + + boolean invalidateHasCondition; List> invalidateOperations; CacheOperation(ExecutableMethod method, boolean isVoid) { @@ -967,7 +988,11 @@ private class CacheOperation { method.classValue(CacheConfig.class, MEMBER_KEY_GENERATOR).orElse(getDefaultKeyGenerator(method)) ); this.putOperations = isVoid ? null : putOperations(method); + this.putHasCondition = this.putOperations != null && hasConditional(putOperations); + this.invalidateOperations = invalidateOperations(method); + this.invalidateHasCondition = hasConditional(invalidateOperations); // if any of the invalidate operations has a condition, then we need to filter + this.defaultCacheNames = method.stringValues(CacheConfig.class, MEMBER_CACHE_NAMES); this.cacheable = method.hasStereotype(Cacheable.class); if (!isVoid && cacheable) { @@ -982,6 +1007,13 @@ private class CacheOperation { } } + private boolean hasConditional(@NonNull List> annotationValues) { + if (CollectionUtils.isEmpty(annotationValues)) { + return false; + } + return annotationValues.stream().anyMatch(av -> av.isPresent(MEMBER_CONDITION)); + } + private Class getDefaultKeyGenerator(ExecutableMethod method) { if (method.isSuspend()) { return KotlinSuspendFunCacheKeyGenerator.class; @@ -990,6 +1022,42 @@ private Class getDefaultKeyGenerator(ExecutableMeth } } + List> getPutOperations(MethodInvocationContext context) { + return this.putHasCondition ? filter(putOperations, context, CachePut.class) : putOperations; + } + + List> getInvalidateOperations(MethodInvocationContext context) { + return this.invalidateHasCondition ? filter(invalidateOperations, context, CacheInvalidate.class) : invalidateOperations; + } + + private > List filter( + List executableMethodAnnotations, + MethodInvocationContext context, + Class annotationClass + ) { + if (CollectionUtils.isEmpty(executableMethodAnnotations)) { + return executableMethodAnnotations; + } + if (LOG.isDebugEnabled()) { + LOG.debug("Filtering {} by expressions", executableMethodAnnotations); + } + // For each annotation on this method, we need to find the same annotation in the invocation context. + // And then execute the condition in that annotation. If the condition is true, then we keep the annotation. + List filtered = new ArrayList<>(executableMethodAnnotations.size()); + for (U executableMethodAnnotation: executableMethodAnnotations) { + for (AnnotationValue value: context.getAnnotationValuesByType(annotationClass)) { + if (value.equals(executableMethodAnnotation)) { + // if the matching annotation has no condition, or the condition is true, then we keep it + if (!value.isPresent(MEMBER_CONDITION) || value.booleanValue(MEMBER_CONDITION).orElse(false)) { + filtered.add(executableMethodAnnotation); + } + break; + } + } + } + return filtered; + } + boolean hasWriteOperations() { return CollectionUtils.isNotEmpty(putOperations) || CollectionUtils.isNotEmpty(invalidateOperations); } @@ -1031,7 +1099,7 @@ private String[] getCacheNames(String[] cacheNames) { private CacheKeyGenerator getKeyGenerator(Class alternateKeyGen) { CacheKeyGenerator keyGenerator = defaultKeyGenerator; if (alternateKeyGen != null && defaultKeyGenerator.getClass() != alternateKeyGen && CacheKeyGenerator.class - .isAssignableFrom(alternateKeyGen)) { + .isAssignableFrom(alternateKeyGen)) { //noinspection unchecked keyGenerator = resolveKeyGenerator((Class) alternateKeyGen); } diff --git a/cache-core/src/test/groovy/io/micronaut/cache/jcache/CacheConditionalSpec.groovy b/cache-core/src/test/groovy/io/micronaut/cache/jcache/CacheConditionalSpec.groovy new file mode 100644 index 000000000..ac09ba082 --- /dev/null +++ b/cache-core/src/test/groovy/io/micronaut/cache/jcache/CacheConditionalSpec.groovy @@ -0,0 +1,264 @@ +package io.micronaut.cache.jcache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.CacheInvalidate +import io.micronaut.cache.annotation.CachePut +import io.micronaut.cache.annotation.Cacheable +import io.micronaut.cache.annotation.PutOperations +import io.micronaut.context.ApplicationContext +import io.micronaut.context.annotation.Factory +import io.micronaut.context.annotation.Requires +import io.micronaut.core.async.annotation.SingleResult +import jakarta.inject.Singleton +import org.reactivestreams.Publisher +import reactor.core.publisher.Flux +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +import javax.cache.CacheManager +import javax.cache.Caching +import javax.cache.configuration.MutableConfiguration +import java.time.Instant +import java.util.concurrent.CompletableFuture + +class CacheConditionalSpec extends Specification { + + @AutoCleanup + @Shared + ApplicationContext applicationContext = ApplicationContext.run( + (JCacheManager.JCACHE_ENABLED): true, + 'spec.name': 'CacheConditionalSpec' + ) + + @Shared + CacheService cacheService = applicationContext.getBean(CacheService) + + @Shared + CacheManager cacheManager = applicationContext.getBean(CacheManager) + + void '@Cacheable condition is used for #scenario return'(CacheScenario scenario) { + given: + Map data = loadCacheableData(scenario) + + when: + sleep(100) + + then: 'test returns cached value' + Flux.from(cacheService.publisherValue('test')).blockFirst() == data.test + + and: 'ignore is not cached' + Flux.from(cacheService.publisherValue('ignore')).blockFirst() != data.ignore + + where: + scenario << CacheScenario.values() + } + + void '@CachePut condition is used for #scenario returne'(CacheScenario scenario) { + given: + String cacheName = 'cond-service' + + when: 'we put a value for test and ignored' + loadCachePutData(scenario) + + def cache = cacheManager.getCache(cacheName) + + then: 'test is in the cache' + cache.containsKey('test') + if (scenario == CacheScenario.PUBLISHER) { + assert cache.get('test') == ['foo'] + } else { + assert cache.get('test') == 'foo' + } + + and: 'ignore is not in the cache as the name was "ignore"' + !cache.containsKey('ignore') + + and: 'qux is not in the cache as the value was "ignore"' + !cache.containsKey('qux') + + and: 'ham is in the cache' + cache.containsKey('ham') + if (scenario == CacheScenario.PUBLISHER) { + assert cache.get('ham') == ['yes'] + } else { + assert cache.get('ham') == 'yes' + } + + cleanup: + cache.removeAll() + + where: + scenario << CacheScenario.values() + } + + void 'cacheinvalidate uses the condition'() { + when: 'we invalidate a value for test and ignored' + def cache = cacheManager.getCache('cond-service') + cache.put("ignore", "foo") + + and: 'we put values for all the things' + cacheService.putValue('test', 'foo') + cacheService.putValue('ignore', 'bar') + cacheService.putValue('qux', 'ignore') + cacheService.putValue('ham', 'yes') + + and: + cacheService.invalidate('test') + cacheService.invalidate('ignore') + cacheService.invalidate('qux') + cacheService.invalidate('ham') + + then: 'the non-ignored values are removed from the cache' + !cache.containsKey('test') + !cache.containsKey('qux') + !cache.containsKey('ham') + + and: 'the ignored value is still in the cache' + cache.containsKey('ignore') + + and: 'ignore has the value we set at the start' + cache.get('ignore') == 'foo' + } + + void 'multiple puts with a condition are handled'() { + when: 'we invalidate a value for test and ignored' + def noIgnoreCache = cacheManager.getCache('cache-no-ignore') + def allCache = cacheManager.getCache('cache-all') + def noFooCache = cacheManager.getCache('cache-no-foo') + + cacheService.multiPut('foo') + cacheService.multiPut('bar') + cacheService.multiPut('ignore') + + then: 'noIgnoreCache ignores the name "ignore"' + noIgnoreCache.get('foo') == 'set!' + noIgnoreCache.get('bar') == 'set!' + noIgnoreCache.get('ignore') == null + + and: 'allCache does not ignore anything' + allCache.get('foo') == 'set!' + allCache.get('bar') == 'set!' + allCache.get('ignore') == 'set!' + + and: 'noFooCache ignores the name "foo"' + noFooCache.get('foo') == null + noFooCache.get('bar') == 'set!' + noFooCache.get('ignore') == 'set!' + } + + private Map loadCacheableData(CacheScenario scenario) { + String initialTest + String initialIgnore + switch (scenario) { + case CacheScenario.PUBLISHER: + initialTest = Flux.from(cacheService.publisherValue('test')).blockFirst() + initialIgnore = Flux.from(cacheService.publisherValue('ignore')).blockFirst() + break + case CacheScenario.FUTURE: + initialTest = cacheService.futureValue('test').get() + initialIgnore = cacheService.futureValue('ignore').get() + break + case CacheScenario.STRING: + initialTest = cacheService.getValue('test') + initialIgnore = cacheService.getValue('ignore') + break + } + return [test: initialTest, ignore: initialIgnore] + } + + private void loadCachePutData(CacheScenario scenario) { + switch (scenario) { + case CacheScenario.PUBLISHER: + Flux.from(cacheService.publisherPutValue('test', 'foo')).blockFirst() + Flux.from(cacheService.publisherPutValue('ignore', 'bar')).blockFirst() + Flux.from(cacheService.publisherPutValue('qux', 'ignore')).blockFirst() + Flux.from(cacheService.publisherPutValue('ham', 'yes')).blockFirst() + break + case CacheScenario.FUTURE: + cacheService.futurePutValue('test', 'foo').get() + cacheService.futurePutValue('ignore', 'bar').get() + cacheService.futurePutValue('qux', 'ignore').get() + cacheService.futurePutValue('ham', 'yes').get() + break + case CacheScenario.STRING: + cacheService.putValue('test', 'foo') + cacheService.putValue('ignore', 'bar') + cacheService.putValue('qux', 'ignore') + cacheService.putValue('ham', 'yes') + break + } + } + + private enum CacheScenario { + PUBLISHER, + FUTURE, + STRING + } + + @Factory + @Requires(property = JCacheManager.JCACHE_ENABLED, value = "true") + @Requires(property = "spec.name", value = "CacheConditionalSpec") + static class CacheFactory { + + @Singleton + CacheManager cacheManager() { + Caching.getCachingProvider().cacheManager.tap { + createCache('cond-service', new MutableConfiguration()) + createCache('cache-no-ignore', new MutableConfiguration()) + createCache('cache-all', new MutableConfiguration()) + createCache('cache-no-foo', new MutableConfiguration()) + } + } + } + + @Singleton + @CacheConfig('cond-service') + @Requires(property = "spec.name", value = "CacheConditionalSpec") + static class CacheService { + + @Cacheable(condition = "#{ name != 'ignore' }") + String getValue(String name) { + return Instant.now().toString() + } + + @Cacheable(condition = "#{ name != 'ignore' }") + CompletableFuture futureValue(String name) { + CompletableFuture.completedFuture(Instant.now().toString()) + } + + @SingleResult + @Cacheable(condition = "#{ name != 'ignore' }") + Publisher publisherValue(String name) { + Flux.just(Instant.now().toString()) + } + + @CachePut(parameters = 'name', condition = "#{ name != 'ignore' && value != 'ignore' }") + String putValue(String name, String value) { + value + } + + @PutOperations([ + @CachePut(cacheNames = 'cache-no-ignore', condition = "#{ name != 'ignore' }"), + @CachePut(cacheNames = 'cache-all'), + @CachePut(cacheNames = 'cache-no-foo', condition = "#{ name != 'foo' }"), + ]) + String multiPut(String name) { + return "set!" + } + + @CachePut(parameters = 'name', condition = "#{ name != 'ignore' && value != 'ignore' }") + CompletableFuture futurePutValue(String name, String value) { + CompletableFuture.completedFuture(value) + } + + @CachePut(parameters = 'name', condition = "#{ name != 'ignore' && value != 'ignore' }") + Publisher publisherPutValue(String name, String value) { + Flux.just(value) + } + + @CacheInvalidate(parameters = 'name', condition = "#{ name != 'ignore' }") + void invalidate(String name) { + } + } +} diff --git a/cache-core/src/test/groovy/io/micronaut/cache/jcache/JCacheSyncCacheSpec.groovy b/cache-core/src/test/groovy/io/micronaut/cache/jcache/JCacheSyncCacheSpec.groovy index 1da33e8a6..b59a2543e 100644 --- a/cache-core/src/test/groovy/io/micronaut/cache/jcache/JCacheSyncCacheSpec.groovy +++ b/cache-core/src/test/groovy/io/micronaut/cache/jcache/JCacheSyncCacheSpec.groovy @@ -1,18 +1,3 @@ -/* - * Copyright 2017-2020 original authors - * - * Licensed 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 io.micronaut.cache.jcache import io.micronaut.cache.annotation.CacheConfig @@ -37,6 +22,7 @@ import javax.cache.configuration.MutableConfiguration import java.util.concurrent.CompletableFuture class JCacheSyncCacheSpec extends Specification { + void "test cacheable annotations with jcache"() { given: ApplicationContext applicationContext = ApplicationContext.run( diff --git a/gradle.properties b/gradle.properties index de77fc390..cf4d9388d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,3 +11,16 @@ jdkapi=https://docs.oracle.com/en/java/javase/17/docs/api org.gradle.caching=true org.gradle.jvmargs=-Xmx1g + +# No matter which Java toolchain we use, the Kotlin Daemon is always invoked by the current JDK. +# Therefor to fix Kapt errors when running tests under Java 21, we need to open up some modules for the Kotlin Daemon. +kotlin.daemon.jvmargs=--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED\ + --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0a9a866a..dfa3bf1a6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ micronaut-docs = "2.0.0" micronaut = "4.2.0" micronaut-test = "4.0.0" +kotlin = "1.9.20" groovy = "4.0.12" spock = "2.3-groovy-4.0" @@ -18,6 +19,7 @@ micronaut-serde = "2.4.0" micronaut-validation = "4.2.0" testcontainers = "1.19.2" graal-svm = "23.1.1" +graal-plugin = "0.9.28" micronaut-logging = "1.0.0" [libraries] @@ -39,6 +41,11 @@ micronaut-validation = { module = "io.micronaut.validation:micronaut-validation- cache-api = { module = "javax.cache:cache-api", version.ref = "cache-ri-impl" } cache-ri-impl = { module = "org.jsr107.ri:cache-ri-impl", version.ref = "cache-ri-impl" } +junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" } +testcontainers-junit = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } +testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-spock = { module = "org.testcontainers:spock", version.ref = "testcontainers" } graal-svm = { module = "org.graalvm.nativeimage:svm", version.ref = "graal-svm" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } +gradle-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +gradle-graal = { module = "org.graalvm.buildtools.native:org.graalvm.buildtools.native.gradle.plugin", version.ref = "graal-plugin" } diff --git a/settings.gradle b/settings.gradle index 498f05f7d..9653a5239 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,14 @@ include 'cache-management' include 'cache-noop' include 'cache-tck' +include 'test-suite-caffeine-groovy' +include 'test-suite-caffeine-java' +include 'test-suite-caffeine-kotlin' + +include 'test-suite-hazelcast-groovy' +include 'test-suite-hazelcast-java' +include 'test-suite-hazelcast-kotlin' + include 'test-suite-caffeine-native' enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' diff --git a/src/main/docs/guide/annotations.adoc b/src/main/docs/guide/annotations.adoc new file mode 100644 index 000000000..83800ef8a --- /dev/null +++ b/src/main/docs/guide/annotations.adoc @@ -0,0 +1,11 @@ +The following cache annotations are supported: + +- link:{api}/io/micronaut/cache/annotation/Cacheable.html[@Cacheable] - Indicates a method is cacheable within the given cache name +- link:{api}/io/micronaut/cache/annotation/CachePut.html[@CachePut] - Indicates that the return value of a method invocation should be cached. Unlike `@Cacheable` the original operation is never skipped. +- link:{api}/io/micronaut/cache/annotation/CacheInvalidate.html[@CacheInvalidate] - Indicates the invocation of a method should cause the invalidation of one or many caches. + +By using one of the annotations the api:cache.interceptor.CacheInterceptor[] is activated which in the case of `@Cacheable` will cache the return result of the method. + +If the return type of the method is a non-blocking type (either link:{jdkapi}/java.base/java/util/concurrent/CompletableFuture.html[CompletableFuture] or an instance of rs:Publisher[] the emitted result will be cached. + +In addition if the underlying Cache implementation supports non-blocking cache operations then cache values will be read from the cache without blocking, resulting in the ability to implement completely non-blocking cache operations. diff --git a/src/main/docs/guide/annotations/conditional.adoc b/src/main/docs/guide/annotations/conditional.adoc new file mode 100644 index 000000000..b35d22331 --- /dev/null +++ b/src/main/docs/guide/annotations/conditional.adoc @@ -0,0 +1,5 @@ +Since Micronaut Cache 4.2.0, the above annotations can be conditionally disabled via an https://docs.micronaut.io/latest/guide/#evaluatedExpressions[Expression Language] expression in the `condition` attribute. + +For example, we can cache the result of a method invocation only if the `id` parameters value is greater than 5: + +snippet::io.micronaut.cache.ConditionalService[project-base="test-suite-caffeine",tags="conditional",indent=0] diff --git a/src/main/docs/guide/cache-abstraction.adoc b/src/main/docs/guide/cache-abstraction.adoc index 0a5469276..7a2d09146 100644 --- a/src/main/docs/guide/cache-abstraction.adoc +++ b/src/main/docs/guide/cache-abstraction.adoc @@ -4,81 +4,4 @@ The link:{api}/io/micronaut/cache/CacheManager.html[CacheManager] interface allo The link:{api}/io/micronaut/cache/SyncCache.html[SyncCache] interface provides a synchronous API for caching, whilst the link:{api}/io/micronaut/cache/AsyncCache.html[AsyncCache] API allows non-blocking operation. -== Cache Annotations -The following cache annotations are supported: - -- link:{api}/io/micronaut/cache/annotation/Cacheable.html[@Cacheable] - Indicates a method is cacheable within the given cache name -- link:{api}/io/micronaut/cache/annotation/CachePut.html[@CachePut] - Indicates that the return value of a method invocation should be cached. Unlike `@Cacheable` the original operation is never skipped. -- link:{api}/io/micronaut/cache/annotation/CacheInvalidate.html[@CacheInvalidate] - Indicates the invocation of a method should cause the invalidation of one or many caches. - - -By using one of the annotations the api:cache.interceptor.CacheInterceptor[] is activated which in the case of `@Cacheable` will cache the return result of the method. - -If the return type of the method is a non-blocking type (either link:{jdkapi}/java.base/java/util/concurrent/CompletableFuture.html[CompletableFuture] or an instance of rs:Publisher[] the emitted result will be cached. - -In addition if the underlying Cache implementation supports non-blocking cache operations then cache values will be read from the cache without blocking, resulting in the ability to implement completely non-blocking cache operations. - - -== Caching with Caffeine - -To cache using https://github.com/ben-manes/caffeine[Caffeine] add the following dependency to your application: - -NOTE: This module is built and tested with Caffeine {caffeineVersion} - -dependency:io.micronaut.cache:micronaut-cache-caffeine[] - - -Then configure one or many caches. For example with `application.yml`: - -.Cache Configuration Example -[configuration] ----- -micronaut: - caches: - my-cache: - maximum-size: 20 ----- - -The above example will configure a cache called "my-cache" with a maximum size of 20. - -[configuration] ----- -micronaut: - caches: - my-cache: - listen-to-removals: true - listen-to-evictions: true ----- - -This example is a cache with the removal/eviction listeners. To be able to use them just implement the `com.github.benmanes.caffeine.cache.RemovalListener` interface as shown in the example. - -[source,java] ----- -@Singleton -public class RemovalListenerImpl implements RemovalListener { - - @Override - public void onRemoval( - @Nullable K key, - @Nullable V value, - @NonNull RemovalCause cause - ) { - } -} ----- - -[NOTE] -.Naming Caches -==== -Names of caches under `micronaut.caches` should be defined in kebab case (lowercase and hyphen separated), if camel case is used the names are normalized to kebab case. So for example specifying `myCache` will become `my-cache`. The kebab case form should be used when referencing caches in the ann:cache.annotation.Cacheable[] annotation. -==== - -To configure a weigher to be used with the `maximumWeight` configuration, create a bean that implements `com.github.benmanes.caffeine.cache.Weigher`. To associate a given weigher with only a specific cache, annotate the bean with `@Named()`. Weighers without a named qualifier will apply to all caches that don't have a named weigher. If no beans are found, a default implementation will be used. - -[NOTE] -.Native compilation -==== -When using Caffeine with Native Compilation, the most commonly used caches will be automatically registered. -If you require additional caches, you will need to register them with Graal yourself https://docs.micronaut.io/latest/guide/#_adding_additional_classes_for_reflective_access[as shown in the guide]. -==== diff --git a/src/main/docs/guide/caffeine.adoc b/src/main/docs/guide/caffeine.adoc new file mode 100644 index 000000000..80594201d --- /dev/null +++ b/src/main/docs/guide/caffeine.adoc @@ -0,0 +1,47 @@ +To cache using https://github.com/ben-manes/caffeine[Caffeine] add the following dependency to your application: + +NOTE: This module is built and tested with Caffeine {caffeineVersion} + +dependency:io.micronaut.cache:micronaut-cache-caffeine[] + + +Then configure one or many caches. For example with `application.yml`: + +.Cache Configuration Example +[configuration] +---- +micronaut: + caches: + my-cache: + maximum-size: 20 +---- + +The above example will configure a cache called "my-cache" with a maximum size of 20. + +[configuration] +---- +micronaut: + caches: + my-cache: + listen-to-removals: true + listen-to-evictions: true +---- + +This example is a cache with the removal/eviction listeners. To be able to use them just implement the `com.github.benmanes.caffeine.cache.RemovalListener` interface as shown in the example. + +snippet::io.micronaut.cache.RemovalListenerImpl[project-base="test-suite-caffeine",tags="clazz"] + +[NOTE] +.Naming Caches +==== +Names of caches under `micronaut.caches` should be defined in kebab case (lowercase and hyphen separated), if camel case is used the names are normalized to kebab case. So for example specifying `myCache` will become `my-cache`. The kebab case form should be used when referencing caches in the ann:cache.annotation.Cacheable[] annotation. +==== + +To configure a weigher to be used with the `maximumWeight` configuration, create a bean that implements `com.github.benmanes.caffeine.cache.Weigher`. To associate a given weigher with only a specific cache, annotate the bean with `@Named()`. Weighers without a named qualifier will apply to all caches that don't have a named weigher. If no beans are found, a default implementation will be used. + +[NOTE] +.Native compilation +==== +When using Caffeine with Native Compilation, the most commonly used caches will be automatically registered. +If you require additional caches, you will need to register them with Graal yourself https://docs.micronaut.io/latest/guide/#_adding_additional_classes_for_reflective_access[as shown in the guide]. +==== diff --git a/src/main/docs/guide/hazelcast.adoc b/src/main/docs/guide/hazelcast.adoc index 119077e6f..706bdf55a 100644 --- a/src/main/docs/guide/hazelcast.adoc +++ b/src/main/docs/guide/hazelcast.adoc @@ -39,19 +39,7 @@ include::{includedir}configurationProperties/io.micronaut.cache.hazelcast.Hazelc For settings not in the above list, a https://docs.micronaut.io/latest/api/io/micronaut/context/event/BeanCreatedEventListener.html[BeanCreatedEventListener] can be registered for link:{api}/io/micronaut/cache/hazelcast/HazelcastClientConfiguration.html[HazelcastClientConfiguration] or link:{api}/io/micronaut/cache/hazelcast/HazelcastMemberConfiguration.html[HazelcastMemberConfiguration]. The listener will allow all properties to be set directly on the configuration instance. -[source,java] ----- -@Singleton -public class HazelcastAdditionalSettings implements BeanCreatedEventListener { - - @Override - public HazelcastClientConfiguration onCreated(BeanCreatedEvent event) { - HazelcastClientConfiguration configuration = event.getBean(); - // Set anything on the configuration - return configuration; - } -} ----- +snippet::io.micronaut.cache.HazelcastAdditionalSettings[project-base="test-suite-hazelcast",tags="clazz"] Alternatively, the `HazelcastClientConfiguration` or `HazelcastMemberConfiguration` bean may be replaced with your own implementation. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 25d8696f4..76088f02b 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -1,6 +1,10 @@ introduction: Introduction releaseHistory: Release History cache-abstraction: Cache Abstraction +annotations: + title: Cache Annotations + conditional: Conditional Caching +caffeine: Caching with Caffeine jcache: JCache API support redis: Redis Support ehcache: Ehcache Support @@ -9,4 +13,4 @@ infinispan: Infinispan Support microstream: MicroStream Support noop: No Operation Cache Support endpoint: Endpoint -repository: Repository \ No newline at end of file +repository: Repository diff --git a/test-suite-caffeine-groovy/build.gradle.kts b/test-suite-caffeine-groovy/build.gradle.kts new file mode 100644 index 000000000..29275e358 --- /dev/null +++ b/test-suite-caffeine-groovy/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + id("groovy") +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(projects.micronautCacheCaffeine) + + testImplementation(mn.micronaut.inject.groovy) + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.spock) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CaffeineTest.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CaffeineTest.groovy new file mode 100644 index 000000000..07c71d158 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CaffeineTest.groovy @@ -0,0 +1,73 @@ +package io.micronaut.cache + +import io.micronaut.context.BeanContext +import io.micronaut.context.annotation.Property +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import spock.lang.Specification + +import java.util.List +import java.util.stream.Stream + +@MicronautTest +@Property(name = "micronaut.caches.counter.initial-capacity", value = "10") +@Property(name = "micronaut.caches.counter.test-mode", value = "true") +@Property(name = "micronaut.caches.counter.maximum-size", value = "20") +@Property(name = "micronaut.caches.counter.listen-to-removals", value = "true") +class CaffeineTest extends Specification { + + @Inject + @Client("/") + HttpClient httpClient + + @Inject + BeanContext ctx + + def 'simple test'() { + given: + BlockingHttpClient client = httpClient.toBlocking() + MyRemovalHandler bean = ctx.getBean(MyRemovalHandler.class) + + when: + Integer inc = client.retrieve("/inc", Integer.class) + + then: + inc == 1 + bean.removals.empty + + when: + inc = client.retrieve("/inc", Integer.class) + + then: + inc == 2 + bean.removals == ["test|1|REPLACED"] + + when: + Integer get = client.retrieve("/get", Integer.class) + + then: + get == 2 + + when: + client.exchange("/del") + + then: + bean.removals == ["test|1|REPLACED", "test|2|EXPLICIT"] + } + + def "conditional test"() { + given: + ConditionalService bean = ctx.getBean(ConditionalService) + + when: + List first = (1..10).collect { bean.get(new ConditionalService.Id(it)) } + List second = (1..10).collect { bean.get(new ConditionalService.Id(it)) } + + then: + first.take(5) != second.take(5) + first.drop(5) == second.drop(5) + } +} diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/ConditionalService.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/ConditionalService.groovy new file mode 100644 index 000000000..963f1ec86 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/ConditionalService.groovy @@ -0,0 +1,34 @@ +package io.micronaut.cache + +import groovy.transform.Canonical +import groovy.transform.Immutable +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["conditional"]) +class ConditionalService { + + DocRepo repository = new DocRepo() + + // tag::conditional[] + @Immutable + static class Id { + int value + } + + @Cacheable(condition = "#{id.value > 5}") + String get(Id id) { + return repository.get(id) + } + // end::conditional[] + + // here to make the docs look nice when we extract the function above + private static class DocRepo { + + String get(Id id) { + return "test $id.value ${System.currentTimeMillis()}" + } + } +} diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy new file mode 100644 index 000000000..44c2cf424 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy @@ -0,0 +1,37 @@ +package io.micronaut.cache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.CacheInvalidate +import io.micronaut.cache.annotation.CachePut +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["counter"]) +public class CounterService { + + Map counters = [:] + + @CachePut + int increment(String name) { + int value = counters.computeIfAbsent(name, s -> 0) + counters.put(name, ++value) + return value + } + + @Cacheable + int getValue(String name) { + return counters.computeIfAbsent(name, s -> 0) + } + + + @CacheInvalidate() + void reset(String name) { + counters.remove(name) + } + + @CacheInvalidate + void set(String name, int val) { + counters.put(name, val) + } +} diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/MyRemovalHandler.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/MyRemovalHandler.groovy new file mode 100644 index 000000000..603ddebfb --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/MyRemovalHandler.groovy @@ -0,0 +1,14 @@ +package io.micronaut.cache + +import com.github.benmanes.caffeine.cache.RemovalCause +import jakarta.inject.Singleton + +@Singleton +class MyRemovalHandler { + + List removals = [] + + void handle(String key, Integer value, RemovalCause cause) { + removals << "$key|$value|$cause" + } +} diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/RemovalListenerImpl.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/RemovalListenerImpl.groovy new file mode 100644 index 000000000..2517888e3 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/RemovalListenerImpl.groovy @@ -0,0 +1,24 @@ +package io.micronaut.cache + +import com.github.benmanes.caffeine.cache.RemovalCause +import com.github.benmanes.caffeine.cache.RemovalListener +import io.micronaut.core.annotation.NonNull +import io.micronaut.core.annotation.Nullable +import jakarta.inject.Singleton + +// tag::clazz[] +@Singleton +class RemovalListenerImpl implements RemovalListener { + + private final MyRemovalHandler handler + + RemovalListenerImpl(MyRemovalHandler handler) { + this.handler = handler + } + + @Override + public void onRemoval(@Nullable String key, @Nullable Integer value, @NonNull RemovalCause cause) { + handler.handle(key, value, cause) + } +} +// end::clazz[] diff --git a/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy new file mode 100644 index 000000000..2e44a11e1 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy @@ -0,0 +1,32 @@ +package io.micronaut.cache + +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Status + +@Controller +class SimpleController { + + private final CounterService counterService + + SimpleController(CounterService counterService) { + this.counterService = counterService + } + + @Get("/inc") + int increment() { + return counterService.increment("test") + } + + @Get("/get") + int get() { + return counterService.getValue("test") + } + + @Get("/del") + @Status(HttpStatus.FOUND) + void del() { + counterService.reset("test") + } +} diff --git a/test-suite-caffeine-groovy/src/test/resources/logback.xml b/test-suite-caffeine-groovy/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-caffeine-groovy/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite-caffeine-java/build.gradle.kts b/test-suite-caffeine-java/build.gradle.kts new file mode 100644 index 000000000..d24547390 --- /dev/null +++ b/test-suite-caffeine-java/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id("java-library") +} + +repositories { + mavenCentral() +} + +dependencies { + testAnnotationProcessor(mn.micronaut.inject.java) + + testImplementation(projects.micronautCacheCaffeine) + + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.junit5) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CaffeineTest.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CaffeineTest.java new file mode 100644 index 000000000..1db42648a --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CaffeineTest.java @@ -0,0 +1,69 @@ +package io.micronaut.cache; + +import io.micronaut.context.BeanContext; +import io.micronaut.context.annotation.Property; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest +@Property(name = "micronaut.caches.counter.initial-capacity", value = "10") +@Property(name = "micronaut.caches.counter.test-mode", value = "true") +@Property(name = "micronaut.caches.counter.maximum-size", value = "20") +@Property(name = "micronaut.caches.counter.listen-to-removals", value = "true") +class CaffeineTest { + + @Inject + @Client("/") + HttpClient httpClient; + + @Inject + BeanContext ctx; + + @Test + void simpleTest() { + BlockingHttpClient client = httpClient.toBlocking(); + MyRemovalHandler bean = ctx.getBean(MyRemovalHandler.class); + + Integer inc = client.retrieve("/inc", Integer.class); + assertEquals(1, inc); + + assertTrue(() -> bean.getRemovals().isEmpty()); + + inc = client.retrieve("/inc", Integer.class); + assertEquals(2, inc); + + List expectedEventsAfterReplacement = List.of("test|1|REPLACED"); + assertEquals(expectedEventsAfterReplacement, bean.getRemovals()); + + Integer get = client.retrieve("/get", Integer.class); + assertEquals(2, get); + + client.exchange("/del"); + + List expectedEventsAfterRemoval = List.of("test|1|REPLACED", "test|2|EXPLICIT"); + assertEquals(expectedEventsAfterRemoval, bean.getRemovals()); + } + + @Test + void conditionalTest() { + ConditionalService bean = ctx.getBean(ConditionalService.class); + + // Get the same thing twice, ids > 5 are cached + List first = Stream.iterate(1, i -> ++i).limit(10).map(i -> bean.get(new ConditionalService.Id(i))).toList(); + List second = Stream.iterate(1, i -> ++i).limit(10).map(i -> bean.get(new ConditionalService.Id(i))).toList(); + + assertNotEquals(first.subList(0, 5), second.subList(0, 5)); + assertEquals(first.subList(5, 10), second.subList(5, 10)); + } +} diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/ConditionalService.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/ConditionalService.java new file mode 100644 index 000000000..48c587c0f --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/ConditionalService.java @@ -0,0 +1,30 @@ +package io.micronaut.cache; + +import io.micronaut.cache.annotation.CacheConfig; +import io.micronaut.cache.annotation.Cacheable; +import jakarta.inject.Singleton; + +@Singleton +@CacheConfig(cacheNames = {"conditional"}) +public class ConditionalService { + + DocRepo repository = new DocRepo(); + + // tag::conditional[] + public record Id(Integer value) { + } + + @Cacheable(condition = "#{id.value > 5}") + public String get(Id id) { + return repository.get(id); + } + // end::conditional[] + + // here to make the docs look nice when we extract the function above + private static class DocRepo { + + String get(Id id) { + return "test " + id.value + " " + System.currentTimeMillis(); + } + } +} diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CounterService.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CounterService.java new file mode 100644 index 000000000..0ca025258 --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/CounterService.java @@ -0,0 +1,44 @@ +package io.micronaut.cache; + +import io.micronaut.cache.annotation.CacheConfig; +import io.micronaut.cache.annotation.CacheInvalidate; +import io.micronaut.cache.annotation.CachePut; +import io.micronaut.cache.annotation.Cacheable; +import jakarta.inject.Singleton; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Graeme Rocher + * @since 1.0 + */ +@Singleton +@CacheConfig(cacheNames = {"counter"}) +public class CounterService { + + Map counters = new LinkedHashMap<>(); + + @CachePut + public int increment(String name) { + int value = counters.computeIfAbsent(name, s -> 0); + counters.put(name, ++value); + return value; + } + + @Cacheable + public int getValue(String name) { + return counters.computeIfAbsent(name, s -> 0); + } + + + @CacheInvalidate() + public void reset(String name) { + counters.remove(name); + } + + @CacheInvalidate + public void set(String name, int val) { + counters.put(name, val); + } +} diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/MyRemovalHandler.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/MyRemovalHandler.java new file mode 100644 index 000000000..30893ca9d --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/MyRemovalHandler.java @@ -0,0 +1,21 @@ +package io.micronaut.cache; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import jakarta.inject.Singleton; + +import java.util.ArrayList; +import java.util.List; + +@Singleton +public class MyRemovalHandler { + + private List removals = new ArrayList<>(); + + void handle(String key, Integer value, RemovalCause cause) { + removals.add(key + "|" + value + "|" + cause); + } + + public List getRemovals() { + return removals; + } +} diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/RemovalListenerImpl.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/RemovalListenerImpl.java new file mode 100644 index 000000000..d609a35d1 --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/RemovalListenerImpl.java @@ -0,0 +1,24 @@ +package io.micronaut.cache; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; +import io.micronaut.core.annotation.Nullable; +import jakarta.inject.Singleton; +import org.checkerframework.checker.nullness.qual.NonNull; + +// tag::clazz[] +@Singleton +public class RemovalListenerImpl implements RemovalListener { + + private final MyRemovalHandler handler; + + RemovalListenerImpl(MyRemovalHandler handler) { + this.handler = handler; + } + + @Override + public void onRemoval(@Nullable String key, @Nullable Integer value, @NonNull RemovalCause cause) { + handler.handle(key, value, cause); + } +} +// end::clazz[] diff --git a/test-suite-caffeine-java/src/test/java/io/micronaut/cache/SimpleController.java b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/SimpleController.java new file mode 100644 index 000000000..1819cdcbe --- /dev/null +++ b/test-suite-caffeine-java/src/test/java/io/micronaut/cache/SimpleController.java @@ -0,0 +1,32 @@ +package io.micronaut.cache; + +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Status; + +@Controller +public class SimpleController { + + private final CounterService counterService; + + public SimpleController(CounterService counterService) { + this.counterService = counterService; + } + + @Get("/inc") + public int increment() { + return counterService.increment("test"); + } + + @Get("/get") + public int get() { + return counterService.getValue("test"); + } + + @Get("/del") + @Status(HttpStatus.FOUND) + public void del() { + counterService.reset("test"); + } +} diff --git a/test-suite-caffeine-java/src/test/resources/logback.xml b/test-suite-caffeine-java/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-caffeine-java/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite-caffeine-kotlin/build.gradle.kts b/test-suite-caffeine-kotlin/build.gradle.kts new file mode 100644 index 000000000..950d019f2 --- /dev/null +++ b/test-suite-caffeine-kotlin/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.kapt") +} + +repositories { + mavenCentral() +} + +dependencies { + kaptTest(mn.micronaut.inject.java) + + testImplementation(projects.micronautCacheCaffeine) + + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.junit5) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CaffeineTest.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CaffeineTest.kt new file mode 100644 index 000000000..f1aca36eb --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CaffeineTest.kt @@ -0,0 +1,61 @@ +package io.micronaut.cache + +import io.micronaut.context.BeanContext +import io.micronaut.context.annotation.Property +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import jakarta.inject.Inject +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@MicronautTest +@Property(name = "micronaut.caches.counter.initial-capacity", value = "10") +@Property(name = "micronaut.caches.counter.test-mode", value = "true") +@Property(name = "micronaut.caches.counter.maximum-size", value = "20") +@Property(name = "micronaut.caches.counter.listen-to-removals", value = "true") +internal class CaffeineTest { + + @Inject + @field:Client("/") + lateinit var httpClient: HttpClient + + @Inject + lateinit var ctx: BeanContext + + @Test + fun simpleTest() { + val client = httpClient.toBlocking() + val bean = ctx.getBean(MyRemovalHandler::class.java) + + var inc = client.retrieve("/inc", Int::class.java) + assertEquals(1, inc) + assertTrue { bean.removals.isEmpty() } + + inc = client.retrieve("/inc", Int::class.java) + assertEquals(2, inc) + val expectedEventsAfterReplacement = listOf("test|1|REPLACED") + assertEquals(expectedEventsAfterReplacement, bean.removals) + + val get = client.retrieve("/get", Int::class.java) + assertEquals(2, get) + + client.exchange("/del") + val expectedEventsAfterRemoval = listOf("test|1|REPLACED", "test|2|EXPLICIT") + assertEquals(expectedEventsAfterRemoval, bean.removals) + } + + @Test + fun conditionalTest() { + val bean = ctx.getBean(ConditionalService::class.java) + + // Get the same thing twice, ids > 5 are cached + val first = generateSequence(1) { it + 1 }.take(10).map { bean.get(ConditionalService.Id(it)) }.toList() + val second = generateSequence(1) { it + 1 }.take(10).map { bean.get(ConditionalService.Id(it)) }.toList() + + Assertions.assertNotEquals(first.subList(0, 5), second.subList(0, 5)) + assertEquals(first.subList(5, 10), second.subList(5, 10)) + } +} diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/ConditionalService.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/ConditionalService.kt new file mode 100644 index 000000000..45f5f15ee --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/ConditionalService.kt @@ -0,0 +1,26 @@ +package io.micronaut.cache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["conditional"]) +open class ConditionalService { + + private val repository = DocRepo() + + // tag::conditional[] + data class Id(val value: Int) + + @Cacheable(condition = "#{id.value > 5}") + open fun get(id: Id) = repository[id] + // end::conditional[] + + // here to make the docs look nice when we extract the function above + private class DocRepo { + operator fun get(id: Id): String { + return "test " + id.value + " " + System.currentTimeMillis() + } + } +} diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt new file mode 100644 index 000000000..3a58bc52c --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt @@ -0,0 +1,32 @@ +package io.micronaut.cache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.CacheInvalidate +import io.micronaut.cache.annotation.CachePut +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["counter"]) +open class CounterService { + + private val counters: MutableMap = mutableMapOf() + + @CachePut + open fun increment(name: String): Int { + var value = counters.computeIfAbsent(name) { s: String? -> 0 } + counters[name] = ++value + return value + } + + @Cacheable + open fun getValue(name: String) = counters.computeIfAbsent(name) { s: String? -> 0 } + + @CacheInvalidate + open fun reset(name: String) = counters.remove(name) + + @CacheInvalidate + open operator fun set(name: String, value: Int) { + counters[name] = value + } +} diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/MyRemovalHandler.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/MyRemovalHandler.kt new file mode 100644 index 000000000..dda957643 --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/MyRemovalHandler.kt @@ -0,0 +1,14 @@ +package io.micronaut.cache + +import com.github.benmanes.caffeine.cache.RemovalCause +import jakarta.inject.Singleton + +@Singleton +class MyRemovalHandler { + + val removals: MutableList = ArrayList() + + fun handle(key: String, value: Int, cause: RemovalCause) { + removals.add("$key|$value|$cause") + } +} diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/RemovalListenerImpl.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/RemovalListenerImpl.kt new file mode 100644 index 000000000..556119661 --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/RemovalListenerImpl.kt @@ -0,0 +1,15 @@ +package io.micronaut.cache + +import com.github.benmanes.caffeine.cache.RemovalCause +import com.github.benmanes.caffeine.cache.RemovalListener +import jakarta.inject.Singleton + +// tag::clazz[] +@Singleton +class RemovalListenerImpl internal constructor(private val handler: MyRemovalHandler) : RemovalListener { + + override fun onRemoval(key: String?, value: Int?, cause: RemovalCause) { + handler.handle(key!!, value!!, cause) + } +} +// end::clazz[] diff --git a/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt new file mode 100644 index 000000000..079ac203b --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt @@ -0,0 +1,20 @@ +package io.micronaut.cache + +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Status + +@Controller +class SimpleController(private val counterService: CounterService) { + + @Get("/inc") + fun increment() = counterService.increment("test") + + @Get("/get") + fun get() = counterService.getValue("test") + + @Get("/del") + @Status(HttpStatus.FOUND) + fun del() = counterService.reset("test") +} diff --git a/test-suite-caffeine-kotlin/src/test/resources/logback.xml b/test-suite-caffeine-kotlin/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-caffeine-kotlin/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite-hazelcast-groovy/build.gradle.kts b/test-suite-hazelcast-groovy/build.gradle.kts new file mode 100644 index 000000000..5cd1db539 --- /dev/null +++ b/test-suite-hazelcast-groovy/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("groovy") +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(projects.micronautCacheHazelcast) + + testImplementation(mn.micronaut.inject.groovy) + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.spock) + testImplementation(libs.testcontainers) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() + systemProperty("hazelcastVersion", libs.versions.managed.hazelcast.get()) +} diff --git a/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy new file mode 100644 index 000000000..e385ff9a7 --- /dev/null +++ b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/CounterService.groovy @@ -0,0 +1,37 @@ +package io.micronaut.cache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.CacheInvalidate +import io.micronaut.cache.annotation.CachePut +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["counter"]) +class CounterService { + + Map counters = [:] + + @CachePut + int increment(String name) { + int value = counters.computeIfAbsent(name, s -> 0) + counters.put(name, ++value) + value + } + + @Cacheable + int getValue(String name) { + counters.computeIfAbsent(name, s -> 0) + } + + + @CacheInvalidate() + void reset(String name) { + counters.remove(name) + } + + @CacheInvalidate + void set(String name, int val) { + counters.put(name, val) + } +} diff --git a/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastAdditionalSettings.groovy b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastAdditionalSettings.groovy new file mode 100644 index 000000000..cbf80b52b --- /dev/null +++ b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastAdditionalSettings.groovy @@ -0,0 +1,21 @@ +package io.micronaut.cache + +import io.micronaut.cache.hazelcast.HazelcastClientConfiguration +import io.micronaut.context.event.BeanCreatedEvent +import io.micronaut.context.event.BeanCreatedEventListener +import io.micronaut.core.annotation.NonNull +import jakarta.inject.Singleton + +// tag::clazz[] +@Singleton +class HazelcastAdditionalSettings implements BeanCreatedEventListener { + + @Override + HazelcastClientConfiguration onCreated(@NonNull BeanCreatedEvent event) { + event.bean.tap { + // Set anything on the configuration + clusterName = "dev" + } + } +} +// end::clazz[] diff --git a/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastSpec.groovy b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastSpec.groovy new file mode 100644 index 000000000..83c3bd0ee --- /dev/null +++ b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/HazelcastSpec.groovy @@ -0,0 +1,67 @@ +package io.micronaut.cache + +import io.micronaut.cache.hazelcast.HazelcastCacheManager +import io.micronaut.http.client.BlockingHttpClient +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import io.micronaut.test.support.TestPropertyProvider +import jakarta.inject.Inject +import org.testcontainers.containers.GenericContainer +import spock.lang.AutoCleanup +import spock.lang.Shared +import spock.lang.Specification + +@MicronautTest +class HazelcastSpec extends Specification implements TestPropertyProvider { + + @AutoCleanup + @Shared + GenericContainer hazelcast = new GenericContainer<>("hazelcast/hazelcast:" + System.getProperty("hazelcastVersion")) + .withExposedPorts(5701) + + @Inject + @Client("/") + HttpClient httpClient; + + @Inject + HazelcastCacheManager cacheManager; + + void "simple test"() { + given: + BlockingHttpClient client = httpClient.toBlocking(); + + when: + Integer inc = client.retrieve("/inc", Integer.class); + + then: + inc == 1 + + when: + inc = client.retrieve("/inc", Integer.class); + + then: + inc == 2 + + when: + Integer get = client.retrieve("/get", Integer.class); + + then: + get == 2 + cacheManager.getCache("counter").get("test", Integer.class).get() == 2 + + when: + client.exchange("/del"); + + then: + cacheManager.getCache("counter").get("test", Integer.class).empty + } + + @Override + Map getProperties() { + if (!hazelcast.running) { + hazelcast.start() + } + ["hazelcast.client.network.addresses": "${hazelcast.host}:${hazelcast.firstMappedPort}"] + } +} diff --git a/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy new file mode 100644 index 000000000..421c05bf8 --- /dev/null +++ b/test-suite-hazelcast-groovy/src/test/groovy/io/micronaut/cache/SimpleController.groovy @@ -0,0 +1,32 @@ +package io.micronaut.cache + +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Status + +@Controller +class SimpleController { + + private final CounterService counterService + + SimpleController(CounterService counterService) { + this.counterService = counterService + } + + @Get("/inc") + int increment() { + counterService.increment("test") + } + + @Get("/get") + int get() { + counterService.getValue("test") + } + + @Get("/del") + @Status(HttpStatus.FOUND) + void del() { + counterService.reset("test") + } +} diff --git a/test-suite-hazelcast-groovy/src/test/resources/logback.xml b/test-suite-hazelcast-groovy/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-hazelcast-groovy/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite-hazelcast-java/build.gradle.kts b/test-suite-hazelcast-java/build.gradle.kts new file mode 100644 index 000000000..a0cdf81fa --- /dev/null +++ b/test-suite-hazelcast-java/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("java-library") +} + +repositories { + mavenCentral() +} + +dependencies { + testAnnotationProcessor(mn.micronaut.inject.java) + + testImplementation(projects.micronautCacheHazelcast) + + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.junit5) + testImplementation(libs.testcontainers.junit) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() + systemProperty("hazelcastVersion", libs.versions.managed.hazelcast.get()) +} diff --git a/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/CounterService.java b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/CounterService.java new file mode 100644 index 000000000..0ca025258 --- /dev/null +++ b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/CounterService.java @@ -0,0 +1,44 @@ +package io.micronaut.cache; + +import io.micronaut.cache.annotation.CacheConfig; +import io.micronaut.cache.annotation.CacheInvalidate; +import io.micronaut.cache.annotation.CachePut; +import io.micronaut.cache.annotation.Cacheable; +import jakarta.inject.Singleton; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @author Graeme Rocher + * @since 1.0 + */ +@Singleton +@CacheConfig(cacheNames = {"counter"}) +public class CounterService { + + Map counters = new LinkedHashMap<>(); + + @CachePut + public int increment(String name) { + int value = counters.computeIfAbsent(name, s -> 0); + counters.put(name, ++value); + return value; + } + + @Cacheable + public int getValue(String name) { + return counters.computeIfAbsent(name, s -> 0); + } + + + @CacheInvalidate() + public void reset(String name) { + counters.remove(name); + } + + @CacheInvalidate + public void set(String name, int val) { + counters.put(name, val); + } +} diff --git a/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastAdditionalSettings.java b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastAdditionalSettings.java new file mode 100644 index 000000000..3e8dff6a3 --- /dev/null +++ b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastAdditionalSettings.java @@ -0,0 +1,22 @@ +package io.micronaut.cache; + +import io.micronaut.cache.hazelcast.HazelcastClientConfiguration; +import io.micronaut.context.event.BeanCreatedEvent; +import io.micronaut.context.event.BeanCreatedEventListener; +import io.micronaut.core.annotation.NonNull; +import jakarta.inject.Singleton; + +// tag::clazz[] +@Singleton +public class HazelcastAdditionalSettings implements BeanCreatedEventListener { + + @Override + public HazelcastClientConfiguration onCreated(@NonNull BeanCreatedEvent event) { + HazelcastClientConfiguration configuration = event.getBean(); + // Set anything on the configuration + configuration.setClusterName("dev"); + + return configuration; + } +} +// end::clazz[] diff --git a/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastTest.java b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastTest.java new file mode 100644 index 000000000..e1e0f5b3f --- /dev/null +++ b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/HazelcastTest.java @@ -0,0 +1,62 @@ +package io.micronaut.cache; + +import io.micronaut.cache.hazelcast.HazelcastCacheManager; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.test.support.TestPropertyProvider; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest +@Testcontainers +class HazelcastTest implements TestPropertyProvider { + + @Container + GenericContainer hazelcast = new GenericContainer<>("hazelcast/hazelcast:" + System.getProperty("hazelcastVersion")) + .withExposedPorts(5701); + + @Inject + @Client("/") + HttpClient httpClient; + + @Inject + HazelcastCacheManager cacheManager; + + @Test + void simpleTest() { + BlockingHttpClient client = httpClient.toBlocking(); + + Integer inc = client.retrieve("/inc", Integer.class); + assertEquals(1, inc); + + inc = client.retrieve("/inc", Integer.class); + assertEquals(2, inc); + + Integer get = client.retrieve("/get", Integer.class); + assertEquals(2, get); + + assertEquals(2, cacheManager.getCache("counter").get("test", Integer.class).get()); + + client.exchange("/del"); + + assertTrue(cacheManager.getCache("counter").get("test", Integer.class).isEmpty()); + } + + @Override + public @NonNull Map getProperties() { + return Map.of( + "hazelcast.client.network.addresses", hazelcast.getHost() + ":" + hazelcast.getFirstMappedPort() + ); + } +} diff --git a/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/SimpleController.java b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/SimpleController.java new file mode 100644 index 000000000..1819cdcbe --- /dev/null +++ b/test-suite-hazelcast-java/src/test/java/io/micronaut/cache/SimpleController.java @@ -0,0 +1,32 @@ +package io.micronaut.cache; + +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Status; + +@Controller +public class SimpleController { + + private final CounterService counterService; + + public SimpleController(CounterService counterService) { + this.counterService = counterService; + } + + @Get("/inc") + public int increment() { + return counterService.increment("test"); + } + + @Get("/get") + public int get() { + return counterService.getValue("test"); + } + + @Get("/del") + @Status(HttpStatus.FOUND) + public void del() { + counterService.reset("test"); + } +} diff --git a/test-suite-hazelcast-java/src/test/resources/logback.xml b/test-suite-hazelcast-java/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-hazelcast-java/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite-hazelcast-kotlin/build.gradle.kts b/test-suite-hazelcast-kotlin/build.gradle.kts new file mode 100644 index 000000000..76c020329 --- /dev/null +++ b/test-suite-hazelcast-kotlin/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.kotlin.kapt") +} + +repositories { + mavenCentral() +} + +dependencies { + kaptTest(mn.micronaut.inject.java) + + testImplementation(projects.micronautCacheHazelcast) + + testImplementation(mn.micronaut.http.client) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnSerde.micronaut.serde.jackson) + testImplementation(mnTest.micronaut.test.junit5) + testImplementation(libs.testcontainers.junit) + + testRuntimeOnly(libs.junit.jupiter.engine) + testRuntimeOnly(mnLogging.logback.classic) +} + +tasks.withType().configureEach { + useJUnitPlatform() + systemProperty("hazelcastVersion", libs.versions.managed.hazelcast.get()) +} + +kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} diff --git a/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt new file mode 100644 index 000000000..3a58bc52c --- /dev/null +++ b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/CounterService.kt @@ -0,0 +1,32 @@ +package io.micronaut.cache + +import io.micronaut.cache.annotation.CacheConfig +import io.micronaut.cache.annotation.CacheInvalidate +import io.micronaut.cache.annotation.CachePut +import io.micronaut.cache.annotation.Cacheable +import jakarta.inject.Singleton + +@Singleton +@CacheConfig(cacheNames = ["counter"]) +open class CounterService { + + private val counters: MutableMap = mutableMapOf() + + @CachePut + open fun increment(name: String): Int { + var value = counters.computeIfAbsent(name) { s: String? -> 0 } + counters[name] = ++value + return value + } + + @Cacheable + open fun getValue(name: String) = counters.computeIfAbsent(name) { s: String? -> 0 } + + @CacheInvalidate + open fun reset(name: String) = counters.remove(name) + + @CacheInvalidate + open operator fun set(name: String, value: Int) { + counters[name] = value + } +} diff --git a/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastAdditionalSettings.kt b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastAdditionalSettings.kt new file mode 100644 index 000000000..ffca8c0e1 --- /dev/null +++ b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastAdditionalSettings.kt @@ -0,0 +1,17 @@ +package io.micronaut.cache + +import io.micronaut.cache.hazelcast.HazelcastClientConfiguration +import io.micronaut.context.event.BeanCreatedEvent +import io.micronaut.context.event.BeanCreatedEventListener +import jakarta.inject.Singleton + +// tag::clazz[] +@Singleton +class HazelcastAdditionalSettings : BeanCreatedEventListener { + + override fun onCreated(event: BeanCreatedEvent) = event.bean.apply { + // Set anything on the configuration + clusterName = "dev" + } +} +// end::clazz[] diff --git a/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastTest.kt b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastTest.kt new file mode 100644 index 000000000..ab3c8259d --- /dev/null +++ b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/HazelcastTest.kt @@ -0,0 +1,54 @@ +package io.micronaut.cache + +import io.micronaut.cache.hazelcast.HazelcastCacheManager +import io.micronaut.http.client.HttpClient +import io.micronaut.http.client.annotation.Client +import io.micronaut.test.extensions.junit5.annotation.MicronautTest +import io.micronaut.test.support.TestPropertyProvider +import jakarta.inject.Inject +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +@MicronautTest +@Testcontainers +internal class HazelcastTest : TestPropertyProvider { + + @Container + val hazelcast: GenericContainer<*> = + GenericContainer("hazelcast/hazelcast:" + System.getProperty("hazelcastVersion")) + .withExposedPorts(5701) + + @Inject + @field:Client("/") + lateinit var httpClient: HttpClient + + @Inject + lateinit var cacheManager: HazelcastCacheManager + + @Test + fun simpleTest() { + val client = httpClient.toBlocking() + + var inc = client.retrieve("/inc", Int::class.java) + assertEquals(1, inc) + + inc = client.retrieve("/inc", Int::class.java) + assertEquals(2, inc) + + val get = client.retrieve("/get", Int::class.java) + assertEquals(2, get) + assertEquals(2, cacheManager.getCache("counter").get("test", Int::class.java).get()) + + client.exchange("/del") + + Assertions.assertTrue(cacheManager.getCache("counter").get("test", Int::class.java).isEmpty) + } + + override fun getProperties() = mapOf( + "hazelcast.client.network.addresses" to "${hazelcast.host}:${hazelcast.firstMappedPort}" + ) +} diff --git a/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt new file mode 100644 index 000000000..079ac203b --- /dev/null +++ b/test-suite-hazelcast-kotlin/src/test/kotlin/io/micronaut/cache/SimpleController.kt @@ -0,0 +1,20 @@ +package io.micronaut.cache + +import io.micronaut.http.HttpStatus +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.annotation.Status + +@Controller +class SimpleController(private val counterService: CounterService) { + + @Get("/inc") + fun increment() = counterService.increment("test") + + @Get("/get") + fun get() = counterService.getValue("test") + + @Get("/del") + @Status(HttpStatus.FOUND) + fun del() = counterService.reset("test") +} diff --git a/test-suite-hazelcast-kotlin/src/test/resources/logback.xml b/test-suite-hazelcast-kotlin/src/test/resources/logback.xml new file mode 100644 index 000000000..b5f6a5144 --- /dev/null +++ b/test-suite-hazelcast-kotlin/src/test/resources/logback.xml @@ -0,0 +1,18 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file