diff --git a/.github/native-tests.json b/.github/native-tests.json index 451fb30e2cd61..7aede39fdb855 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -86,8 +86,8 @@ }, { "category": "Cache", - "timeout": 55, - "test-modules": "infinispan-cache-jpa, infinispan-client, cache", + "timeout": 60, + "test-modules": "infinispan-cache-jpa, infinispan-client, cache, redis-cache", "os-name": "ubuntu-latest" }, { diff --git a/bom/application/pom.xml b/bom/application/pom.xml index d81fa3effa617..d24a90f83ac17 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -2808,6 +2808,11 @@ quarkus-cache-deployment-spi ${project.version} + + io.quarkus + quarkus-cache-runtime-spi + ${project.version} + io.quarkus quarkus-google-cloud-functions @@ -5856,12 +5861,22 @@ quarkus-redis-client ${project.version} + + io.quarkus + quarkus-redis-cache + ${project.version} + io.quarkus quarkus-redis-client-deployment ${project.version} + + io.quarkus + quarkus-redis-cache-deployment + ${project.version} + diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 541ac67e002f5..bd040b5384fc6 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -1786,6 +1786,19 @@ + + io.quarkus + quarkus-redis-cache + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-redis-client diff --git a/docs/pom.xml b/docs/pom.xml index 2d7f10d67f1dc..38a5d15e6b12e 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1796,6 +1796,19 @@ + + io.quarkus + quarkus-redis-cache-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-redis-client-deployment diff --git a/docs/src/main/asciidoc/cache-redis-reference.adoc b/docs/src/main/asciidoc/cache-redis-reference.adoc new file mode 100644 index 0000000000000..cec715482b675 --- /dev/null +++ b/docs/src/main/asciidoc/cache-redis-reference.adoc @@ -0,0 +1,151 @@ +//// +This guide is maintained in the main Quarkus repository +and pull requests should be submitted there: +https://github.com/quarkusio/quarkus/tree/main/docs/src/main/asciidoc +//// += Redis Cache +:extension-status: preview +include::_attributes.adoc[] +:categories: data +:summary: Use Redis as the Quarkus cache backend + +By default, Quarkus Cache uses Caffeine as backend. +It's possible to use Redis instead. + +include::{includes}/extension-status.adoc[] + +== Redis as cache backend + +When using Redis as the backend for Quarkus cache, each cached item will be stored in Redis: + +- The backend uses the __ Redis client (if not configured otherwise), so make sure it's configured (or use the xref:redis-dev-services.adoc[redis dev service]) +- the Redis key is built as follows: `cache:$cache-name:$cache-key`, where `cache-key` is the key the application uses. +- the value is encoded to JSON if needed + + +== Use the Redis backend + +First, you need to add the `quarkus-redis-cache` extension to your project: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-redis-cache + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +implementation("io.quarkus:quarkus-redis-cache") +---- + +Then, use the `@CacheResult` and others cache annotations as explained in the xref:cache.adoc[Quarkus Cache guide]: + +[source, java] +---- +@GET +@Path("/{keyElement1}/{keyElement2}/{keyElement3}") +@CacheResult(cacheName = "expensiveResourceCache") +public ExpensiveResponse getExpensiveResponse(@PathParam("keyElement1") @CacheKey String keyElement1, + @PathParam("keyElement2") @CacheKey String keyElement2, @PathParam("keyElement3") @CacheKey String keyElement3, + @QueryParam("foo") String foo) { + invocations.incrementAndGet(); + ExpensiveResponse response = new ExpensiveResponse(); + response.setResult(keyElement1 + " " + keyElement2 + " " + keyElement3 + " too!"); + return response; +} + +@POST +@CacheInvalidateAll(cacheName = "expensiveResourceCache") +public void invalidateAll() { + +} +---- + +[[redis-cache-configuration-reference]] +== Configure the Redis backend + +The Redis backend uses the `` Redis client. +See the xref:redis-reference.adoc[Redis reference] to configure the access to Redis. + +TIP: In dev mode, you can use the xref:redis-dev-services.adoc[Redis Dev Service]. + +If you want to use another Redis for your cache, configure the `client-name` as follows: + +[source, properties] +---- +quarkus.cache.redis.client-name=my-redis-for-cache +---- + +When writing to Redis or reading from Redis, Quarkus needs to know the type. +Indeed, the objects need to be serialized and deserialized. +For that purpose, you may need to configure type (class names) of the key and value you want to cache. +At build time, Quarkus tries to deduce the types from the application code, but that decision can be overridden using: + +[source, properties] +---- +# Default configuration +quarkus.cache.redis.key-type=java.lang.String +quarkus.cache.redis.value-type=org.acme.Person + +# Configuration for `expensiveResourceCache` +quarkus.cache.redis.expensiveResourceCache.key-type=java.lang.String +quarkus.cache.redis.expensiveResourceCache.value-type=org.acme.Supes +---- + +You can also configure the time to live of the cached entries: + +[source, properties] +---- +# Default configuration +quarkus.cache.redis.ttl=10s + +# Configuration for `expensiveResourceCache` +quarkus.cache.redis.expensiveResourceCache.ttl=1h +---- + +If the `ttl` is not configured, the entry won't be evicted. +You would need to invalidate the values using the `@CacheInvalidateAll` or `@CacheInvalidate` annotations. + +The following table lists the supported properties: + +include::{generated-dir}/config/quarkus-cache-redis.adoc[opts=optional, leveloffset=+1] + +== Configure the Redis key + +By default, the Redis backend stores the entry using the following keys: `cache:$cache-name:$cache-key`, where `cache-key` is the key the application uses. +So, you can find all the entries for a single cache using the Redis `KEYS` command: `KEYS cache:$cache-name:*` + +The `cache:$cache-name:` segment can be configured using the `prefix` property: + + +[source, properties] +---- +# Default configuration +quarkus.cache.redis.prefix=my-cache + +# Configuration for `expensiveResourceCache` +quarkus.cache.redis.expensiveResourceCache.prefix=my-expensive-cache +---- + +In these cases, you can find all the keys managed by the default cache using `KEYS my-cache:*`, and all the keys managed by the `expensiveResourceCache` cache using: `KEYS my-expensive-cache:*`. + +== Enable optimistic locking + +The access to the cache can be _direct_ or use https://redis.io/docs/manual/transactions/#optimistic-locking-using-check-and-set[optimistic locking]. +By default, optimistic locking is disabled. + +You can enable optimistic locking using: +[source, properties] +---- +# Default configuration +quarkus.cache.redis.use-optimistic-locking=true + +# Configuration for `expensiveResourceCache` +quarkus.cache.redis.expensiveResourceCache.use-optimistic-locking=true +---- + +When used, the key is _watched_ and the _SET_ command is executed in a transaction (`MULTI/EXEC`). diff --git a/docs/src/main/asciidoc/cache.adoc b/docs/src/main/asciidoc/cache.adoc index 006cd16d9f268..aa8fbc85f2cbf 100644 --- a/docs/src/main/asciidoc/cache.adoc +++ b/docs/src/main/asciidoc/cache.adoc @@ -25,6 +25,13 @@ Since the weather forecast is updated once every twelve hours, caching the servi We'll do that using a single Quarkus annotation. +[NOTE] +==== +In this guide, we use the default Quarkus Cache backend (Caffeine). +You can use Redis instead. +Refer to the xref:cache-redis-reference.adoc[Redis cache backend reference] to configure the Redis backend. +==== + == Solution We recommend that you follow the instructions in the next sections and create the application step by step. @@ -736,7 +743,7 @@ properties in the `application.properties` file. By default, caches do not perfo You need to replace `cache-name` in all the following properties with the real name of the cache you want to configure. ==== -include::{generated-dir}/config/quarkus-cache-config-group-cache-config-caffeine-config.adoc[opts=optional, leveloffset=+1] +include::{generated-dir}/config/quarkus-cache-cache-config.adoc[opts=optional, leveloffset=+1] Here's what your cache configuration could look like: diff --git a/docs/src/main/asciidoc/redis-reference.adoc b/docs/src/main/asciidoc/redis-reference.adoc index 83c29b9f20219..a9e3d1e882229 100644 --- a/docs/src/main/asciidoc/redis-reference.adoc +++ b/docs/src/main/asciidoc/redis-reference.adoc @@ -55,6 +55,9 @@ import io.quarkus.redis.datasource.RedisDataSource; More details about the various APIs offered by the quarkus-redis extension are available in the <> section. +[NOTE] +To use Redis as a cache backend, refer to the xref:cache-redis-reference.adoc[Redis Cache Backend reference]. + [[apis]] == One extension, multiple APIs diff --git a/extensions/cache/deployment-spi/pom.xml b/extensions/cache/deployment-spi/pom.xml index 1cfd1d01e99c2..9e5bda717185f 100644 --- a/extensions/cache/deployment-spi/pom.xml +++ b/extensions/cache/deployment-spi/pom.xml @@ -18,6 +18,10 @@ io.quarkus quarkus-core-deployment + + io.quarkus + quarkus-cache-runtime-spi + diff --git a/extensions/cache/deployment-spi/src/main/java/io/quarkus/cache/deployment/spi/CacheManagerInfoBuildItem.java b/extensions/cache/deployment-spi/src/main/java/io/quarkus/cache/deployment/spi/CacheManagerInfoBuildItem.java new file mode 100644 index 0000000000000..08863e998b912 --- /dev/null +++ b/extensions/cache/deployment-spi/src/main/java/io/quarkus/cache/deployment/spi/CacheManagerInfoBuildItem.java @@ -0,0 +1,20 @@ +package io.quarkus.cache.deployment.spi; + +import io.quarkus.builder.item.MultiBuildItem; +import io.quarkus.cache.CacheManagerInfo; + +/** + * A build item that makes sure a {@link CacheManagerInfo} is available at runtime for consideration as the cache backend + */ +public final class CacheManagerInfoBuildItem extends MultiBuildItem { + + private final CacheManagerInfo info; + + public CacheManagerInfoBuildItem(CacheManagerInfo info) { + this.info = info; + } + + public CacheManagerInfo get() { + return info; + } +} diff --git a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java index ed97db3e07d74..020f15d8b5130 100644 --- a/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java +++ b/extensions/cache/deployment/src/main/java/io/quarkus/cache/deployment/CacheProcessor.java @@ -53,6 +53,7 @@ import io.quarkus.cache.deployment.exception.UnsupportedRepeatedAnnotationException; import io.quarkus.cache.deployment.exception.VoidReturnTypeTargetException; import io.quarkus.cache.deployment.spi.AdditionalCacheNameBuildItem; +import io.quarkus.cache.deployment.spi.CacheManagerInfoBuildItem; import io.quarkus.cache.runtime.CacheInvalidateAllInterceptor; import io.quarkus.cache.runtime.CacheInvalidateInterceptor; import io.quarkus.cache.runtime.CacheManagerRecorder; @@ -238,17 +239,26 @@ private List validateKeyGeneratorsDefaultConstructor(CombinedIndexBui @BuildStep @Record(RUNTIME_INIT) - SyntheticBeanBuildItem configureCacheManagerSyntheticBean(CacheNamesBuildItem cacheNames, - CacheManagerRecorder cacheManagerRecorder, Optional metricsCapability) { + void cacheManagerInfos(BuildProducer producer, + Optional metricsCapability, CacheManagerRecorder recorder) { + producer.produce(new CacheManagerInfoBuildItem(recorder.noOpCacheManagerInfo())); + producer.produce(new CacheManagerInfoBuildItem(recorder.getCacheManagerInfoWithoutMetrics())); + if (metricsCapability.isPresent() && metricsCapability.get().metricsSupported(MICROMETER)) { + // if we include this unconditionally the native image building will fail when Micrometer is not around + producer.produce(new CacheManagerInfoBuildItem(recorder.getCacheManagerInfoWithMicrometerMetrics())); + } + } - boolean micrometerSupported = metricsCapability.isPresent() && metricsCapability.get().metricsSupported(MICROMETER); + @BuildStep + @Record(RUNTIME_INIT) + SyntheticBeanBuildItem configureCacheManagerSyntheticBean(List infos, + CacheNamesBuildItem cacheNames, Optional metricsCapability, + CacheManagerRecorder cacheManagerRecorder) { - Supplier cacheManagerSupplier; - if (micrometerSupported) { - cacheManagerSupplier = cacheManagerRecorder.getCacheManagerSupplierWithMicrometerMetrics(cacheNames.getNames()); - } else { - cacheManagerSupplier = cacheManagerRecorder.getCacheManagerSupplierWithoutMetrics(cacheNames.getNames()); - } + boolean micrometerSupported = metricsCapability.isPresent() && metricsCapability.get().metricsSupported(MICROMETER); + Supplier cacheManagerSupplier = cacheManagerRecorder.resolveCacheInfo( + infos.stream().map(CacheManagerInfoBuildItem::get).collect(toList()), cacheNames.getNames(), + micrometerSupported); return SyntheticBeanBuildItem.configure(CacheManager.class) .scope(ApplicationScoped.class) diff --git a/extensions/cache/pom.xml b/extensions/cache/pom.xml index 092198414e79c..a7f580a571601 100644 --- a/extensions/cache/pom.xml +++ b/extensions/cache/pom.xml @@ -19,5 +19,6 @@ deployment deployment-spi runtime + runtime-spi diff --git a/extensions/cache/runtime-spi/pom.xml b/extensions/cache/runtime-spi/pom.xml new file mode 100644 index 0000000000000..19619b5e24a5f --- /dev/null +++ b/extensions/cache/runtime-spi/pom.xml @@ -0,0 +1,40 @@ + + + + io.quarkus + quarkus-cache-parent + 999-SNAPSHOT + + 4.0.0 + + quarkus-cache-runtime-spi + Quarkus - Cache - Runtime SPI + + + + io.smallrye.reactive + mutiny + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/Cache.java b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/Cache.java similarity index 89% rename from extensions/cache/runtime/src/main/java/io/quarkus/cache/Cache.java rename to extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/Cache.java index a7d7b3e9b2637..baba8744869a7 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/Cache.java +++ b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/Cache.java @@ -7,7 +7,7 @@ /** * Use this interface to interact with a cache programmatically e.g. store, retrieve or delete cache values. The cache can be - * injected using the {@link CacheName} annotation or retrieved using {@link CacheManager#getCache(String)}. + * injected using the {@code @io.quarkus.cache.CacheName} annotation or retrieved using {@link CacheManager#getCache(String)}. */ public interface Cache { @@ -20,7 +20,8 @@ public interface Cache { /** * Returns the unique and immutable default key for the current cache. This key is used by the annotations caching API when - * a no-args method annotated with {@link CacheResult} or {@link CacheInvalidate} is invoked. It can also be used with the + * a no-args method annotated with {@code @io.quarkus.cache.CacheResult} or {@code @io.quarkus.cache.CacheInvalidate} is + * invoked. It can also be used with the * programmatic caching API. * * @return default cache key diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheManager.java b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManager.java similarity index 91% rename from extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheManager.java rename to extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManager.java index ffedd8ad288e1..c62628ca1e089 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CacheManager.java +++ b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManager.java @@ -7,7 +7,7 @@ *

* Use this interface to retrieve all existing {@link Cache} names and interact with any cache programmatically e.g. store, * retrieve or delete cache values. It shares the same caches collection the Quarkus caching annotations use. The - * {@link CacheName} annotation can also be used to inject and access a specific cache from its name. + * {@code @io.quarkus.cache.CacheName} annotation can also be used to inject and access a specific cache from its name. *

*

* Code example: diff --git a/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManagerInfo.java b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManagerInfo.java new file mode 100644 index 0000000000000..cd46de0549184 --- /dev/null +++ b/extensions/cache/runtime-spi/src/main/java/io/quarkus/cache/CacheManagerInfo.java @@ -0,0 +1,27 @@ +package io.quarkus.cache; + +import java.util.Set; +import java.util.function.Supplier; + +public interface CacheManagerInfo { + + boolean supports(Context context); + + Supplier get(Context context); + + interface Context { + + boolean cacheEnabled(); + + Metrics metrics(); + + String cacheType(); + + Set cacheNames(); + + enum Metrics { + NONE, + MICROMETER + } + } +} diff --git a/extensions/cache/runtime/pom.xml b/extensions/cache/runtime/pom.xml index c87ec82e4e0ef..70993637b709a 100644 --- a/extensions/cache/runtime/pom.xml +++ b/extensions/cache/runtime/pom.xml @@ -27,6 +27,10 @@ io.quarkus quarkus-mutiny + + io.quarkus + quarkus-cache-runtime-spi + io.vertx vertx-web diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CompositeCacheKey.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CompositeCacheKey.java index 2803b832e5cc7..d06929756ac3a 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/CompositeCacheKey.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/CompositeCacheKey.java @@ -46,4 +46,8 @@ public boolean equals(Object obj) { public String toString() { return "CompositeCacheKey" + Arrays.toString(keyElements); } + + public Object[] getKeyElements() { + return keyElements; + } } diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheBuildConfig.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheBuildConfig.java new file mode 100644 index 0000000000000..be01933b30a96 --- /dev/null +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheBuildConfig.java @@ -0,0 +1,20 @@ +package io.quarkus.cache.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.BUILD_AND_RUN_TIME_FIXED; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigRoot(phase = BUILD_AND_RUN_TIME_FIXED) +@ConfigMapping(prefix = "quarkus.cache") +public interface CacheBuildConfig { + + String CAFFEINE_CACHE_TYPE = "caffeine"; + + /** + * Cache type. + */ + @WithDefault(CAFFEINE_CACHE_TYPE) + String type(); +} diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheConfig.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheConfig.java index 56d46a8870da1..0ac4f0bad8331 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheConfig.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheConfig.java @@ -10,60 +10,50 @@ import io.quarkus.runtime.annotations.ConfigDocMapKey; import io.quarkus.runtime.annotations.ConfigDocSection; -import io.quarkus.runtime.annotations.ConfigGroup; -import io.quarkus.runtime.annotations.ConfigItem; import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; +import io.smallrye.config.WithParentName; @ConfigRoot(phase = RUN_TIME) -public class CacheConfig { - - public static final String CAFFEINE_CACHE_TYPE = "caffeine"; +@ConfigMapping(prefix = "quarkus.cache") +public interface CacheConfig { /** * Whether or not the cache extension is enabled. */ - @ConfigItem(defaultValue = "true") - public boolean enabled; - - /** - * Cache type. - */ - @ConfigItem(defaultValue = CAFFEINE_CACHE_TYPE) - public String type; + @WithDefault("true") + boolean enabled(); /** * Caffeine configuration. */ - @ConfigItem - public CaffeineConfig caffeine; + CaffeineConfig caffeine(); - @ConfigGroup - public static class CaffeineConfig { + interface CaffeineConfig { /** * Default configuration applied to all Caffeine caches (lowest precedence) */ - @ConfigItem(name = ConfigItem.PARENT) + @WithParentName @ConfigDocSection - public CaffeineCacheConfig defaultConfig; + CaffeineCacheConfig defaultConfig(); /** * Additional configuration applied to a specific Caffeine cache (highest precedence) */ - @ConfigItem(name = ConfigItem.PARENT) + @WithParentName @ConfigDocMapKey("cache-name") @ConfigDocSection - public Map cachesConfig; + Map cachesConfig(); - @ConfigGroup - public static class CaffeineCacheConfig { + interface CaffeineCacheConfig { /** * Minimum total size for the internal data structures. Providing a large enough estimate at construction time * avoids the need for expensive resizing operations later, but setting this value unnecessarily high wastes memory. */ - @ConfigItem - public OptionalInt initialCapacity; + OptionalInt initialCapacity(); /** * Maximum number of entries the cache may contain. Note that the cache may evict an entry before this limit is @@ -71,29 +61,25 @@ public static class CaffeineCacheConfig { * the cache evicts entries that are less likely to be used again. For example, the cache may evict an entry because * it hasn't been used recently or very often. */ - @ConfigItem - public OptionalLong maximumSize; + OptionalLong maximumSize(); /** * Specifies that each entry should be automatically removed from the cache once a fixed duration has elapsed after * the entry's creation, or the most recent replacement of its value. */ - @ConfigItem - public Optional expireAfterWrite; + Optional expireAfterWrite(); /** * Specifies that each entry should be automatically removed from the cache once a fixed duration has elapsed after * the entry's creation, the most recent replacement of its value, or its last read. */ - @ConfigItem - public Optional expireAfterAccess; + Optional expireAfterAccess(); /** * Whether or not metrics are recorded if the application depends on the Micrometer extension. Setting this * value to {@code true} will enable the accumulation of cache stats inside Caffeine. */ - @ConfigItem - public Optional metricsEnabled; + Optional metricsEnabled(); } } } diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheManagerRecorder.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheManagerRecorder.java index 8350ca55c6b28..2e2621edc114f 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheManagerRecorder.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/CacheManagerRecorder.java @@ -1,57 +1,105 @@ package io.quarkus.cache.runtime; -import static io.quarkus.cache.runtime.CacheConfig.CAFFEINE_CACHE_TYPE; +import static io.quarkus.cache.runtime.CacheBuildConfig.CAFFEINE_CACHE_TYPE; +import java.util.Collection; import java.util.Set; import java.util.function.Supplier; import jakarta.enterprise.inject.spi.DeploymentException; import io.quarkus.cache.CacheManager; +import io.quarkus.cache.CacheManagerInfo; import io.quarkus.cache.runtime.caffeine.CaffeineCacheManagerBuilder; import io.quarkus.cache.runtime.noop.NoOpCacheManagerBuilder; +import io.quarkus.runtime.RuntimeValue; import io.quarkus.runtime.annotations.Recorder; @Recorder public class CacheManagerRecorder { - private final CacheConfig cacheConfig; + private final CacheBuildConfig cacheBuildConfig; + private final RuntimeValue cacheConfigRV; - public CacheManagerRecorder(CacheConfig cacheConfig) { - this.cacheConfig = cacheConfig; + public CacheManagerRecorder(CacheBuildConfig cacheBuildConfig, RuntimeValue cacheConfigRV) { + this.cacheBuildConfig = cacheBuildConfig; + this.cacheConfigRV = cacheConfigRV; } - public Supplier getCacheManagerSupplierWithMicrometerMetrics(Set cacheNames) { - Supplier> caffeineCacheManagerSupplier = new Supplier>() { + public Supplier resolveCacheInfo(Collection infos, Set cacheNames, + boolean micrometerMetricsEnabled) { + CacheConfig cacheConfig = cacheConfigRV.getValue(); + CacheManagerInfo.Context context = new CacheManagerInfo.Context() { @Override - public Supplier get() { - return CaffeineCacheManagerBuilder.buildWithMicrometerMetrics(cacheNames, cacheConfig); + public boolean cacheEnabled() { + return cacheConfig.enabled(); + } + + @Override + public Metrics metrics() { + return micrometerMetricsEnabled ? Metrics.MICROMETER : Metrics.NONE; + } + + @Override + public String cacheType() { + return cacheBuildConfig.type(); + } + + @Override + public Set cacheNames() { + return cacheNames; } }; - return getCacheManagerSupplier(cacheNames, caffeineCacheManagerSupplier); + for (CacheManagerInfo info : infos) { + if (info.supports(context)) { + return info.get(context); + } + } + throw new DeploymentException("Unknown cache type: " + context.cacheType()); } - public Supplier getCacheManagerSupplierWithoutMetrics(Set cacheNames) { - Supplier> caffeineCacheManagerSupplier = new Supplier>() { + public CacheManagerInfo noOpCacheManagerInfo() { + return new CacheManagerInfo() { @Override - public Supplier get() { - return CaffeineCacheManagerBuilder.buildWithoutMetrics(cacheNames, cacheConfig); + public boolean supports(Context context) { + return !context.cacheEnabled(); + } + + @Override + public Supplier get(Context context) { + return NoOpCacheManagerBuilder.build(context.cacheNames()); } }; - return getCacheManagerSupplier(cacheNames, caffeineCacheManagerSupplier); } - private Supplier getCacheManagerSupplier(Set cacheNames, - Supplier> caffeineCacheManagerSupplier) { - if (cacheConfig.enabled) { - switch (cacheConfig.type) { - case CAFFEINE_CACHE_TYPE: - return caffeineCacheManagerSupplier.get(); - default: - throw new DeploymentException("Unknown cache type: " + cacheConfig.type); - } - } else { - return NoOpCacheManagerBuilder.build(cacheNames); - } + public CacheManagerInfo getCacheManagerInfoWithMicrometerMetrics() { + return new CacheManagerInfo() { + @Override + public boolean supports(Context context) { + return context.cacheEnabled() && context.cacheType().equals(CAFFEINE_CACHE_TYPE) + && (context.metrics() == Context.Metrics.MICROMETER); + } + + @Override + public Supplier get(Context context) { + return CaffeineCacheManagerBuilder.buildWithMicrometerMetrics(context.cacheNames(), cacheConfigRV.getValue()); + } + }; } + + public CacheManagerInfo getCacheManagerInfoWithoutMetrics() { + return new CacheManagerInfo() { + @Override + public boolean supports(Context context) { + return context.cacheEnabled() && context.cacheType().equals(CAFFEINE_CACHE_TYPE) + && (context.metrics() == Context.Metrics.NONE); + } + + @Override + public Supplier get(Context context) { + return CaffeineCacheManagerBuilder.buildWithoutMetrics(context.cacheNames(), cacheConfigRV.getValue()); + } + }; + } + } diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheImpl.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheImpl.java index 80665cfcad6cd..c8baff5992b7a 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheImpl.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheImpl.java @@ -28,8 +28,9 @@ import io.smallrye.mutiny.Uni; /** - * This class is an internal Quarkus cache implementation. Do not use it explicitly from your Quarkus application. The public - * methods signatures may change without prior notice. + * This class is an internal Quarkus cache implementation using Caffeine. Do not use it explicitly from your Quarkus + * application. + * The public methods signatures may change without prior notice. */ public class CaffeineCacheImpl extends AbstractCache implements CaffeineCache { diff --git a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheInfoBuilder.java b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheInfoBuilder.java index e324300052acb..c5af6931e29d3 100644 --- a/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheInfoBuilder.java +++ b/extensions/cache/runtime/src/main/java/io/quarkus/cache/runtime/caffeine/CaffeineCacheInfoBuilder.java @@ -14,7 +14,7 @@ public static Set build(Set cacheNames, CacheConfig c if (cacheNames.isEmpty()) { return Collections.emptySet(); } else { - CaffeineCacheConfig defaultConfig = cacheConfig.caffeine.defaultConfig; + CaffeineCacheConfig defaultConfig = cacheConfig.caffeine().defaultConfig(); Set cacheInfos = HashSetFactory. getInstance().apply(cacheNames.size()); for (String cacheName : cacheNames) { @@ -22,36 +22,36 @@ public static Set build(Set cacheNames, CacheConfig c CaffeineCacheInfo cacheInfo = new CaffeineCacheInfo(); cacheInfo.name = cacheName; - CaffeineCacheConfig namedCacheConfig = cacheConfig.caffeine.cachesConfig.get(cacheInfo.name); + CaffeineCacheConfig namedCacheConfig = cacheConfig.caffeine().cachesConfig().get(cacheInfo.name); - if (namedCacheConfig != null && namedCacheConfig.initialCapacity.isPresent()) { - cacheInfo.initialCapacity = namedCacheConfig.initialCapacity.getAsInt(); - } else if (defaultConfig.initialCapacity.isPresent()) { - cacheInfo.initialCapacity = defaultConfig.initialCapacity.getAsInt(); + if (namedCacheConfig != null && namedCacheConfig.initialCapacity().isPresent()) { + cacheInfo.initialCapacity = namedCacheConfig.initialCapacity().getAsInt(); + } else if (defaultConfig.initialCapacity().isPresent()) { + cacheInfo.initialCapacity = defaultConfig.initialCapacity().getAsInt(); } - if (namedCacheConfig != null && namedCacheConfig.maximumSize.isPresent()) { - cacheInfo.maximumSize = namedCacheConfig.maximumSize.getAsLong(); - } else if (defaultConfig.maximumSize.isPresent()) { - cacheInfo.maximumSize = defaultConfig.maximumSize.getAsLong(); + if (namedCacheConfig != null && namedCacheConfig.maximumSize().isPresent()) { + cacheInfo.maximumSize = namedCacheConfig.maximumSize().getAsLong(); + } else if (defaultConfig.maximumSize().isPresent()) { + cacheInfo.maximumSize = defaultConfig.maximumSize().getAsLong(); } - if (namedCacheConfig != null && namedCacheConfig.expireAfterWrite.isPresent()) { - cacheInfo.expireAfterWrite = namedCacheConfig.expireAfterWrite.get(); - } else if (defaultConfig.expireAfterWrite.isPresent()) { - cacheInfo.expireAfterWrite = defaultConfig.expireAfterWrite.get(); + if (namedCacheConfig != null && namedCacheConfig.expireAfterWrite().isPresent()) { + cacheInfo.expireAfterWrite = namedCacheConfig.expireAfterWrite().get(); + } else if (defaultConfig.expireAfterWrite().isPresent()) { + cacheInfo.expireAfterWrite = defaultConfig.expireAfterWrite().get(); } - if (namedCacheConfig != null && namedCacheConfig.expireAfterAccess.isPresent()) { - cacheInfo.expireAfterAccess = namedCacheConfig.expireAfterAccess.get(); - } else if (defaultConfig.expireAfterAccess.isPresent()) { - cacheInfo.expireAfterAccess = defaultConfig.expireAfterAccess.get(); + if (namedCacheConfig != null && namedCacheConfig.expireAfterAccess().isPresent()) { + cacheInfo.expireAfterAccess = namedCacheConfig.expireAfterAccess().get(); + } else if (defaultConfig.expireAfterAccess().isPresent()) { + cacheInfo.expireAfterAccess = defaultConfig.expireAfterAccess().get(); } - if (namedCacheConfig != null && namedCacheConfig.metricsEnabled.isPresent()) { - cacheInfo.metricsEnabled = namedCacheConfig.metricsEnabled.get(); - } else if (defaultConfig.metricsEnabled.isPresent()) { - cacheInfo.metricsEnabled = defaultConfig.metricsEnabled.get(); + if (namedCacheConfig != null && namedCacheConfig.metricsEnabled().isPresent()) { + cacheInfo.metricsEnabled = namedCacheConfig.metricsEnabled().get(); + } else if (defaultConfig.metricsEnabled().isPresent()) { + cacheInfo.metricsEnabled = defaultConfig.metricsEnabled().get(); } cacheInfos.add(cacheInfo); diff --git a/extensions/pom.xml b/extensions/pom.xml index e443cd5dffe96..b2ea1473f0fa3 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -79,6 +79,7 @@ mailer grpc redis-client + redis-cache transaction-annotations diff --git a/extensions/redis-cache/deployment/pom.xml b/extensions/redis-cache/deployment/pom.xml new file mode 100644 index 0000000000000..078f0184d1cf2 --- /dev/null +++ b/extensions/redis-cache/deployment/pom.xml @@ -0,0 +1,111 @@ + + + 4.0.0 + + + io.quarkus + quarkus-redis-cache-parent + 999-SNAPSHOT + + + quarkus-redis-cache-deployment + + Quarkus - Redis Cache - Deployment + + + + io.quarkus + quarkus-redis-client-deployment + + + io.quarkus + quarkus-cache-deployment + + + io.quarkus + quarkus-redis-cache + + + + org.testcontainers + testcontainers + + + junit + junit + + + + + io.quarkus + quarkus-junit4-mock + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + maven-surefire-plugin + + true + + + + + + + + test-redis + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + diff --git a/extensions/redis-cache/deployment/src/main/java/io/quarkus/cache/redis/deployment/RedisCacheProcessor.java b/extensions/redis-cache/deployment/src/main/java/io/quarkus/cache/redis/deployment/RedisCacheProcessor.java new file mode 100644 index 0000000000000..ebbce3ad96db5 --- /dev/null +++ b/extensions/redis-cache/deployment/src/main/java/io/quarkus/cache/redis/deployment/RedisCacheProcessor.java @@ -0,0 +1,179 @@ +package io.quarkus.cache.redis.deployment; + +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; +import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; +import static org.jboss.jandex.AnnotationTarget.Kind.METHOD; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import jakarta.enterprise.inject.spi.DeploymentException; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.Type; +import org.jboss.logging.Logger; +import org.jetbrains.annotations.NotNull; + +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.cache.CompositeCacheKey; +import io.quarkus.cache.deployment.CacheDeploymentConstants; +import io.quarkus.cache.deployment.CacheNamesBuildItem; +import io.quarkus.cache.deployment.spi.CacheManagerInfoBuildItem; +import io.quarkus.cache.redis.runtime.RedisCacheBuildRecorder; +import io.quarkus.cache.redis.runtime.RedisCacheBuildTimeConfig; +import io.quarkus.cache.redis.runtime.RedisCachesBuildTimeConfig; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.RunTimeConfigurationDefaultBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.redis.client.deployment.RequestedRedisClientBuildItem; +import io.quarkus.redis.runtime.client.config.RedisConfig; +import io.smallrye.mutiny.Uni; + +public class RedisCacheProcessor { + + private static final Logger LOGGER = Logger.getLogger(RedisCacheProcessor.class); + + public static final DotName UNI = DotName.createSimple(Uni.class.getName()); + + @BuildStep + @Record(RUNTIME_INIT) + CacheManagerInfoBuildItem cacheManagerInfo(RedisCacheBuildRecorder recorder) { + return new CacheManagerInfoBuildItem(recorder.getCacheManagerSupplier()); + } + + @BuildStep + UnremovableBeanBuildItem redisClientUnremoveable() { + return UnremovableBeanBuildItem.beanTypes(io.vertx.redis.client.Redis.class, io.vertx.mutiny.redis.client.Redis.class); + } + + @BuildStep + RequestedRedisClientBuildItem requestedRedisClientBuildItem(RedisCachesBuildTimeConfig buildConfig) { + return new RequestedRedisClientBuildItem(buildConfig.clientName.orElse(RedisConfig.DEFAULT_CLIENT_NAME)); + } + + @BuildStep + RunTimeConfigurationDefaultBuildItem redisCacheByDefault() { + return new RunTimeConfigurationDefaultBuildItem("quarkus.cache.type", "redis"); + } + + @BuildStep + void nativeImage(BuildProducer producer) { + producer.produce(ReflectiveClassBuildItem.builder(CompositeCacheKey.class).methods(true).build()); + } + + @BuildStep + @Record(STATIC_INIT) + void determineValueTypes(RedisCacheBuildRecorder recorder, CombinedIndexBuildItem combinedIndex, + CacheNamesBuildItem cacheNamesBuildItem, RedisCachesBuildTimeConfig buildConfig) { + Map resolvedValuesTypesFromAnnotations = valueTypesFromCacheResultAnnotation(combinedIndex); + + Map valueTypes = new HashMap<>(); + Optional defaultValueType = buildConfig.defaultConfig.valueType; + Set cacheNames = cacheNamesBuildItem.getNames(); + for (String cacheName : cacheNames) { + String valueType = null; + RedisCacheBuildTimeConfig cacheSpecificGroup = buildConfig.cachesConfig.get(cacheName); + if (cacheSpecificGroup == null) { + if (defaultValueType.isPresent()) { + valueType = defaultValueType.get(); + } + } else { + if (cacheSpecificGroup.valueType.isPresent()) { + valueType = cacheSpecificGroup.valueType.get(); + } + } + + if (valueType == null) { // TODO: does it make sense to use the return type of method annotated with @CacheResult as the last resort or should it override the default cache config? + valueType = resolvedValuesTypesFromAnnotations.get(cacheName); + } + + if (valueType != null) { + valueTypes.put(cacheName, valueType); + } else { + throw new DeploymentException("Unable to determine the value type for '" + cacheName + + "' Redis cache. An appropriate configuration value for 'quarkus.cache.redis." + cacheName + + ".value-type' needs to be set"); + } + } + recorder.setCacheValueTypes(valueTypes); + } + + @NotNull + private static Map valueTypesFromCacheResultAnnotation(CombinedIndexBuildItem combinedIndex) { + Map> valueTypesFromAnnotations = new HashMap<>(); + + // first go through @CacheResult instances and simply record the return types + for (AnnotationInstance instance : combinedIndex.getIndex().getAnnotations(CacheDeploymentConstants.CACHE_RESULT)) { + if (instance.target().kind() != METHOD) { + continue; + } + Type methodReturnType = instance.target().asMethod().returnType(); + if (methodReturnType.kind() == Type.Kind.VOID) { + continue; + } + AnnotationValue cacheNameValue = instance.value("cacheName"); + if (cacheNameValue == null) { + continue; + } + String cacheName = cacheNameValue.asString(); + Set types = valueTypesFromAnnotations.get(cacheName); + if (types == null) { + types = new HashSet<>(1); + valueTypesFromAnnotations.put(cacheName, types); + } + types.add(methodReturnType); + } + + if (valueTypesFromAnnotations.isEmpty()) { + return Collections.emptyMap(); + } + + Map result = new HashMap<>(); + + // now apply our resolution logic on the obtained types + for (var entry : valueTypesFromAnnotations.entrySet()) { + String cacheName = entry.getKey(); + Set typeSet = entry.getValue(); + if (typeSet.size() != 1) { + LOGGER.debugv("Cache named '{0}' is used on methods with different result types", cacheName); + // TODO: when there are multiple types for the same @CacheResult, should we fail? Should we try and be smarter in determining the type? + continue; + } + + Type type = typeSet.iterator().next(); + String resolvedType = null; + if (type.kind() == Type.Kind.CLASS) { + resolvedType = type.asClassType().name().toString(); + } else if (type.kind() == Type.Kind.PRIMITIVE) { + resolvedType = type.asPrimitiveType().name().toString(); + } else if ((type.kind() == Type.Kind.PARAMETERIZED_TYPE) && UNI.equals(type.name())) { + ParameterizedType parameterizedType = type.asParameterizedType(); + List arguments = parameterizedType.arguments(); + if (arguments.size() == 1) { + resolvedType = arguments.get(0).name().toString(); + } + } + + if (resolvedType != null) { + result.put(cacheName, resolvedType); + } else { + LOGGER.debugv( + "Cache named '{0}' is used on method whose return type '{1}' is not eligible for automatic resolution", + cacheName, type); + } + } + + return result; + } +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/BasicRedisCacheTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/BasicRedisCacheTest.java new file mode 100644 index 0000000000000..67a08643f0683 --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/BasicRedisCacheTest.java @@ -0,0 +1,137 @@ +package io.quarkus.cache.redis.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Optional; + +import jakarta.inject.Inject; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheManager; +import io.quarkus.cache.redis.runtime.RedisCache; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.QuarkusUnitTest; + +public class BasicRedisCacheTest { + + private static final String KEY_1 = "1"; + private static final String KEY_2 = "2"; + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(SimpleCachedService.class, TestUtil.class)); + + @Inject + SimpleCachedService simpleCachedService; + + @Test + public void testTypes() { + CacheManager cacheManager = Arc.container().select(CacheManager.class).get(); + assertNotNull(cacheManager); + + Optional cacheOpt = cacheManager.getCache(SimpleCachedService.CACHE_NAME); + assertTrue(cacheOpt.isPresent()); + + Cache cache = cacheOpt.get(); + assertTrue(cache instanceof RedisCache); + } + + @Test + public void testAllCacheAnnotations() { + RedisDataSource redisDataSource = Arc.container().select(RedisDataSource.class).get(); + List allKeysAtStart = TestUtil.allRedisKeys(redisDataSource); + + // STEP 1 + // Action: @CacheResult-annotated method call. + // Expected effect: method invoked and result cached. + // Verified by: STEP 2. + String value1 = simpleCachedService.cachedMethod(KEY_1); + List newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 1, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCacheKey(KEY_1)); + + // STEP 2 + // Action: same call as STEP 1. + // Expected effect: method not invoked and result coming from the cache. + // Verified by: same object reference between STEPS 1 and 2 results. + String value2 = simpleCachedService.cachedMethod(KEY_1); + assertEquals(value1, value2); + assertEquals(allKeysAtStart.size() + 1, + TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 3 + // Action: same call as STEP 2 with a new key. + // Expected effect: method invoked and result cached. + // Verified by: different objects references between STEPS 2 and 3 results. + String value3 = simpleCachedService.cachedMethod(KEY_2); + assertNotEquals(value2, value3); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 2, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCacheKey(KEY_1), expectedCacheKey(KEY_2)); + + // STEP 4 + // Action: cache entry invalidation. + // Expected effect: STEP 2 cache entry removed. + // Verified by: STEP 5. + simpleCachedService.invalidate(KEY_1); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 1, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCacheKey(KEY_2)).doesNotContain(expectedCacheKey(KEY_1)); + + // STEP 5 + // Action: same call as STEP 2. + // Expected effect: method invoked because of STEP 4 and result cached. + // Verified by: different objects references between STEPS 2 and 5 results. + String value5 = simpleCachedService.cachedMethod(KEY_1); + assertNotEquals(value2, value5); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 2, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCacheKey(KEY_1), expectedCacheKey(KEY_2)); + + // STEP 6 + // Action: same call as STEP 3. + // Expected effect: method not invoked and result coming from the cache. + // Verified by: same object reference between STEPS 3 and 6 results. + String value6 = simpleCachedService.cachedMethod(KEY_2); + assertEquals(value3, value6); + assertEquals(allKeysAtStart.size() + 2, + TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 7 + // Action: full cache invalidation. + // Expected effect: empty cache. + // Verified by: STEPS 8 and 9. + simpleCachedService.invalidateAll(); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size(), newKeys.size()); + Assertions.assertThat(newKeys).doesNotContain(expectedCacheKey(KEY_1), expectedCacheKey(KEY_2)); + + // STEP 8 + // Action: same call as STEP 5. + // Expected effect: method invoked because of STEP 7 and result cached. + // Verified by: different objects references between STEPS 5 and 8 results. + String value8 = simpleCachedService.cachedMethod(KEY_1); + assertNotEquals(value5, value8); + + // STEP 9 + // Action: same call as STEP 6. + // Expected effect: method invoked because of STEP 7 and result cached. + // Verified by: different objects references between STEPS 6 and 9 results. + String value9 = simpleCachedService.cachedMethod(KEY_2); + assertNotEquals(value6, value9); + } + + private static String expectedCacheKey(String key) { + return "cache:" + SimpleCachedService.CACHE_NAME + ":" + key; + } + +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/ErroneousCacheTypeTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/ErroneousCacheTypeTest.java new file mode 100644 index 0000000000000..6a954ddf857a9 --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/ErroneousCacheTypeTest.java @@ -0,0 +1,16 @@ +package io.quarkus.cache.redis.deployment; + +import jakarta.enterprise.inject.spi.DeploymentException; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class ErroneousCacheTypeTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClass(SimpleCachedService.class)) + .overrideConfigKey("quarkus.cache.type", "not-redis") + .setExpectedException(DeploymentException.class); +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/MultipleCachesTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/MultipleCachesTest.java new file mode 100644 index 0000000000000..f96c9312435a4 --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/MultipleCachesTest.java @@ -0,0 +1,88 @@ +package io.quarkus.cache.redis.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.cache.CacheResult; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.QuarkusUnitTest; + +public class MultipleCachesTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(CachedService.class, TestUtil.class)) + .overrideConfigKey("quarkus.cache.redis.cache2.prefix", "dummy"); + + @Inject + CachedService cachedService; + + @Test + public void test() { + RedisDataSource redisDataSource = Arc.container().select(RedisDataSource.class).get(); + List allKeysAtStart = TestUtil.allRedisKeys(redisDataSource); + + String key1FromCache1 = cachedService.cache1("1"); + assertEquals(key1FromCache1, cachedService.cache1("1")); + List newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 1, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCache1Key("1")); + + String key2FromCache1 = cachedService.cache1("2"); + assertNotEquals(key2FromCache1, key1FromCache1); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 2, + newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCache1Key("1"), expectedCache1Key("2")); + + Double key1FromCache2 = cachedService.cache2(1d); + assertEquals(key1FromCache2, cachedService.cache2(1d)); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 3, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCache1Key("1"), expectedCache1Key("2"), expectedCache2Key("1.0")); + + Double key2FromCache2 = cachedService.cache2(2d); + assertNotEquals(key2FromCache2, key1FromCache2); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 4, newKeys.size()); + Assertions.assertThat(newKeys).contains(expectedCache1Key("1"), expectedCache1Key("2"), expectedCache2Key("1.0"), + expectedCache2Key("2.0")); + } + + private static String expectedCache1Key(String key) { + return "cache:" + CachedService.CACHE1 + ":" + key; + } + + private static String expectedCache2Key(String key) { + return "dummy:" + key; + } + + @ApplicationScoped + public static class CachedService { + + static final String CACHE1 = "cache1"; + static final String CACHE2 = "cache2"; + + @CacheResult(cacheName = CACHE1) + public String cache1(String key) { + return UUID.randomUUID().toString(); + } + + @CacheResult(cacheName = CACHE2) + public double cache2(double in) { + return ThreadLocalRandom.current().nextDouble(); + } + } +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/NamedRedisCacheTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/NamedRedisCacheTest.java new file mode 100644 index 0000000000000..5e03be1ef6e8d --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/NamedRedisCacheTest.java @@ -0,0 +1,108 @@ +package io.quarkus.cache.redis.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; + +@QuarkusTestResource(RedisTestResource.class) +public class NamedRedisCacheTest { + + private static final String KEY_1 = "1"; + private static final String KEY_2 = "2"; + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(SimpleCachedService.class, TestUtil.class)) + .overrideConfigKey("quarkus.redis.test.hosts", "${quarkus.redis.tr}/1") + .overrideConfigKey("quarkus.cache.redis.client-name", "test"); + + @Inject + SimpleCachedService simpleCachedService; + + @Test + public void testAllCacheAnnotations() { + RedisDataSource redisDataSource = Arc.container().select(RedisDataSource.class, + RedisClientName.Literal.of("test")).get(); + List allKeysAtStart = TestUtil.allRedisKeys(redisDataSource); + + // STEP 1 + // Action: @CacheResult-annotated method call. + // Expected effect: method invoked and result cached. + // Verified by: STEP 2. + String value1 = simpleCachedService.cachedMethod(KEY_1); + assertEquals(allKeysAtStart.size() + 1, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 2 + // Action: same call as STEP 1. + // Expected effect: method not invoked and result coming from the cache. + // Verified by: same object reference between STEPS 1 and 2 results. + String value2 = simpleCachedService.cachedMethod(KEY_1); + assertEquals(value1, value2); + assertEquals(allKeysAtStart.size() + 1, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 3 + // Action: same call as STEP 2 with a new key. + // Expected effect: method invoked and result cached. + // Verified by: different objects references between STEPS 2 and 3 results. + String value3 = simpleCachedService.cachedMethod(KEY_2); + assertNotEquals(value2, value3); + assertEquals(allKeysAtStart.size() + 2, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 4 + // Action: cache entry invalidation. + // Expected effect: STEP 2 cache entry removed. + // Verified by: STEP 5. + simpleCachedService.invalidate(KEY_1); + assertEquals(allKeysAtStart.size() + 1, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 5 + // Action: same call as STEP 2. + // Expected effect: method invoked because of STEP 4 and result cached. + // Verified by: different objects references between STEPS 2 and 5 results. + String value5 = simpleCachedService.cachedMethod(KEY_1); + assertNotEquals(value2, value5); + assertEquals(allKeysAtStart.size() + 2, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 6 + // Action: same call as STEP 3. + // Expected effect: method not invoked and result coming from the cache. + // Verified by: same object reference between STEPS 3 and 6 results. + String value6 = simpleCachedService.cachedMethod(KEY_2); + assertEquals(value3, value6); + assertEquals(allKeysAtStart.size() + 2, TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 7 + // Action: full cache invalidation. + // Expected effect: empty cache. + // Verified by: STEPS 8 and 9. + simpleCachedService.invalidateAll(); + assertEquals(allKeysAtStart.size(), TestUtil.allRedisKeys(redisDataSource).size()); + + // STEP 8 + // Action: same call as STEP 5. + // Expected effect: method invoked because of STEP 7 and result cached. + // Verified by: different objects references between STEPS 5 and 8 results. + String value8 = simpleCachedService.cachedMethod(KEY_1); + assertNotEquals(value5, value8); + + // STEP 9 + // Action: same call as STEP 6. + // Expected effect: method invoked because of STEP 7 and result cached. + // Verified by: different objects references between STEPS 6 and 9 results. + String value9 = simpleCachedService.cachedMethod(KEY_2); + assertNotEquals(value6, value9); + } + +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/PojoAndMultipleKeysCacheTest.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/PojoAndMultipleKeysCacheTest.java new file mode 100644 index 0000000000000..758ac269c430d --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/PojoAndMultipleKeysCacheTest.java @@ -0,0 +1,107 @@ +package io.quarkus.cache.redis.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +import jakarta.inject.Inject; +import jakarta.inject.Singleton; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import io.quarkus.arc.Arc; +import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheResult; +import io.quarkus.redis.datasource.RedisDataSource; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class PojoAndMultipleKeysCacheTest { + + @RegisterExtension + static final QuarkusUnitTest TEST = new QuarkusUnitTest() + .withApplicationRoot(jar -> jar.addClasses(CachedService.class, Message.class, TestUtil.class)); + + @Inject + CachedService cachedService; + + @Test + public void test() { + RedisDataSource redisDataSource = Arc.container().select(RedisDataSource.class).get(); + List allKeysAtStart = TestUtil.allRedisKeys(redisDataSource); + + Message messageFromKey1 = cachedService.getMessage("hello", "a", "1").await().indefinitely(); + List newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 1, newKeys.size()); + + assertEquals(messageFromKey1, cachedService.getMessage("hello", "b", "1").await().indefinitely()); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 1, newKeys.size()); + + Message messageFromKey2 = cachedService.getMessage("world", "c", "1").await().indefinitely(); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 2, newKeys.size()); + + assertEquals(messageFromKey2, cachedService.getMessage("world", "d", "1").await().indefinitely()); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 2, newKeys.size()); + + Message otherMessageFromKey1 = cachedService.getMessage("hello", "e", "2").await().indefinitely(); + assertNotEquals(otherMessageFromKey1, messageFromKey1); + assertEquals(otherMessageFromKey1, cachedService.getMessage("hello", "f", "2").await().indefinitely()); + newKeys = TestUtil.allRedisKeys(redisDataSource); + assertEquals(allKeysAtStart.size() + 3, newKeys.size()); + } + + @Singleton + public static class CachedService { + + @CacheResult(cacheName = "message") + public Uni getMessage(@CacheKey String key, String notPartOfTheKey, @CacheKey String otherKey) { + return Uni.createFrom().item(new Message(UUID.randomUUID().toString(), ThreadLocalRandom.current().nextInt())); + } + } + + public static class Message { + private final String str; + private final int num; + + @JsonCreator + public Message(String str, int num) { + this.str = str; + this.num = num; + } + + public String getStr() { + return str; + } + + public int getNum() { + return num; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Message message = (Message) o; + return num == message.num && str.equals(message.str); + } + + @Override + public int hashCode() { + return Objects.hash(str, num); + } + } +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisTestResource.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisTestResource.java new file mode 100644 index 0000000000000..d680374ad3fc4 --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/RedisTestResource.java @@ -0,0 +1,30 @@ +package io.quarkus.cache.redis.deployment; + +import java.util.Map; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; + +public class RedisTestResource implements QuarkusTestResourceLifecycleManager { + + static GenericContainer server = new GenericContainer<>( + DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @Override + public Map start() { + server.start(); + return Map.of("quarkus.redis.tr", getEndpoint()); + } + + @Override + public void stop() { + server.stop(); + } + + public static String getEndpoint() { + return String.format("redis://%s:%s", server.getHost(), server.getMappedPort(6379)); + } +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java new file mode 100644 index 0000000000000..88b641e0784d1 --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/SimpleCachedService.java @@ -0,0 +1,28 @@ +package io.quarkus.cache.redis.deployment; + +import java.util.UUID; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheResult; + +@ApplicationScoped +public class SimpleCachedService { + + static final String CACHE_NAME = "test-cache"; + + @CacheResult(cacheName = CACHE_NAME) + public String cachedMethod(String key) { + return UUID.randomUUID().toString(); + } + + @CacheInvalidate(cacheName = CACHE_NAME) + public void invalidate(String key) { + } + + @CacheInvalidateAll(cacheName = CACHE_NAME) + public void invalidateAll() { + } +} diff --git a/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/TestUtil.java b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/TestUtil.java new file mode 100644 index 0000000000000..2eb746715ce5f --- /dev/null +++ b/extensions/redis-cache/deployment/src/test/java/io/quarkus/cache/redis/deployment/TestUtil.java @@ -0,0 +1,22 @@ +package io.quarkus.cache.redis.deployment; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import io.quarkus.redis.datasource.RedisDataSource; + +final class TestUtil { + + private TestUtil() { + } + + static List allRedisKeys(RedisDataSource redisDataSource) { + Iterator iter = redisDataSource.key().scan().toIterable().iterator(); + List result = new ArrayList<>(); + while (iter.hasNext()) { + result.add(iter.next()); + } + return result; + } +} diff --git a/extensions/redis-cache/pom.xml b/extensions/redis-cache/pom.xml new file mode 100644 index 0000000000000..e1fae6a1e5fb4 --- /dev/null +++ b/extensions/redis-cache/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + + quarkus-redis-cache-parent + pom + + Quarkus - Redis Cache + + + deployment + runtime + + + + diff --git a/extensions/redis-cache/runtime/pom.xml b/extensions/redis-cache/runtime/pom.xml new file mode 100644 index 0000000000000..860e13e5aa0be --- /dev/null +++ b/extensions/redis-cache/runtime/pom.xml @@ -0,0 +1,113 @@ + + + 4.0.0 + + + io.quarkus + quarkus-redis-cache-parent + 999-SNAPSHOT + + + quarkus-redis-cache + + Quarkus - Redis Cache - Runtime + Use Redis as the caching backend + + + io.quarkus + quarkus-redis-client + + + io.quarkus + quarkus-cache + + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter + test + + + org.awaitility + awaitility + test + + + io.quarkus + quarkus-junit4-mock + test + + + org.testcontainers + testcontainers + test + + + junit + junit + + + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + maven-surefire-plugin + + true + + + + + + + + test-redis + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCache.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCache.java new file mode 100644 index 0000000000000..8127fde03e743 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCache.java @@ -0,0 +1,89 @@ +package io.quarkus.cache.redis.runtime; + +import java.util.function.Function; +import java.util.function.Supplier; + +import io.quarkus.cache.Cache; +import io.smallrye.mutiny.Uni; + +public interface RedisCache extends Cache { + + /** + * When configured, gets the default type of the value stored in the cache. + * The configured type is used when no type is passed into the {@link #get(Object, Class, Function)}. + * + * @return the type, {@code null} if not configured. + */ + Class getDefaultValueType(); + + @Override + default Uni get(K key, Function valueLoader) { + Class type = (Class) getDefaultValueType(); + if (type == null) { + throw new UnsupportedOperationException("Cannot use `get` method without a default type configured. " + + "Consider using the `get` method accepting the type or configure the default type for the cache " + + getName()); + } + return get(key, type, valueLoader); + } + + @SuppressWarnings("unchecked") + @Override + default Uni getAsync(K key, Function> valueLoader) { + Class type = (Class) getDefaultValueType(); + if (type == null) { + throw new UnsupportedOperationException("Cannot use `getAsync` method without a default type configured. " + + "Consider using the `getAsync` method accepting the type or configure the default type for the cache " + + getName()); + } + return getAsync(key, type, valueLoader); + } + + /** + * Allows retrieving a value from the Redis cache. + * + * @param key the key + * @param clazz the class of the value + * @param valueLoader the value loader called when there is no value stored in the cache + * @param the type of key + * @param the type of value + * @return the Uni emitting the cached value. + */ + Uni get(K key, Class clazz, Function valueLoader); + + /** + * Allows retrieving a value from the Redis cache. + * + * @param key the key + * @param clazz the class of the value + * @param valueLoader the value loader called when there is no value stored in the cache + * @param the type of key + * @param the type of value + * @return the Uni emitting the cached value. + */ + Uni getAsync(K key, Class clazz, Function> valueLoader); + + /** + * Put a value in the cache. + * + * @param key the key + * @param value the value + * @param the type of key + * @param the type of value + * @return a Uni emitting {@code null} when the operation completes + */ + default Uni put(K key, V value) { + return put(key, new Supplier() { + @Override + public V get() { + return value; + } + }); + } + + Uni put(K key, Supplier supplier); + + Uni getOrDefault(K key, V defaultValue); + + Uni getOrNull(K key, Class clazz); +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildRecorder.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildRecorder.java new file mode 100644 index 0000000000000..d4c95091d5680 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildRecorder.java @@ -0,0 +1,72 @@ +package io.quarkus.cache.redis.runtime; + +import java.util.*; +import java.util.function.Supplier; + +import org.jboss.logging.Logger; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CacheManager; +import io.quarkus.cache.CacheManagerInfo; +import io.quarkus.cache.runtime.CacheManagerImpl; +import io.quarkus.runtime.RuntimeValue; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class RedisCacheBuildRecorder { + + private static final Logger LOGGER = Logger.getLogger(RedisCacheBuildRecorder.class); + + private final RedisCachesBuildTimeConfig buildConfig; + private final RuntimeValue redisCacheConfigRV; + + private static Map valueTypes; + + public RedisCacheBuildRecorder(RedisCachesBuildTimeConfig buildConfig, RuntimeValue redisCacheConfigRV) { + this.buildConfig = buildConfig; + this.redisCacheConfigRV = redisCacheConfigRV; + } + + public CacheManagerInfo getCacheManagerSupplier() { + return new CacheManagerInfo() { + @Override + public boolean supports(Context context) { + return context.cacheEnabled() && "redis".equals(context.cacheType()); // TODO: fix constant + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Supplier get(Context context) { + return new Supplier() { + @Override + public CacheManager get() { + Set cacheInfos = RedisCacheInfoBuilder.build(context.cacheNames(), buildConfig, + redisCacheConfigRV.getValue(), valueTypes); + if (cacheInfos.isEmpty()) { + return new CacheManagerImpl(Collections.emptyMap()); + } else { + // The number of caches is known at build time so we can use fixed initialCapacity and loadFactor for the caches map. + Map caches = new HashMap<>(cacheInfos.size() + 1, 1.0F); + for (RedisCacheInfo cacheInfo : cacheInfos) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debugf( + "Building Redis cache [%s] with [ttl=%s], [prefix=%s], [classOfItems=%s]", + cacheInfo.name, cacheInfo.ttl, cacheInfo.prefix, + cacheInfo.valueType); + } + + RedisCacheImpl cache = new RedisCacheImpl(cacheInfo, buildConfig.clientName); + caches.put(cacheInfo.name, cache); + } + return new CacheManagerImpl(caches); + } + } + }; + } + }; + } + + public void setCacheValueTypes(Map valueTypes) { + RedisCacheBuildRecorder.valueTypes = valueTypes; + } +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildTimeConfig.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildTimeConfig.java new file mode 100644 index 0000000000000..9db9560c408a7 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheBuildTimeConfig.java @@ -0,0 +1,22 @@ +package io.quarkus.cache.redis.runtime; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class RedisCacheBuildTimeConfig { + + /** + * The default type of the value stored in the cache. + */ + @ConfigItem + public Optional valueType; + + /** + * The key type, {@code String} by default. + */ + @ConfigItem + public Optional keyType; +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java new file mode 100644 index 0000000000000..0fa03e1c9e961 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheImpl.java @@ -0,0 +1,405 @@ +package io.quarkus.cache.redis.runtime; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.cache.CacheException; +import io.quarkus.cache.CompositeCacheKey; +import io.quarkus.cache.runtime.AbstractCache; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.runtime.datasource.Marshaller; +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.unchecked.Unchecked; +import io.smallrye.mutiny.unchecked.UncheckedFunction; +import io.vertx.mutiny.redis.client.Command; +import io.vertx.mutiny.redis.client.Redis; +import io.vertx.mutiny.redis.client.RedisConnection; +import io.vertx.mutiny.redis.client.Request; +import io.vertx.mutiny.redis.client.Response; + +/** + * This class is an internal Quarkus cache implementation using Redis. + * Do not use it explicitly from your Quarkus application. + */ +public class RedisCacheImpl extends AbstractCache implements RedisCache { + + private static final Map> PRIMITIVE_TO_CLASS_MAPPING = Map.of( + "int", Integer.class, + "byte", Byte.class, + "char", Character.class, + "short", Short.class, + "long", Long.class, + "float", Float.class, + "double", Double.class, + "boolean", Boolean.class); + + private final Redis redis; + + private final RedisCacheInfo cacheInfo; + private final Class classOfValue; + private final Class classOfKey; + + private final Marshaller marshaller; + + public RedisCacheImpl(RedisCacheInfo cacheInfo, Optional redisClientName) { + + this(cacheInfo, determineRedisClient(redisClientName)); + } + + private static Redis determineRedisClient(Optional redisClientName) { + ArcContainer container = Arc.container(); + if (redisClientName.isPresent()) { + return container.select(Redis.class, RedisClientName.Literal.of(redisClientName.get())).get(); + } else { + return container.select(Redis.class).get(); + } + } + + @SuppressWarnings("unchecked") + public RedisCacheImpl(RedisCacheInfo cacheInfo, Redis redis) { + this.cacheInfo = cacheInfo; + + try { + this.classOfKey = (Class) loadClass(this.cacheInfo.keyType); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("Unable to load the class " + this.cacheInfo.keyType, e); + } + + if (this.cacheInfo.valueType != null) { + try { + this.classOfValue = (Class) loadClass(this.cacheInfo.valueType); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("Unable to load the class " + this.cacheInfo.valueType, e); + } + this.marshaller = new Marshaller(this.classOfValue, this.classOfKey); + } else { + this.classOfValue = null; + this.marshaller = new Marshaller(this.classOfKey); + } + this.marshaller.add(CompositeCacheKey.class); + this.redis = redis; + } + + private Class loadClass(String type) throws ClassNotFoundException { + if (PRIMITIVE_TO_CLASS_MAPPING.containsKey(type)) { + return PRIMITIVE_TO_CLASS_MAPPING.get(type); + } + return Thread.currentThread().getContextClassLoader().loadClass(type); + } + + @Override + public String getName() { + return Objects.requireNonNullElse(cacheInfo.name, "default-redis-cache"); + } + + @Override + public Object getDefaultKey() { + return "default-cache-key"; + } + + @Override + public Class getDefaultValueType() { + return classOfValue; + } + + private String encodeKey(K key) { + return new String(marshaller.encode(key), StandardCharsets.UTF_8); + } + + @Override + public Uni get(K key, Class clazz, Function valueLoader) { + // With optimistic locking: + // WATCH K + // val = deserialize(GET K) + // If val == null + // MULTI + // SET K computation.apply(K) + // EXEC + // Without: + // val = deserialize(GET K) + // if (val == null) => SET K computation.apply(K) + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + return withConnection(new Function>() { + @Override + public Uni apply(RedisConnection connection) { + return watch(connection, encodedKey) + .chain(new GetFromConnectionSupplier<>(connection, clazz, encodedKey, marshaller)) + .chain(Unchecked.function(new UncheckedFunction<>() { + @Override + public Uni apply(V cached) throws Exception { + if (cached != null) { + return Uni.createFrom().item(new StaticSupplier<>(cached)); + } else { + V value = valueLoader.apply(key); + if (value == null) { + throw new IllegalArgumentException("Cannot cache `null` value"); + } + byte[] encodedValue = marshaller.encode(value); + if (cacheInfo.useOptimisticLocking) { + return multi(connection, set(connection, encodedKey, encodedValue)) + .replaceWith(value); + } else { + return set(connection, encodedKey, encodedValue).replaceWith(value); + } + } + } + })); + } + }); + } + + @Override + public Uni getAsync(K key, Class clazz, Function> valueLoader) { + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + return withConnection(new Function>() { + @Override + public Uni apply(RedisConnection connection) { + return watch(connection, encodedKey) + .chain(new GetFromConnectionSupplier<>(connection, clazz, encodedKey, marshaller)) + .chain(cached -> { + if (cached != null) { + return Uni.createFrom().item(new StaticSupplier<>(cached)); + } else { + Uni getter = valueLoader.apply(key); + return getter + .chain(value -> { + byte[] encodedValue = marshaller.encode(value); + if (cacheInfo.useOptimisticLocking) { + return multi(connection, set(connection, encodedKey, encodedValue)) + .replaceWith(value); + } else { + return set(connection, encodedKey, encodedValue) + .replaceWith(value); + } + }); + } + }); + } + }); + } + + @Override + public Uni put(K key, V value) { + return put(key, new StaticSupplier<>(value)); + } + + @Override + public Uni put(K key, Supplier supplier) { + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + byte[] encodedValue = marshaller.encode(supplier.get()); + return withConnection(new Function>() { + @Override + public Uni apply(RedisConnection connection) { + return set(connection, encodedKey, encodedValue); + } + }); + } + + private void enforceDefaultType() { + if (classOfValue == null) { + throw new UnsupportedOperationException( + "Cannot execute the operation without the default type configured in cache " + cacheInfo.name); + } + } + + @SuppressWarnings("unchecked") + @Override + public Uni getOrDefault(K key, V defaultValue) { + enforceDefaultType(); + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + return withConnection(new Function>() { + @Override + public Uni apply(RedisConnection redisConnection) { + return (Uni) doGet(redisConnection, encodedKey, classOfValue, marshaller); + } + }).onItem().ifNull().continueWith(new StaticSupplier<>(defaultValue)); + } + + @Override + @SuppressWarnings("unchecked") + public Uni getOrNull(K key, Class clazz) { + enforceDefaultType(); + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + return withConnection(new Function>() { + @Override + public Uni apply(RedisConnection redisConnection) { + return (Uni) doGet(redisConnection, encodedKey, classOfValue, marshaller); + } + }); + } + + @Override + public Uni invalidate(Object key) { + byte[] encodedKey = marshaller.encode(computeActualKey(encodeKey(key))); + return redis.send(Request.cmd(Command.DEL).arg(encodedKey)) + .replaceWithVoid(); + } + + @Override + public Uni invalidateAll() { + return invalidateIf(AlwaysTruePredicate.INSTANCE); + } + + @Override + public Uni invalidateIf(Predicate predicate) { + return redis.send(Request.cmd(Command.KEYS).arg(getKeyPattern())) + .map(response -> marshaller.decodeAsList(response, String.class)) + .chain(new Function, Uni>() { + @Override + public Uni apply(List listOfKeys) { + var req = Request.cmd(Command.DEL); + boolean hasAtLEastOneMatch = false; + for (String key : listOfKeys) { + K userKey = computeUserKey(key); + if (predicate.test(userKey)) { + hasAtLEastOneMatch = true; + req.arg(marshaller.encode(key)); + } + } + if (hasAtLEastOneMatch) { + // We cannot send the command with parameters, it would not be a valid command. + return redis.send(req); + } else { + return Uni.createFrom().voidItem(); + } + } + }) + .replaceWithVoid(); + } + + String computeActualKey(String key) { + if (cacheInfo.prefix != null) { + return cacheInfo.prefix + ":" + key; + } else { + return "cache:" + getName() + ":" + key; + } + } + + K computeUserKey(String key) { + String prefix = cacheInfo.prefix != null ? cacheInfo.prefix : "cache:" + getName(); + if (!key.startsWith(prefix + ":")) { + return null; // Not a key handle by the cache. + } + String stripped = key.substring(prefix.length() + 1); + return marshaller.decode(classOfKey, stripped.getBytes(StandardCharsets.UTF_8)); + } + + private String getKeyPattern() { + if (cacheInfo.prefix != null) { + return cacheInfo.prefix + ":" + "*"; + } else { + return "cache:" + getName() + ":" + "*"; + } + } + + private Uni withConnection(Function> function) { + return redis.connect() + .chain(new Function>() { + @Override + public Uni apply(RedisConnection con) { + Uni res; + try { + res = function.apply(con); + } catch (Exception e) { + res = Uni.createFrom().failure(new CacheException(e)); + } + return res + .onTermination().call(con::close); + } + }); + } + + private Uni watch(RedisConnection connection, byte[] keyToWatch) { + return connection.send(Request.cmd(Command.WATCH).arg(keyToWatch)) + .replaceWithVoid(); + } + + private static Uni doGet(RedisConnection connection1, byte[] encodedKey1, Class clazz, + Marshaller marshaller) { + return connection1.send(Request.cmd(Command.GET).arg(encodedKey1)) + .map(new Function() { + @Override + public X apply(Response r) { + return marshaller.decode(clazz, r); + } + }); + } + + private Uni set(RedisConnection connection, byte[] key, byte[] value) { + Request request = Request.cmd(Command.SET).arg(key).arg(value); + if (cacheInfo.ttl.isPresent()) { + request = request.arg("EX").arg(cacheInfo.ttl.get().toSeconds()); + } + return connection.send(request).replaceWithVoid(); + } + + private Uni multi(RedisConnection connection, Uni operation) { + return connection.send(Request.cmd(Command.MULTI)) + .chain(() -> operation) + .onFailure().call(() -> abort(connection)) + .call(() -> exec(connection)); + } + + private Uni exec(RedisConnection connection) { + return connection.send(Request.cmd(Command.EXEC)) + .replaceWithVoid(); + } + + private Uni abort(RedisConnection connection) { + return connection.send(Request.cmd(Command.DISCARD)) + .replaceWithVoid(); + } + + private static class StaticSupplier implements Supplier { + private final V cached; + + public StaticSupplier(V cached) { + this.cached = cached; + } + + @Override + public V get() { + return cached; + } + } + + private static class GetFromConnectionSupplier implements Supplier> { + private final RedisConnection connection; + private final Class clazz; + private final byte[] encodedKey; + private final Marshaller marshaller; + + public GetFromConnectionSupplier(RedisConnection connection, Class clazz, byte[] encodedKey, Marshaller marshaller) { + this.connection = connection; + this.clazz = clazz; + this.encodedKey = encodedKey; + this.marshaller = marshaller; + } + + @Override + public Uni get() { + return doGet(connection, encodedKey, clazz, marshaller); + } + } + + private static class AlwaysTruePredicate implements Predicate { + + public static AlwaysTruePredicate INSTANCE = new AlwaysTruePredicate(); + + private AlwaysTruePredicate() { + } + + @Override + public boolean test(Object o) { + return true; + } + } +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfo.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfo.java new file mode 100644 index 0000000000000..54bc0e0062559 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfo.java @@ -0,0 +1,40 @@ +package io.quarkus.cache.redis.runtime; + +import java.time.Duration; +import java.util.Optional; + +public class RedisCacheInfo { + + /** + * The cache name + */ + public String name; + + /** + * The default time to live of the item stored in the cache + */ + public Optional ttl = Optional.empty(); + + /** + * the key prefix allowing to identify the keys belonging to the cache. + * If not set, use "cache:$cache-name" + */ + public String prefix; + + /** + * The default type of the value stored in the cache. + */ + public String valueType; + + /** + * The key type, {@code String} by default. + */ + public String keyType = String.class.getName(); + + /** + * Whether the access to the cache should be using optimistic locking + * See Redis Optimistic + * Locking for details. + */ + public boolean useOptimisticLocking = false; +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfoBuilder.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfoBuilder.java new file mode 100644 index 0000000000000..84467b2e80c0c --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheInfoBuilder.java @@ -0,0 +1,61 @@ +package io.quarkus.cache.redis.runtime; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import io.quarkus.runtime.configuration.HashSetFactory; + +public class RedisCacheInfoBuilder { + + public static Set build(Set cacheNames, RedisCachesBuildTimeConfig buildTimeConfig, + RedisCachesConfig runtimeConfig, Map valueTypes) { + if (cacheNames.isEmpty()) { + return Collections.emptySet(); + } else { + Set result = HashSetFactory. getInstance().apply(cacheNames.size()); + ; + for (String cacheName : cacheNames) { + + RedisCacheInfo cacheInfo = new RedisCacheInfo(); + cacheInfo.name = cacheName; + + RedisCacheRuntimeConfig defaultRuntimeConfig = runtimeConfig.defaultConfig; + RedisCacheRuntimeConfig namedRuntimeConfig = runtimeConfig.cachesConfig.get(cacheInfo.name); + + if (namedRuntimeConfig != null && namedRuntimeConfig.ttl.isPresent()) { + cacheInfo.ttl = namedRuntimeConfig.ttl; + } else if (defaultRuntimeConfig.ttl.isPresent()) { + cacheInfo.ttl = defaultRuntimeConfig.ttl; + } + + if (namedRuntimeConfig != null && namedRuntimeConfig.prefix.isPresent()) { + cacheInfo.prefix = namedRuntimeConfig.prefix.get(); + } else if (defaultRuntimeConfig.prefix.isPresent()) { + cacheInfo.prefix = defaultRuntimeConfig.prefix.get(); + } + + cacheInfo.valueType = valueTypes.get(cacheName); + + RedisCacheBuildTimeConfig defaultBuildTimeConfig = buildTimeConfig.defaultConfig; + RedisCacheBuildTimeConfig namedBuildTimeConfig = buildTimeConfig.cachesConfig + .get(cacheInfo.name); + + if (namedBuildTimeConfig != null && namedBuildTimeConfig.keyType.isPresent()) { + cacheInfo.keyType = namedBuildTimeConfig.keyType.get(); + } else if (defaultBuildTimeConfig.keyType.isPresent()) { + cacheInfo.keyType = defaultBuildTimeConfig.keyType.get(); + } + + if (namedRuntimeConfig != null && namedRuntimeConfig.useOptimisticLocking.isPresent()) { + cacheInfo.useOptimisticLocking = namedRuntimeConfig.useOptimisticLocking.get(); + } else if (defaultRuntimeConfig.useOptimisticLocking.isPresent()) { + cacheInfo.useOptimisticLocking = defaultRuntimeConfig.useOptimisticLocking.get(); + } + + result.add(cacheInfo); + } + return result; + } + } +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheRuntimeConfig.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheRuntimeConfig.java new file mode 100644 index 0000000000000..3171817e06588 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCacheRuntimeConfig.java @@ -0,0 +1,33 @@ +package io.quarkus.cache.redis.runtime; + +import java.time.Duration; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class RedisCacheRuntimeConfig { + /** + * The default time to live of the item stored in the cache + */ + @ConfigItem + public Optional ttl; + + /** + * the key prefix allowing to identify the keys belonging to the cache. + * If not set, use "cache:$cache-name" + */ + @ConfigItem + public Optional prefix; + + /** + * Whether the access to the cache should be using optimistic locking. + * See Redis Optimistic + * Locking for details. + * Default is {@code false}. + */ + @ConfigItem + public Optional useOptimisticLocking; + +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesBuildTimeConfig.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesBuildTimeConfig.java new file mode 100644 index 0000000000000..ce9e2b3da345e --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesBuildTimeConfig.java @@ -0,0 +1,37 @@ +package io.quarkus.cache.redis.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.BUILD_AND_RUN_TIME_FIXED; + +import java.util.Map; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigDocSection; +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigRoot; + +@ConfigRoot(phase = BUILD_AND_RUN_TIME_FIXED, name = "cache.redis") +public class RedisCachesBuildTimeConfig { + + /** + * The name of the named Redis client to be used for communicating with Redis. + * If not set, use the default Redis client. + */ + @ConfigItem + public Optional clientName; + + /** + * Default configuration applied to all Redis caches (lowest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + public RedisCacheBuildTimeConfig defaultConfig; + + /** + * Additional configuration applied to a specific Redis cache (highest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + @ConfigDocMapKey("cache-name") + @ConfigDocSection + public Map cachesConfig; + +} diff --git a/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesConfig.java b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesConfig.java new file mode 100644 index 0000000000000..3e0a13fc66308 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/java/io/quarkus/cache/redis/runtime/RedisCachesConfig.java @@ -0,0 +1,29 @@ +package io.quarkus.cache.redis.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.RUN_TIME; + +import java.util.Map; + +import io.quarkus.runtime.annotations.ConfigDocMapKey; +import io.quarkus.runtime.annotations.ConfigDocSection; +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigRoot; + +@ConfigRoot(phase = RUN_TIME, name = "cache.redis") +public class RedisCachesConfig { + + /** + * Default configuration applied to all Redis caches (lowest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + public RedisCacheRuntimeConfig defaultConfig; + + /** + * Additional configuration applied to a specific Redis cache (highest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + @ConfigDocMapKey("cache-name") + @ConfigDocSection + Map cachesConfig; + +} diff --git a/extensions/redis-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/redis-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..0b24c6aa16532 --- /dev/null +++ b/extensions/redis-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,14 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Redis Cache" +metadata: + keywords: + - "redis" + - "cache" + guide: "https://quarkus.io/guides/cache-redis-reference" + categories: + - "data" + - "reactive" + status: "preview" + config: + - "quarkus.cache.redis" diff --git a/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheImplTest.java b/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheImplTest.java new file mode 100644 index 0000000000000..b29be5d993ea6 --- /dev/null +++ b/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheImplTest.java @@ -0,0 +1,486 @@ +package io.quarkus.cache.redis.runtime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.util.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import io.smallrye.mutiny.Uni; +import io.smallrye.mutiny.infrastructure.Infrastructure; +import io.vertx.core.json.Json; +import io.vertx.mutiny.redis.client.Command; +import io.vertx.mutiny.redis.client.Request; +import io.vertx.mutiny.redis.client.Response; + +class RedisCacheImplTest extends RedisCacheTestBase { + + @AfterEach + void clear() { + redis.send(Request.cmd(Command.FLUSHALL).arg("SYNC")).await().atMost(Duration.ofSeconds(10)); + } + + @Test + public void testPutInTheCache() { + String k = UUID.randomUUID().toString(); + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "foo"; + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(2)); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + assertThat(cache.get(k, s -> "hello").await().indefinitely()).isEqualTo("hello"); + var r = redis.send(Request.cmd(Command.GET).arg("cache:foo:" + k)).await().indefinitely(); + assertThat(r).isNotNull(); + } + + @Test + public void testPutInTheCacheWithOptimisitcLocking() { + String k = UUID.randomUUID().toString(); + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "foo"; + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.useOptimisticLocking = true; + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + assertThat(cache.get(k, s -> "hello").await().indefinitely()).isEqualTo("hello"); + var r = redis.send(Request.cmd(Command.GET).arg("cache:foo:" + k)).await().indefinitely(); + assertThat(r).isNotNull(); + } + + @Test + public void testPutAndWaitForInvalidation() { + String k = UUID.randomUUID().toString(); + RedisCacheInfo info = new RedisCacheInfo(); + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(1)); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + assertThat(cache.get(k, s -> "hello").await().indefinitely()).isEqualTo("hello"); + var x = cache.get(k, String::toUpperCase).await().indefinitely(); + assertEquals(x, "hello"); + await().until(() -> cache.getOrNull(k, String.class).await().indefinitely() == null); + } + + @Test + public void testManualInvalidation() { + RedisCacheInfo info = new RedisCacheInfo(); + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + cache.get("foo", s -> "hello").await().indefinitely(); + var x = cache.get("foo", String::toUpperCase).await().indefinitely(); + assertEquals(x, "hello"); + + cache.invalidate("foo").await().indefinitely(); + String foo = cache.get("foo", String.class, String::toUpperCase).await().indefinitely(); + assertThat(foo).isEqualTo("FOO"); + } + + public static class Person { + public String firstName; + public String lastName; + + public Person(String firstName, String lastName) { + this.firstName = firstName; + this.lastName = lastName; + } + + public Person() { + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + Person person = (Person) o; + return firstName.equals(person.firstName) && lastName.equals(person.lastName); + } + + @Override + public int hashCode() { + return Objects.hash(firstName, lastName); + } + } + + @Test + public void testGetOrNull() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + Person person = cache.getOrNull("foo", Person.class).await().indefinitely(); + assertThat(person).isNull(); + assertThatTheKeyDoesNotExist("cache:foo"); + + cache.get("foo", Person.class, s -> new Person(s, s.toUpperCase())).await().indefinitely(); + person = cache.getOrNull("foo", Person.class).await().indefinitely(); + assertThat(person).isNotNull() + .satisfies(p -> { + assertThat(p.firstName).isEqualTo("foo"); + assertThat(p.lastName).isEqualTo("FOO"); + }); + assertThatTheKeyDoesExist("cache:default-redis-cache:foo"); + } + + @Test + public void testGetOrDefault() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + Person person = cache.getOrDefault("foo", new Person("bar", "BAR")).await().indefinitely(); + assertThat(person).isNotNull() + .satisfies(p -> { + assertThat(p.firstName).isEqualTo("bar"); + assertThat(p.lastName).isEqualTo("BAR"); + }); + // Verify it was not stored + person = cache.getOrNull("foo", Person.class).await().indefinitely(); + assertThat(person).isNull(); + + cache.get("foo", Person.class, s -> new Person(s, s.toUpperCase())).await().indefinitely(); + person = cache.getOrNull("foo", Person.class).await().indefinitely(); + assertThat(person).isNotNull() + .satisfies(p -> { + assertThat(p.firstName).isEqualTo("foo"); + assertThat(p.lastName).isEqualTo("FOO"); + }); + assertThatTheKeyDoesExist("cache:default-redis-cache:foo"); + } + + @Test + public void testCacheNullValue() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + // with custom key + double key = 122334545.0; + assertThatThrownBy(() -> cache.get(key, k -> null).await().indefinitely()) + .isInstanceOf(IllegalArgumentException.class); + assertThatTheKeyDoesNotExist("cache:default-redis-cache:" + Json.encode(key)); + } + + @Test + public void testExceptionInValueLoader() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + info.keyType = Double.class.getName(); + info.name = "foo"; + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + // with custom key and exception + Double key = 122334545.0; + RuntimeException thrown = new RuntimeException(); + + // when exception thrown in the value loader for the key + assertThatThrownBy(() -> { + cache.get(key, k -> { + throw thrown; + }).await().indefinitely(); + }).isInstanceOf(RuntimeException.class).isEqualTo(thrown); + + assertThatTheKeyDoesNotExist(Json.encode("cache:foo:" + key)); + } + + @Test + public void testPutShouldPopulateCache() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + info.keyType = Integer.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + cache.put(1, new Person("luke", "skywalker")).await().indefinitely(); + assertThat(cache.get(1, x -> new Person("1", "1")).await().indefinitely()).isEqualTo(new Person("luke", "skywalker")); + assertThatTheKeyDoesExist("cache:default-redis-cache:1"); + cache.invalidate(1).await().indefinitely(); + assertThat(cache.getOrNull(1, Person.class).await().indefinitely()).isNull(); + assertThatTheKeyDoesNotExist("cache:default-redis-cache:1"); + } + + @Test + public void testPutShouldPopulateCacheWithOptimisticLocking() { + RedisCacheInfo info = new RedisCacheInfo(); + info.ttl = Optional.of(Duration.ofSeconds(10)); + info.valueType = Person.class.getName(); + info.keyType = Integer.class.getName(); + info.useOptimisticLocking = true; + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + cache.put(1, new Person("luke", "skywalker")).await().indefinitely(); + assertThat(cache.get(1, x -> new Person("1", "1")).await().indefinitely()).isEqualTo(new Person("luke", "skywalker")); + assertThatTheKeyDoesExist("cache:default-redis-cache:1"); + cache.invalidate(1).await().indefinitely(); + assertThat(cache.getOrNull(1, Person.class).await().indefinitely()).isNull(); + assertThatTheKeyDoesNotExist("cache:default-redis-cache:1"); + } + + @Test + public void testThatConnectionsAreRecycled() { + String k = UUID.randomUUID().toString(); + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "foo"; + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(1)); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + for (int i = 0; i < 1000; i++) { + String val = "hello-" + i; + cache.get(k, s -> val).await().indefinitely(); + } + var r = redis.send(Request.cmd(Command.GET).arg("cache:foo:" + k)).await().indefinitely(); + assertThat(r).isNotNull(); + assertThat(r.toString()).startsWith("hello-"); + + await().untilAsserted(() -> assertThatTheKeyDoesNotExist("cache:foo:" + k)); + for (int i = 1000; i < 2000; i++) { + String val = "hello-" + i; + cache.get(k, s -> val).await().indefinitely(); + } + assertThat(r.toString()).startsWith("hello-"); + } + + @Test + public void testThatConnectionsAreRecycledWithOptimisticLocking() { + String k = UUID.randomUUID().toString(); + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "foo"; + info.valueType = String.class.getName(); + info.ttl = Optional.of(Duration.ofSeconds(1)); + info.useOptimisticLocking = true; + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + for (int i = 0; i < 1000; i++) { + String val = "hello-" + i; + cache.get(k, s -> val).await().indefinitely(); + } + var r = redis.send(Request.cmd(Command.GET).arg("cache:foo:" + k)).await().indefinitely(); + assertThat(r).isNotNull(); + assertThat(r.toString()).startsWith("hello-"); + + await().untilAsserted(() -> assertThatTheKeyDoesNotExist("cache:foo:" + k)); + for (int i = 1000; i < 2000; i++) { + String val = "hello-" + i; + cache.get(k, s -> val).await().indefinitely(); + } + assertThat(r.toString()).startsWith("hello-"); + } + + @Test + void testWithMissingDefaultType() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "missing-default-cache"; + info.ttl = Optional.of(Duration.ofSeconds(10)); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + assertThatThrownBy(() -> cache.get("test", x -> "value").await().indefinitely()) + .isInstanceOf(UnsupportedOperationException.class); + + assertThatThrownBy(() -> cache.getAsync("test", x -> Uni.createFrom().item("value")).await().indefinitely()) + .isInstanceOf(UnsupportedOperationException.class); + + assertThat(cache.get("test", String.class, x -> "value").await().indefinitely()).isEqualTo("value"); + assertThat(cache.getAsync("test-async", String.class, x -> Uni.createFrom().item("value")).await().indefinitely()) + .isEqualTo("value"); + + assertThat(cache.get("test", String.class, x -> "another").await().indefinitely()).isEqualTo("value"); + assertThat(cache.getAsync("test-async", String.class, x -> Uni.createFrom().item("another")).await().indefinitely()) + .isEqualTo("value"); + } + + @Test + void testAsyncGetWithDefaultType() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "star-wars"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + assertThat(cache + .getAsync("test", + x -> Uni.createFrom().item(new Person("luke", "skywalker")) + .runSubscriptionOn(Infrastructure.getDefaultExecutor())) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("luke"); + assertThat(p.lastName).isEqualTo("skywalker"); + }); + + assertThat(cache.getAsync("test", x -> Uni.createFrom().item(new Person("leia", "organa"))) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("luke"); + assertThat(p.lastName).isEqualTo("skywalker"); + }); + + await().untilAsserted(() -> assertThat(cache.getAsync("test", x -> Uni.createFrom().item(new Person("leia", "organa"))) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("leia"); + assertThat(p.lastName).isEqualTo("organa"); + })); + } + + @Test + void testAsyncGetWithDefaultTypeWithOptimisticLocking() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "star-wars"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.valueType = Person.class.getName(); + info.useOptimisticLocking = true; + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + assertThat(cache + .getAsync("test", + x -> Uni.createFrom().item(new Person("luke", "skywalker")) + .runSubscriptionOn(Infrastructure.getDefaultExecutor())) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("luke"); + assertThat(p.lastName).isEqualTo("skywalker"); + }); + + assertThat(cache.getAsync("test", x -> Uni.createFrom().item(new Person("leia", "organa"))) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("luke"); + assertThat(p.lastName).isEqualTo("skywalker"); + }); + + await().untilAsserted(() -> assertThat(cache.getAsync("test", x -> Uni.createFrom().item(new Person("leia", "organa"))) + .await().indefinitely()).satisfies(p -> { + assertThat(p.firstName).isEqualTo("leia"); + assertThat(p.lastName).isEqualTo("organa"); + })); + } + + @Test + void testPut() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "put"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + Person luke = new Person("luke", "skywalker"); + Person leia = new Person("leia", "organa"); + cache.put("test", luke).await().indefinitely(); + assertThatTheKeyDoesExist("cache:put:test"); + + assertThat(cache.get("test", x -> new Person("x", "x")).await().indefinitely()).isEqualTo(luke); + + await().untilAsserted(() -> assertThat(cache.getAsync("test", x -> Uni.createFrom().item(leia)) + .await().indefinitely()).isEqualTo(leia)); + } + + @Test + void testPutWithSupplier() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "put"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.valueType = Person.class.getName(); + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + Person luke = new Person("luke", "skywalker"); + Person leia = new Person("leia", "organa"); + cache.put("test", () -> luke).await().indefinitely(); + assertThatTheKeyDoesExist("cache:put:test"); + + assertThat(cache.get("test", x -> new Person("x", "x")).await().indefinitely()).isEqualTo(luke); + + await().untilAsserted(() -> assertThat(cache.get("test", x -> leia) + .await().indefinitely()).isEqualTo(leia)); + } + + @Test + void testInitializationWithAnUnknownClass() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "put"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + info.valueType = Person.class.getPackage().getName() + ".Missing"; + + assertThatThrownBy(() -> new RedisCacheImpl<>(info, redis)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void testGetDefaultKey() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "test-default-key"; + info.ttl = Optional.of(Duration.ofSeconds(2)); + + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + assertThat(cache.getDefaultKey()).isEqualTo("default-cache-key"); + + assertThat(cache.getDefaultValueType()).isNull(); + } + + @Test + void testInvalidation() { + RedisCacheInfo info = new RedisCacheInfo(); + info.name = "test-invalidation"; + info.ttl = Optional.of(Duration.ofSeconds(10)); + + RedisCacheImpl cache = new RedisCacheImpl<>(info, redis); + + redis.send(Request.cmd(Command.SET).arg("key6").arg("my-value")).await().indefinitely(); + + cache.put("key1", "val1").await().indefinitely(); + cache.put("key2", "val2").await().indefinitely(); + cache.put("key3", "val3").await().indefinitely(); + cache.put("key4", "val4").await().indefinitely(); + cache.put("key5", "val5").await().indefinitely(); + + cache.put("clé-1", "valeur-1").await().indefinitely(); + cache.put("clé-2", "valeur-2").await().indefinitely(); + cache.put("clé-3", "valeur-3").await().indefinitely(); + + cache.put("special", "special").await().indefinitely(); + + assertThat(getAllKeys()).hasSize(10); + + cache.invalidate("special").await().indefinitely(); + assertThatTheKeyDoesNotExist("cache:test-invalidation:special"); + + assertThat(getAllKeys()).hasSize(9); + + cache.invalidateIf(o -> o instanceof String && ((String) o).startsWith("key")).await().indefinitely(); + assertThatTheKeyDoesNotExist("cache:test-invalidation:key1"); + assertThatTheKeyDoesNotExist("cache:test-invalidation:key2"); + assertThatTheKeyDoesExist("key6"); + assertThat(getAllKeys()).hasSize(4); + + cache.invalidateAll().await().indefinitely(); + assertThatTheKeyDoesNotExist("cache:test-invalidation:clé-1"); + assertThatTheKeyDoesNotExist("cache:test-invalidation:clé-2"); + assertThatTheKeyDoesNotExist("cache:test-invalidation:clé-3"); + assertThatTheKeyDoesExist("key6"); + assertThat(getAllKeys()).hasSize(1); + } + + private Set getAllKeys() { + return redis.send(Request.cmd(Command.KEYS).arg("*")) + .map(r -> { + Set keys = new HashSet<>(); + for (Response response : r) { + keys.add(response.toString()); + } + return keys; + }) + .await().indefinitely(); + } + + private void assertThatTheKeyDoesExist(String key) { + var actualKeySet = getAllKeys(); + assertThat(actualKeySet).contains(key); + } + + private void assertThatTheKeyDoesNotExist(String key) { + assertThat(getAllKeys()).doesNotContain(key); + } + +} diff --git a/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheTestBase.java b/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheTestBase.java new file mode 100644 index 0000000000000..e3083310a71ac --- /dev/null +++ b/extensions/redis-cache/runtime/src/test/java/io/quarkus/cache/redis/runtime/RedisCacheTestBase.java @@ -0,0 +1,42 @@ +package io.quarkus.cache.redis.runtime; + +import java.util.UUID; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import io.vertx.mutiny.core.Vertx; +import io.vertx.mutiny.redis.client.Redis; +import io.vertx.mutiny.redis.client.RedisAPI; + +public class RedisCacheTestBase { + + final String key = UUID.randomUUID().toString(); + + public static Vertx vertx; + public static Redis redis; + public static RedisAPI api; + + static GenericContainer server = new GenericContainer<>( + DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + + @BeforeEach + void init() { + vertx = Vertx.vertx(); + server.start(); + redis = Redis.createClient(vertx, "redis://" + server.getHost() + ":" + server.getFirstMappedPort()); + // If you want to use a local redis: redis = Redis.createClient(vertx, "redis://localhost:" + 6379); + api = RedisAPI.api(redis); + } + + @AfterEach + void cleanup() { + redis.close(); + server.close(); + vertx.closeAndAwait(); + } + +} diff --git a/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/runtime/datasource/Marshaller.java b/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/runtime/datasource/Marshaller.java index d16d8b94f7fc9..c64fa7d521f62 100644 --- a/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/runtime/datasource/Marshaller.java +++ b/extensions/redis-client/runtime/src/main/java/io/quarkus/redis/runtime/datasource/Marshaller.java @@ -34,13 +34,20 @@ public class Marshaller { Map, Codec> codecs = new ConcurrentHashMap<>(); public Marshaller(Class... hints) { - doesNotContainNull(hints, "hints"); + addAll(hints); + } + public void addAll(Class... hints) { + doesNotContainNull(hints, "hints"); for (Class hint : hints) { - codecs.put(hint, Codecs.getDefaultCodecFor(hint)); + codecs.computeIfAbsent(hint, h -> Codecs.getDefaultCodecFor(hint)); } } + public void add(Class hint) { + codecs.computeIfAbsent(hint, h -> Codecs.getDefaultCodecFor(hint)); + } + @SuppressWarnings({ "rawtypes", "unchecked" }) public byte[] encode(Object o) { if (o instanceof String) { @@ -78,7 +85,7 @@ Codec codec(Class clazz) { return codec; } - final T decode(Class clazz, Response r) { + public final T decode(Class clazz, Response r) { if (r == null) { return null; } diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 617435867cb4b..8e9707113a372 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -281,6 +281,7 @@ jpa-without-entity quartz redis-client + redis-cache logging-gelf cache qute diff --git a/integration-tests/redis-cache/pom.xml b/integration-tests/redis-cache/pom.xml new file mode 100644 index 0000000000000..fdd4ad906046e --- /dev/null +++ b/integration-tests/redis-cache/pom.xml @@ -0,0 +1,213 @@ + + + 4.0.0 + + io.quarkus + quarkus-integration-tests-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-integration-test-redis-cache + Quarkus - Integration Tests - Redis Cache + + + localhost:6379 + + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-redis-cache + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + io.quarkus + quarkus-redis-cache-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-resteasy-reactive-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + src/main/resources + true + + + + + maven-surefire-plugin + + true + + + + maven-failsafe-plugin + + true + + + + io.quarkus + quarkus-maven-plugin + + + + build + + + + + + + + + + test-redis + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + docker-redis + + + start-containers + + + + localhost:6379 + + + + + io.fabric8 + docker-maven-plugin + + + + redis:5.0.8-alpine + quarkus-test-redis + + + 6379:6379 + + + Redis: + default + cyan + + + + + + + + redis-cli PING + + + + + + + true + + + + docker-start + compile + + stop + start + + + + docker-stop + post-integration-test + + stop + + + + + + org.codehaus.mojo + exec-maven-plugin + + + docker-prune + generate-resources + + exec + + + ${docker-prune.location} + + + + + + + + + + + diff --git a/integration-tests/redis-cache/src/main/java/io/quarkus/it/cache/redis/ExpensiveResource.java b/integration-tests/redis-cache/src/main/java/io/quarkus/it/cache/redis/ExpensiveResource.java new file mode 100644 index 0000000000000..f6ae1bc99f431 --- /dev/null +++ b/integration-tests/redis-cache/src/main/java/io/quarkus/it/cache/redis/ExpensiveResource.java @@ -0,0 +1,56 @@ +package io.quarkus.it.cache.redis; + +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.QueryParam; + +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheResult; + +@Path("/expensive-resource") +public class ExpensiveResource { + + private final AtomicInteger invocations = new AtomicInteger(0); + + @GET + @Path("/{keyElement1}/{keyElement2}/{keyElement3}") + @CacheResult(cacheName = "expensiveResourceCache") + public ExpensiveResponse getExpensiveResponse(@PathParam("keyElement1") @CacheKey String keyElement1, + @PathParam("keyElement2") @CacheKey String keyElement2, @PathParam("keyElement3") @CacheKey String keyElement3, + @QueryParam("foo") String foo) { + invocations.incrementAndGet(); + ExpensiveResponse response = new ExpensiveResponse(); + response.setResult(keyElement1 + " " + keyElement2 + " " + keyElement3 + " too!"); + return response; + } + + @POST + @CacheInvalidateAll(cacheName = "expensiveResourceCache") + public void invalidateAll() { + + } + + @GET + @Path("/invocations") + public int getInvocations() { + return invocations.get(); + } + + public static class ExpensiveResponse { + + private String result; + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + } +} diff --git a/integration-tests/redis-cache/src/main/resources/application.properties b/integration-tests/redis-cache/src/main/resources/application.properties new file mode 100644 index 0000000000000..b855ea5dfadfe --- /dev/null +++ b/integration-tests/redis-cache/src/main/resources/application.properties @@ -0,0 +1 @@ +quarkus.redis.hosts=redis://localhost:6379/0 diff --git a/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheIT.java b/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheIT.java new file mode 100644 index 0000000000000..69358c06f34cd --- /dev/null +++ b/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.cache.redis; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class CacheIT extends CacheTest { +} diff --git a/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheTest.java b/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheTest.java new file mode 100644 index 0000000000000..a2e74bd85e1cb --- /dev/null +++ b/integration-tests/redis-cache/src/test/java/io/quarkus/it/cache/redis/CacheTest.java @@ -0,0 +1,33 @@ +package io.quarkus.it.cache.redis; + +import static io.restassured.RestAssured.when; +import static org.hamcrest.Matchers.is; + +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +public class CacheTest { + + @Test + public void testCache() { + runExpensiveRequest(); + runExpensiveRequest(); + runExpensiveRequest(); + when().get("/expensive-resource/invocations").then().statusCode(200).body(is("1")); + + when() + .post("/expensive-resource") + .then() + .statusCode(204); + } + + private void runExpensiveRequest() { + when() + .get("/expensive-resource/I/love/Quarkus?foo=bar") + .then() + .statusCode(200) + .body("result", is("I love Quarkus too!")); + } +}