diff --git a/.github/native-tests.json b/.github/native-tests.json index dbe32b8def327..8885824d48d51 100644 --- a/.github/native-tests.json +++ b/.github/native-tests.json @@ -86,8 +86,8 @@ }, { "category": "Cache", - "timeout": 65, - "test-modules": "infinispan-cache-jpa, infinispan-client, cache, redis-cache", + "timeout": 75, + "test-modules": "infinispan-cache-jpa, infinispan-client, cache, redis-cache, infinispan-cache", "os-name": "ubuntu-latest" }, { diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 382ec161b8619..9d9b7e1efc987 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1265,6 +1265,16 @@ quarkus-infinispan-client-deployment ${project.version} + + io.quarkus + quarkus-infinispan-cache + ${project.version} + + + io.quarkus + quarkus-infinispan-cache-deployment + ${project.version} + io.quarkus quarkus-jdbc-db2 diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 426891e36c7da..22bf08a97b911 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -889,6 +889,19 @@ + + io.quarkus + quarkus-infinispan-cache + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-infinispan-client diff --git a/docs/pom.xml b/docs/pom.xml index e70a317eb71e5..0c68d57da90a6 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -905,6 +905,19 @@ + + io.quarkus + quarkus-infinispan-cache-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-infinispan-client-deployment diff --git a/docs/src/main/asciidoc/cache-infinispan-reference.adoc b/docs/src/main/asciidoc/cache-infinispan-reference.adoc new file mode 100644 index 0000000000000..4b62a29739d54 --- /dev/null +++ b/docs/src/main/asciidoc/cache-infinispan-reference.adoc @@ -0,0 +1,138 @@ +//// +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 +//// += Infinispan Cache +:extension-status: preview +include::_attributes.adoc[] +:categories: data +:summary: Use Infinispan as the Quarkus cache backend +:topics: infinispan,cache,data +:extensions: io.quarkus:quarkus-infinispan-cache,io.quarkus:quarkus-infinispan-client + +By default, Quarkus Cache uses Caffeine as backend. +It's possible to use Infinispan instead. + +include::{includes}/extension-status.adoc[] + +== Infinispan as cache backend + +When using Infinispan as the backend for Quarkus cache, each cached item will be stored in Infinispan: + +- The backend uses the __ Infinispan client (unless configured differently), so ensure its configuration is +set up accordingly (or use the xref:infinispan-dev-services.adoc[Infinispan Dev Service]) +- Both the key and the value are marshalled using Protobuf with Protostream. + +== Use the Infinispan backend + +First, add the `quarkus-infinispan-cache` extension to your project: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-infinispan-cache + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +implementation("io.quarkus:quarkus-infinispan-cache") +---- + +Then, use the `@CacheResult` and other cache annotations as detailed 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() { + +} +---- + +[[infinispan-cache-configuration-reference]] +== Configure the Infinispan backend + +The Infinispan backend uses the `` Infinispan client. +Refer to the xref:infinispan-client-reference.adoc[Infinispan reference] for configuring the access to Infinispan. + +TIP: In dev mode, you can use the xref:infinispan-dev-services.adoc[Infinispan Dev Service]. + +If you want to use another Infinispan for your cache, configure the `client-name` as follows: + +[source, properties] +---- +quarkus.cache.infinispan.client-name=another +---- + +== Marshalling + +When interacting with Infinispan in Quarkus, you can easily marshal and unmarshal +Java simple types when writing to or reading from the cache. However, when dealing +with Plain Old Java Objects (POJOs), users of Infinispan need to provide the marshalling +schema. + +[source, java] +---- +@Proto +public record ExpensiveResponse(String result) { +} + +@ProtoSchema(includeClasses = { ExpensiveResponse.class }) +interface Schema extends GeneratedSchema { +} +---- + +Read more about it in the xref:infinispan-client-reference.adoc[Infinispan reference] in the Annotation +based serialization section. + +== Expiration + +You have the option to configure two properties for data expiration: *lifespan* and *max-idle*. + +=== Lifespan + +In Infinispan, *lifespan* refers to a configuration parameter that determines the maximum time an +entry (or an object) can remain in the cache since it was created or last accessed before it is +considered expired and removed from the cache. + +When you configure the *lifespan* parameter for entries in an Infinispan cache, +you specify a time duration. After an entry has been added to the cache or accessed +(read or written), it starts its lifespan countdown. If the time since the entry +was created or last accessed exceeds the specified "lifespan" duration, the entry +is considered expired and becomes eligible for eviction from the cache. + +[source, properties] +---- +quarkus.cache.infinispan.my-cache.lifespan=10s +---- + +=== Max Idle +When you configure the *max-idle* parameter for entries in an Infinispan cache, you specify a time +duration. After an entry has been accessed (read or written) in the cache, if there are no subsequent +accesses to that entry within the specified duration, it is considered idle. Once the idle time +exceeds the *max-idle* duration, the entry is considered expired and eligible for eviction from +the cache. + +[source, properties] +---- +quarkus.cache.infinispan.my-cache.max-idle=100s +---- + +include::{generated-dir}/config/quarkus-cache-infinispan.adoc[opts=optional, leveloffset=+1] \ No newline at end of file diff --git a/docs/src/main/asciidoc/infinispan-client-reference.adoc b/docs/src/main/asciidoc/infinispan-client-reference.adoc index 665217686267d..8e00c704a1b28 100644 --- a/docs/src/main/asciidoc/infinispan-client-reference.adoc +++ b/docs/src/main/asciidoc/infinispan-client-reference.adoc @@ -318,36 +318,19 @@ some additional steps that are detailed here. Let's say we have the following us .Author.java [source,java] ---- -public class Author { - private final String name; - private final String surname; - - public Author(String name, String surname) { - this.name = Objects.requireNonNull(name); - this.surname = Objects.requireNonNull(surname); - } - // Getter/Setter/equals/hashCode/toString omitted +public record Author(String name, String surname) { } ---- .Book.java [source,java] ---- -public class Book { - private final String title; - private final String description; - private final int publicationYear; - private final Set authors; - private final BigDecimal price; - - public Book(String title, String description, int publicationYear, Set authors, BigDecimal price) { - this.title = Objects.requireNonNull(title); - this.description = Objects.requireNonNull(description); - this.publicationYear = publicationYear; - this.authors = Objects.requireNonNull(authors); - this.price = price; - } - // Getter/Setter/equals/hashCode/toString omitted +public record Book(String title, + String description, + int publicationYear, + Set authors, + Type bookType, + BigDecimal price) { } ---- @@ -711,7 +694,15 @@ https://infinispan.org/docs/stable/titles/rest/rest.html#rest_v2_protobuf_schema https://infinispan.org/docs/stable/titles/encoding/encoding.html#registering-sci-remote-caches_marshalling[Hot Rod Java Client]. [[infinispan-annotations-api]] -== Caching using annotations +=== Caching using annotations + +[IMPORTANT] +==== +Infinispan Caching annotations are deprecated *in this extension* and will be removed. +Use or replace your annotations by using the xref:cache-infinispan-reference.adoc[Infinispan Cache extension]. +Update your import statements to use the annotations from `io.quarkus.cache` package instead of +`io.quarkus.infinispan.client`. +==== The Infinispan Client extension offers a set of annotations that can be used in a CDI managed bean to enable caching abilities with Infinispan. diff --git a/extensions/infinispan-cache/deployment/pom.xml b/extensions/infinispan-cache/deployment/pom.xml new file mode 100644 index 0000000000000..9577d042bdd15 --- /dev/null +++ b/extensions/infinispan-cache/deployment/pom.xml @@ -0,0 +1,94 @@ + + + + quarkus-infinispan-cache-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-infinispan-cache-deployment + Quarkus - Infinispan - Cache - Deployment + + + + io.quarkus + quarkus-infinispan-client-deployment + + + io.quarkus + quarkus-cache-deployment + + + io.quarkus + quarkus-infinispan-cache + + + io.quarkus + quarkus-junit5-internal + test + + + org.assertj + assertj-core + test + + + io.rest-assured + rest-assured + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + maven-surefire-plugin + + true + + + + + + + + test-infinispan + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + \ No newline at end of file diff --git a/extensions/infinispan-cache/deployment/src/main/java/io/quarkus/cache/infinispan/deployment/InfinispanCacheProcessor.java b/extensions/infinispan-cache/deployment/src/main/java/io/quarkus/cache/infinispan/deployment/InfinispanCacheProcessor.java new file mode 100644 index 0000000000000..a51cbb19cf5fd --- /dev/null +++ b/extensions/infinispan-cache/deployment/src/main/java/io/quarkus/cache/infinispan/deployment/InfinispanCacheProcessor.java @@ -0,0 +1,52 @@ +package io.quarkus.cache.infinispan.deployment; + +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; + +import org.infinispan.client.hotrod.RemoteCacheManager; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.UnremovableBeanBuildItem; +import io.quarkus.cache.CompositeCacheKey; +import io.quarkus.cache.deployment.spi.CacheManagerInfoBuildItem; +import io.quarkus.cache.infinispan.runtime.CompositeKeyMarshallerBean; +import io.quarkus.cache.infinispan.runtime.InfinispanCacheBuildRecorder; +import io.quarkus.cache.infinispan.runtime.InfinispanCachesBuildTimeConfig; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.infinispan.client.deployment.InfinispanClientNameBuildItem; +import io.quarkus.infinispan.client.runtime.InfinispanClientUtil; + +public class InfinispanCacheProcessor { + + @BuildStep + @Record(RUNTIME_INIT) + CacheManagerInfoBuildItem cacheManagerInfo(BuildProducer syntheticBeanBuildItemBuildProducer, + InfinispanCacheBuildRecorder recorder) { + return new CacheManagerInfoBuildItem(recorder.getCacheManagerSupplier()); + } + + @BuildStep + void ensureAdditionalBeans(BuildProducer additionalBeans) { + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(CompositeKeyMarshallerBean.class)); + } + + @BuildStep + UnremovableBeanBuildItem ensureBeanLookupAvailable() { + return UnremovableBeanBuildItem.beanTypes(RemoteCacheManager.class); + } + + @BuildStep + InfinispanClientNameBuildItem requestedInfinispanClientBuildItem(InfinispanCachesBuildTimeConfig buildConfig) { + return new InfinispanClientNameBuildItem( + buildConfig.clientName.orElse(InfinispanClientUtil.DEFAULT_INFINISPAN_CLIENT_NAME)); + } + + @BuildStep + void nativeImage(BuildProducer producer) { + producer.produce(ReflectiveClassBuildItem.builder(CompositeCacheKey.class).methods(true).build()); + } + +} diff --git a/extensions/infinispan-cache/deployment/src/main/resources/application.properties b/extensions/infinispan-cache/deployment/src/main/resources/application.properties new file mode 100644 index 0000000000000..a01fe24813cbd --- /dev/null +++ b/extensions/infinispan-cache/deployment/src/main/resources/application.properties @@ -0,0 +1,2 @@ +# To override "quarkus.cache.type" io.quarkus.cache.runtime.CacheBuildConfig#type() +quarkus.cache.type=infinispan diff --git a/extensions/infinispan-cache/deployment/src/test/java/io/quarkus/cache/infinispan/InfinispanCacheTest.java b/extensions/infinispan-cache/deployment/src/test/java/io/quarkus/cache/infinispan/InfinispanCacheTest.java new file mode 100644 index 0000000000000..0fe55a3210876 --- /dev/null +++ b/extensions/infinispan-cache/deployment/src/test/java/io/quarkus/cache/infinispan/InfinispanCacheTest.java @@ -0,0 +1,355 @@ +package io.quarkus.cache.infinispan; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import jakarta.inject.Inject; + +import org.infinispan.client.hotrod.RemoteCache; +import org.infinispan.commons.jdkspecific.ThreadCreator; +import org.infinispan.commons.util.NullValue; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.cache.Cache; +import io.quarkus.cache.CompositeCacheKey; +import io.quarkus.cache.infinispan.runtime.InfinispanCacheImpl; +import io.quarkus.cache.infinispan.runtime.InfinispanCacheInfo; +import io.quarkus.infinispan.client.Remote; +import io.quarkus.logging.Log; +import io.quarkus.test.QuarkusUnitTest; +import io.smallrye.mutiny.Uni; + +public class InfinispanCacheTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withEmptyApplication() + .withConfigurationResource("empty-application-infinispan-client.properties"); + + private static final String CACHE_NAME = "cache"; + + private static final ThreadFactory defaultThreadFactory = getTestThreadFactory("ForkThread"); + private static final ExecutorService testExecutor = ThreadCreator.createBlockingExecutorService() + .orElseGet(() -> new ThreadPoolExecutor(0, Integer.MAX_VALUE, + 60L, TimeUnit.SECONDS, + new SynchronousQueue<>(), + defaultThreadFactory)); + @Inject + @Remote(CACHE_NAME) + RemoteCache remoteCache; + + @BeforeEach + void clear() { + try { + remoteCache.clear(); + } catch (Exception ignored) { + // ignored. + } + } + + @AfterAll + public static void shutdown() { + testExecutor.shutdown(); + } + + @Test + public void testGetName() { + Cache cache = getCache(); + assertThat(cache.getName()).isEqualTo(CACHE_NAME); + } + + @Test + public void testGetDefaultKey() { + Cache cache = getCache(); + assertThat(cache.getDefaultKey()).isEqualTo("default-key"); + } + + @Test + public void testGetWithLifespan() throws Exception { + Cache cache = getCache(2, -1); + String id = generateId(); + String value = awaitUni(cache.get(id, key -> "one")); + assertThat(value).isEqualTo("one"); + value = awaitUni(cache.get(id, key -> "two")); + assertThat(value).isEqualTo("one"); + assertThat(remoteCache.get(id)).isEqualTo("one"); + // Wait lifespan expiration + await().atMost(Duration.ofSeconds(3)).until(() -> remoteCache.get(id) == null); + // key has expired + assertThat(remoteCache.get(id)).isNull(); + value = awaitUni(cache.get(id, key -> "two")); + assertThat(value).isEqualTo("two"); + assertThat(remoteCache.get(id)).isEqualTo("two"); + // Wait lifespan expiration + await().atMost(Duration.ofSeconds(3)).until(() -> remoteCache.get(id) == null); + assertThat(remoteCache.get(id)).isNull(); + } + + @Test + public void testGetWithWithMaxidle() { + Cache cache = getCache(-1, 3); + String id = generateId(); + String value = awaitUni(cache.get(id, key -> "one")); + assertThat(value).isEqualTo("one"); + value = awaitUni(cache.get(id, key -> "two")); + assertThat(value).isEqualTo("one"); + assertThat(remoteCache.get(id)).isEqualTo("one"); + // Wait maxidle expiration + await().pollDelay(Duration.ofSeconds(3)).untilAsserted(() -> assertThat(true).isTrue()); + // key has expired + assertThat(remoteCache.get(id)).isNull(); + value = awaitUni(cache.get(id, key -> "two")); + assertThat(value).isEqualTo("two"); + assertThat(remoteCache.get(id)).isEqualTo("two"); + // Wait maxidle expiration + await().pollDelay(Duration.ofSeconds(3)).untilAsserted(() -> assertThat(true).isTrue()); + assertThat(remoteCache.get(id)).isNull(); + } + + @Test + public void testGetWithNullValues() { + Cache cache = getCache(); + String id = generateId(); + String value = awaitUni(cache.get(id, key -> null)); + assertThat(value).isEqualTo(null); + assertThat(remoteCache.get(id)).isEqualTo(NullValue.NULL); + } + + protected Future fork(Callable c) { + return testExecutor.submit(new CallableWrapper<>(c)); + } + + private static class CallableWrapper implements Callable { + private final Callable c; + + CallableWrapper(Callable c) { + this.c = c; + } + + @Override + public T call() throws Exception { + try { + Log.trace("Started fork callable.."); + T result = c.call(); + Log.debug("Exiting fork callable."); + return result; + } catch (Exception e) { + Log.warn("Exiting fork callable due to exception", e); + throw e; + } + } + } + + protected static ThreadFactory getTestThreadFactory(final String prefix) { + final String className = InfinispanCacheTest.class.getSimpleName(); + + return new ThreadFactory() { + private final AtomicInteger counter = new AtomicInteger(0); + + @Override + public Thread newThread(Runnable r) { + String threadName = prefix + "-" + counter.incrementAndGet() + "," + className; + return new Thread(r, threadName); + } + }; + } + + @Test + public void testGetWithParallelCalls() throws Exception { + CyclicBarrier barrier = new CyclicBarrier(2); + Cache cache = getCache(); + String id = generateId(); + Future thread1 = fork(() -> cache.get(id, key -> { + try { + // In order to avoid it to be a flaky test, first call is to make sure we are inside the lambda. + // The second to wait inside the lambda until we issue the second request on line 193 + barrier.await(10, TimeUnit.SECONDS); + barrier.await(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + return "thread1"; + }).await().atMost(Duration.ofSeconds(10))); + + // Ensure first retrieval is in lambda before continuing + barrier.await(10, TimeUnit.SECONDS); + + Future thread2 = fork(() -> cache.get(id, key -> "thread2").await().atMost(Duration.ofSeconds(10))); + + barrier.await(1, TimeUnit.SECONDS); + + String valueObtainedByThread1 = thread1.get(10, TimeUnit.SECONDS); + String valueObtainedByThread2 = thread2.get(10, TimeUnit.SECONDS); + + assertThat(remoteCache.get(id)).isEqualTo("thread1"); + assertThat(valueObtainedByThread1).isEqualTo("thread1"); + assertThat(valueObtainedByThread2).isEqualTo("thread1"); + } + + @Test + public void testGetAsyncWithLifespan() { + Cache cache = getCache(2, -1); + String id = generateId(); + String value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("one"))); + assertThat(value).isEqualTo("one"); + value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("two"))); + assertThat(value).isEqualTo("one"); + assertThat(remoteCache.get(id)).isEqualTo("one"); + // Wait lifespan expiration + await().atMost(Duration.ofSeconds(3)).until(() -> remoteCache.get(id) == null); + // key has expired + assertThat(remoteCache.get(id)).isNull(); + value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("two"))); + assertThat(value).isEqualTo("two"); + assertThat(remoteCache.get(id)).isEqualTo("two"); + // Wait lifespan expiration + await().atMost(Duration.ofSeconds(3)).until(() -> remoteCache.get(id) == null); + assertThat(remoteCache.get(id)).isNull(); + } + + @Test + public void testGetAsyncWithWithMaxidle() { + Cache cache = getCache(-1, 3); + String id = generateId(); + String value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("one"))); + assertThat(value).isEqualTo("one"); + value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("two"))); + assertThat(value).isEqualTo("one"); + assertThat(remoteCache.get(id)).isEqualTo("one"); + // Wait maxidle expiration + await().pollDelay(Duration.ofSeconds(3)).untilAsserted(() -> assertThat(true).isTrue()); + // key has expired + assertThat(remoteCache.get(id)).isNull(); + value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().item("two"))); + assertThat(value).isEqualTo("two"); + assertThat(remoteCache.get(id)).isEqualTo("two"); + // Wait maxidle expiration + await().pollDelay(Duration.ofSeconds(3)).untilAsserted(() -> assertThat(true).isTrue()); + assertThat(remoteCache.get(id)).isNull(); + } + + @Test + public void testGetAsyncWithNullValues() { + Cache cache = getCache(); + String id = generateId(); + String value = awaitUni(cache.getAsync(id, key -> Uni.createFrom().nullItem())); + assertThat(value).isEqualTo(null); + assertThat(remoteCache.get(id)).isEqualTo(NullValue.NULL); + } + + @Test + public void testGetAsyncWithParallelCalls() throws Exception { + CyclicBarrier barrier = new CyclicBarrier(2); + Cache cache = getCache(); + String id = generateId(); + Future> thread1 = fork(() -> cache.getAsync(id, key -> { + try { + barrier.await(10, TimeUnit.SECONDS); + barrier.await(10, TimeUnit.SECONDS); + } catch (Exception e) { + throw new RuntimeException(e); + } + return Uni.createFrom().item("thread1"); + })); + + // Ensure first retrieval is in lambda before continuing + barrier.await(10, TimeUnit.SECONDS); + + Future> thread2 = fork(() -> cache.getAsync(id, key -> Uni.createFrom().item("thread2"))); + + barrier.await(1, TimeUnit.SECONDS); + + String valueObtainedByThread1 = awaitUni(thread1.get(10, TimeUnit.SECONDS)); + String valueObtainedByThread2 = awaitUni(thread2.get(10, TimeUnit.SECONDS)); + + assertThat(remoteCache.get(id)).isEqualTo("thread1"); + assertThat(valueObtainedByThread1).isEqualTo("thread1"); + assertThat(valueObtainedByThread2).isEqualTo("thread1"); + } + + @Test + public void testInvalidate() { + Cache cache = getCache(); + String id = generateId(); + awaitUni(cache.get(id, key -> "value")); + assertThat(remoteCache.size()).isOne(); + assertThat(remoteCache.get(id)).isEqualTo("value"); + awaitUni(cache.invalidate(id)); + assertThat(remoteCache.size()).isZero(); + } + + @Test + public void testInvalidateIf() { + Cache cache = getCache(); + String id1 = generateId(); + String id2 = generateId(); + awaitUni(cache.get(id1, key -> "value")); + awaitUni(cache.get(id2, key -> null)); + assertThat(remoteCache.get(id1)).isEqualTo("value"); + assertThat(remoteCache.get(id2)).isEqualTo(NullValue.NULL); + + awaitUni(cache.invalidateIf(k -> k.equals(id2))); + + assertThat(remoteCache.containsKey(id1)).isTrue(); + assertThat(remoteCache.containsKey(id2)).isFalse(); + } + + @Test + public void testInvalidateAll() { + Cache cache = getCache(); + for (int i = 0; i < 10; i++) { + awaitUni(cache.get(generateId(), key -> "value")); + } + assertThat(remoteCache.size()).isEqualTo(10); + awaitUni(cache.invalidateAll()); + assertThat(remoteCache.size()).isZero(); + } + + @Test + public void testGetWithCompositeCacheKey() { + Cache cache = getCache(); + CompositeCacheKey compositeId = new CompositeCacheKey("id1", "id2"); + awaitUni(cache.get(compositeId, key -> "value")); + assertThat(remoteCache.get(compositeId)).isEqualTo("value"); + } + + private static String generateId() { + return UUID.randomUUID().toString(); + } + + private Cache getCache() { + InfinispanCacheInfo info = new InfinispanCacheInfo(); + info.name = CACHE_NAME; + info.lifespan = Optional.empty(); + info.maxIdle = Optional.empty(); + return new InfinispanCacheImpl(info, remoteCache); + } + + private Cache getCache(int lifespan, int maxidle) { + InfinispanCacheInfo info = new InfinispanCacheInfo(); + info.name = CACHE_NAME; + info.lifespan = Optional.of(Duration.ofSeconds(lifespan)); + info.maxIdle = Optional.of(Duration.ofSeconds(maxidle)); + return new InfinispanCacheImpl(info, remoteCache); + } + + private static T awaitUni(Uni uni) { + return uni.await().atMost(Duration.ofSeconds(10)); + } +} diff --git a/extensions/infinispan-cache/deployment/src/test/resources/empty-application-infinispan-client.properties b/extensions/infinispan-cache/deployment/src/test/resources/empty-application-infinispan-client.properties new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/extensions/infinispan-cache/pom.xml b/extensions/infinispan-cache/pom.xml new file mode 100644 index 0000000000000..f49b737d37859 --- /dev/null +++ b/extensions/infinispan-cache/pom.xml @@ -0,0 +1,20 @@ + + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-infinispan-cache-parent + Quarkus - Infinispan - Cache + pom + + deployment + runtime + + diff --git a/extensions/infinispan-cache/runtime/pom.xml b/extensions/infinispan-cache/runtime/pom.xml new file mode 100644 index 0000000000000..5ae742d0b1b13 --- /dev/null +++ b/extensions/infinispan-cache/runtime/pom.xml @@ -0,0 +1,79 @@ + + + + quarkus-infinispan-cache-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-infinispan-cache + Quarkus - Infinispan - Cache - Runtime + Implements quarkus-cache SPI + + + io.quarkus + quarkus-infinispan-client + + + io.quarkus + quarkus-cache + + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + maven-surefire-plugin + + true + + + + + + + + test-infinispan + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeCacheKeyMarshaller.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeCacheKeyMarshaller.java new file mode 100644 index 0000000000000..1a0d7e0929c0f --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeCacheKeyMarshaller.java @@ -0,0 +1,47 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import org.infinispan.protostream.MessageMarshaller; +import org.infinispan.protostream.WrappedMessage; + +import io.quarkus.cache.CompositeCacheKey; + +/** + * {@link CompositeCacheKey } protostream marshaller class + */ +public class CompositeCacheKeyMarshaller implements MessageMarshaller { + public static final String PACKAGE = "io.quarkus.cache.infinispan.internal"; + public static final String NAME = "CompositeCacheKey"; + public static final String FULL_NAME = PACKAGE + "." + NAME; + public static final String KEYS = "keys"; + + @Override + public CompositeCacheKey readFrom(ProtoStreamReader reader) throws IOException { + Object[] compositeKeys = reader.readCollection(KEYS, new ArrayList<>(), WrappedMessage.class).stream() + .map(we -> we.getValue()).collect(Collectors.toList()).toArray(); + return new CompositeCacheKey(compositeKeys); + } + + @Override + public void writeTo(ProtoStreamWriter writer, CompositeCacheKey compositeCacheKey) throws IOException { + List wrappedMessages = Arrays.stream(compositeCacheKey.getKeyElements()) + .map(e -> new WrappedMessage(e)) + .collect(Collectors.toList()); + writer.writeCollection(KEYS, wrappedMessages, WrappedMessage.class); + } + + @Override + public Class getJavaClass() { + return CompositeCacheKey.class; + } + + @Override + public String getTypeName() { + return FULL_NAME; + } +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeKeyMarshallerBean.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeKeyMarshallerBean.java new file mode 100644 index 0000000000000..9e979d37b82a2 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/CompositeKeyMarshallerBean.java @@ -0,0 +1,30 @@ +package io.quarkus.cache.infinispan.runtime; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; + +import org.infinispan.protostream.MessageMarshaller; +import org.infinispan.protostream.schema.Schema; +import org.infinispan.protostream.schema.Type; + +/** + * Produces the schema marshaller and protoschema to marshall {@link io.quarkus.cache.CompositeCacheKey} + */ +@ApplicationScoped +public class CompositeKeyMarshallerBean { + + @Produces + public Schema compositeKeySchema() { + return new Schema.Builder("io.quarkus.cache.infinispan.internal.cache.proto") + .packageName(CompositeCacheKeyMarshaller.PACKAGE) + .addImport("org/infinispan/protostream/message-wrapping.proto") + .addMessage(CompositeCacheKeyMarshaller.NAME) + .addRepeatedField(Type.create("org.infinispan.protostream.WrappedMessage"), CompositeCacheKeyMarshaller.KEYS, 1) + .build(); + } + + @Produces + public MessageMarshaller compositeKeyMarshaller() { + return new CompositeCacheKeyMarshaller(); + } +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheBuildRecorder.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheBuildRecorder.java new file mode 100644 index 0000000000000..19ad5ace35291 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheBuildRecorder.java @@ -0,0 +1,70 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +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 InfinispanCacheBuildRecorder { + + private static final Logger LOGGER = Logger.getLogger(InfinispanCacheBuildRecorder.class); + + private final InfinispanCachesBuildTimeConfig buildConfig; + private final RuntimeValue infinispanCacheConfigRV; + + public InfinispanCacheBuildRecorder(InfinispanCachesBuildTimeConfig buildConfig, + RuntimeValue infinispanCacheConfigRV) { + this.buildConfig = buildConfig; + this.infinispanCacheConfigRV = infinispanCacheConfigRV; + } + + public CacheManagerInfo getCacheManagerSupplier() { + return new CacheManagerInfo() { + @Override + public boolean supports(Context context) { + return context.cacheEnabled() && "infinispan".equals(context.cacheType()); + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Supplier get(Context context) { + return new Supplier() { + @Override + public CacheManager get() { + Set cacheInfos = InfinispanCacheInfoBuilder.build(context.cacheNames(), + buildConfig, + infinispanCacheConfigRV.getValue()); + 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 (InfinispanCacheInfo cacheInfo : cacheInfos) { + if (LOGGER.isDebugEnabled()) { + LOGGER.debugf( + "Building Infinispan cache [%s] with [lifespan=%s], [maxIdle=%s]", + cacheInfo.name, cacheInfo.lifespan, cacheInfo.maxIdle); + } + + InfinispanCacheImpl cache = new InfinispanCacheImpl(cacheInfo, buildConfig.clientName); + caches.put(cacheInfo.name, cache); + } + return new CacheManagerImpl(caches); + } + } + }; + } + }; + } +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheImpl.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheImpl.java new file mode 100644 index 0000000000000..a92f6f4e70fd7 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheImpl.java @@ -0,0 +1,181 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.Predicate; + +import org.infinispan.client.hotrod.RemoteCache; +import org.infinispan.client.hotrod.impl.protocol.Codec27; +import org.infinispan.commons.util.NullValue; +import org.infinispan.commons.util.concurrent.CompletionStages; +import org.reactivestreams.FlowAdapters; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcContainer; +import io.quarkus.cache.Cache; +import io.quarkus.cache.runtime.AbstractCache; +import io.quarkus.infinispan.client.runtime.InfinispanClientProducer; +import io.quarkus.infinispan.client.runtime.InfinispanClientUtil; +import io.smallrye.mutiny.Multi; +import io.smallrye.mutiny.Uni; + +/** + * This class is an internal Quarkus cache implementation using Infinispan. + * Do not use it explicitly from your Quarkus application. + */ +public class InfinispanCacheImpl extends AbstractCache implements Cache { + + private final RemoteCache remoteCache; + private final InfinispanCacheInfo cacheInfo; + private final Map computationResults = new ConcurrentHashMap<>(); + private final long lifespan; + private final long maxIdle; + + public InfinispanCacheImpl(InfinispanCacheInfo cacheInfo, RemoteCache remoteCache) { + this.cacheInfo = cacheInfo; + this.remoteCache = remoteCache; + this.lifespan = cacheInfo.lifespan.map(l -> l.toMillis()).orElse(-1L); + this.maxIdle = cacheInfo.maxIdle.map(m -> m.toMillis()).orElse(-1L); + } + + public InfinispanCacheImpl(InfinispanCacheInfo cacheInfo, + Optional infinispanClientName) { + this(cacheInfo, determineInfinispanClient(infinispanClientName, cacheInfo.name)); + } + + private static RemoteCache determineInfinispanClient(Optional infinispanCacheName, String cacheName) { + ArcContainer container = Arc.container(); + InfinispanClientProducer producer = container.instance(InfinispanClientProducer.class).get(); + return producer.getRemoteCache(infinispanCacheName.orElse(InfinispanClientUtil.DEFAULT_INFINISPAN_CLIENT_NAME), + cacheName); + } + + @Override + public String getName() { + return Objects.requireNonNullElse(cacheInfo.name, "default-infinispan-cache"); + } + + @Override + public Object getDefaultKey() { + return "default-key"; + } + + private Object encodeNull(Object value) { + return value != null ? value : NullValue.NULL; + } + + private T decodeNull(Object value) { + return value != NullValue.NULL ? (T) value : null; + } + + @Override + public Uni get(K key, Function valueLoader) { + return Uni.createFrom() + .completionStage(() -> CompletionStages.handleAndCompose(remoteCache.getAsync(key), (v1, ex1) -> { + if (ex1 != null) { + return CompletableFuture.failedFuture(ex1); + } + + if (v1 != null) { + return CompletableFuture.completedFuture(decodeNull(v1)); + } + + CompletableFuture resultAsync = new CompletableFuture<>(); + CompletableFuture computedValue = computationResults.putIfAbsent(key, resultAsync); + if (computedValue != null) { + return computedValue; + } + V newValue = valueLoader.apply(key); + remoteCache + .putIfAbsentAsync(key, encodeNull(newValue), lifespan, TimeUnit.MILLISECONDS, maxIdle, + TimeUnit.MILLISECONDS) + .whenComplete((existing, ex2) -> { + if (ex2 != null) { + resultAsync.completeExceptionally((Throwable) ex2); + } else if (existing == null) { + resultAsync.complete(newValue); + } else { + resultAsync.complete(decodeNull(existing)); + } + computationResults.remove(key); + }); + return resultAsync; + })); + } + + @Override + public Uni getAsync(K key, Function> valueLoader) { + return Uni.createFrom().completionStage(CompletionStages.handleAndCompose(remoteCache.getAsync(key), (v1, ex1) -> { + if (ex1 != null) { + return CompletableFuture.failedFuture(ex1); + } + + if (v1 != null) { + return CompletableFuture.completedFuture(decodeNull(v1)); + } + + CompletableFuture resultAsync = new CompletableFuture<>(); + CompletableFuture computedValue = computationResults.putIfAbsent(key, resultAsync); + if (computedValue != null) { + return computedValue; + } + valueLoader.apply(key).convert().toCompletionStage() + .whenComplete((newValue, ex2) -> { + if (ex2 != null) { + resultAsync.completeExceptionally(ex2); + computationResults.remove(key); + } else { + remoteCache.putIfAbsentAsync(key, encodeNull(newValue), lifespan, TimeUnit.MILLISECONDS, maxIdle, + TimeUnit.MILLISECONDS).whenComplete((existing, ex3) -> { + if (ex3 != null) { + resultAsync.completeExceptionally((Throwable) ex3); + } else if (existing == null) { + resultAsync.complete(newValue); + } else { + resultAsync.complete(decodeNull(existing)); + } + computationResults.remove(key); + }); + } + }); + return resultAsync; + })); + } + + @Override + public Uni invalidate(Object key) { + return Uni.createFrom().completionStage(() -> remoteCache.removeAsync(key)); + } + + @Override + public Uni invalidateAll() { + return Uni.createFrom().completionStage(() -> remoteCache.clearAsync()); + } + + @Override + public Uni invalidateIf(Predicate predicate) { + Flow.Publisher entriesPublisher = FlowAdapters + .toFlowPublisher(remoteCache.publishEntries(Codec27.EMPTY_VALUE_CONVERTER, null, null, 512)); + return Uni.createFrom().multi(Multi.createFrom().publisher(entriesPublisher) + .map(e -> ((Map.Entry) e).getKey()) + .filter(key -> predicate.test(key)) + .onItem() + .call(key -> Uni.createFrom().completionStage(remoteCache.removeAsync(key)))) + .replaceWithVoid(); + } + + @Override + public T as(Class type) { + if (type.getTypeName().equals(InfinispanCacheImpl.class.getTypeName())) { + return (T) this; + } + + throw new IllegalArgumentException("Class type not supported : " + type); + } +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfo.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfo.java new file mode 100644 index 0000000000000..60c936de92969 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfo.java @@ -0,0 +1,23 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.time.Duration; +import java.util.Optional; + +public class InfinispanCacheInfo { + + /** + * The cache name + */ + public String name; + + /** + * The default lifespan of the item stored in the cache + */ + public Optional lifespan = Optional.empty(); + + /** + * The default max-idle of the item stored in the cache + */ + public Optional maxIdle = Optional.empty(); + +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfoBuilder.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfoBuilder.java new file mode 100644 index 0000000000000..7519e082563a7 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheInfoBuilder.java @@ -0,0 +1,42 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.util.Collections; +import java.util.Set; + +import io.quarkus.runtime.configuration.HashSetFactory; + +public class InfinispanCacheInfoBuilder { + + public static Set build(Set cacheNames, InfinispanCachesBuildTimeConfig buildTimeConfig, + InfinispanCachesConfig runtimeConfig) { + if (cacheNames.isEmpty()) { + return Collections.emptySet(); + } else { + Set result = HashSetFactory. getInstance().apply(cacheNames.size()); + + for (String cacheName : cacheNames) { + + InfinispanCacheInfo cacheInfo = new InfinispanCacheInfo(); + cacheInfo.name = cacheName; + + InfinispanCacheRuntimeConfig defaultRuntimeConfig = runtimeConfig.defaultConfig; + InfinispanCacheRuntimeConfig namedRuntimeConfig = runtimeConfig.cachesConfig.get(cacheInfo.name); + + if (namedRuntimeConfig != null && namedRuntimeConfig.lifespan.isPresent()) { + cacheInfo.lifespan = namedRuntimeConfig.lifespan; + } else if (defaultRuntimeConfig.lifespan.isPresent()) { + cacheInfo.lifespan = defaultRuntimeConfig.lifespan; + } + + if (namedRuntimeConfig != null && namedRuntimeConfig.maxIdle.isPresent()) { + cacheInfo.maxIdle = namedRuntimeConfig.maxIdle; + } else if (defaultRuntimeConfig.maxIdle.isPresent()) { + cacheInfo.maxIdle = defaultRuntimeConfig.maxIdle; + } + + result.add(cacheInfo); + } + return result; + } + } +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheRuntimeConfig.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheRuntimeConfig.java new file mode 100644 index 0000000000000..51b22ff1ed9c7 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCacheRuntimeConfig.java @@ -0,0 +1,23 @@ +package io.quarkus.cache.infinispan.runtime; + +import java.time.Duration; +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigGroup; +import io.quarkus.runtime.annotations.ConfigItem; + +@ConfigGroup +public class InfinispanCacheRuntimeConfig { + /** + * The default lifespan of the item stored in the cache + */ + @ConfigItem + public Optional lifespan; + + /** + * The default max-idle of the item stored in the cache + */ + @ConfigItem + public Optional maxIdle; + +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesBuildTimeConfig.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesBuildTimeConfig.java new file mode 100644 index 0000000000000..66596bc5170a6 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesBuildTimeConfig.java @@ -0,0 +1,19 @@ +package io.quarkus.cache.infinispan.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.BUILD_AND_RUN_TIME_FIXED; + +import java.util.Optional; + +import io.quarkus.runtime.annotations.ConfigItem; +import io.quarkus.runtime.annotations.ConfigRoot; + +@ConfigRoot(phase = BUILD_AND_RUN_TIME_FIXED, name = "cache.infinispan") +public class InfinispanCachesBuildTimeConfig { + + /** + * The name of the named Infinispan client to be used for communicating with Infinispan. + * If not set, use the default Infinispan client. + */ + @ConfigItem + public Optional clientName; +} diff --git a/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesConfig.java b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesConfig.java new file mode 100644 index 0000000000000..c2533936392c8 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/java/io/quarkus/cache/infinispan/runtime/InfinispanCachesConfig.java @@ -0,0 +1,29 @@ +package io.quarkus.cache.infinispan.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.infinispan") +public class InfinispanCachesConfig { + + /** + * Default configuration applied to all Infinispan caches (lowest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + public InfinispanCacheRuntimeConfig defaultConfig; + + /** + * Additional configuration applied to a specific Infinispan cache (highest precedence) + */ + @ConfigItem(name = ConfigItem.PARENT) + @ConfigDocMapKey("cache-name") + @ConfigDocSection + Map cachesConfig; + +} diff --git a/extensions/infinispan-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/infinispan-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..ba4cb54091c21 --- /dev/null +++ b/extensions/infinispan-cache/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,14 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "Infinispan Cache" +metadata: + keywords: + - "infinispan" + - "cache" + guide: "https://quarkus.io/guides/cache-infinispan-reference" + categories: + - "data" + - "reactive" + status: "preview" + config: + - "quarkus.cache.infinispan" diff --git a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidate.java b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidate.java index d3056b4ef2c6b..3cdfc250512c6 100644 --- a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidate.java +++ b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidate.java @@ -18,11 +18,14 @@ * This annotation can be combined with {@link CacheResult} annotation on a single method. Caching operations will always * be executed in the same order: {@link CacheInvalidateAll} first, then {@link CacheInvalidate} and finally * {@link CacheResult}. + * + * @deprecated Use Infinispan Cache Extension */ @InterceptorBinding @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Repeatable(List.class) +@Deprecated(forRemoval = true) public @interface CacheInvalidate { /** diff --git a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidateAll.java b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidateAll.java index e25b9311ccd36..e8572eb1fda8e 100644 --- a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidateAll.java +++ b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheInvalidateAll.java @@ -18,11 +18,14 @@ * This annotation can be combined with {@link CacheResult} annotation on a single method. Caching operations will always * be executed in the same order: {@link CacheInvalidateAll} first, then {@link CacheInvalidate} and finally * {@link CacheResult}. + * + * @deprecated Use Infinispan Cache Extension */ @InterceptorBinding @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Repeatable(List.class) +@Deprecated(forRemoval = true) public @interface CacheInvalidateAll { /** diff --git a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheResult.java b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheResult.java index 6c1ac7a1e5c8e..95970dd85cd26 100644 --- a/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheResult.java +++ b/extensions/infinispan-client/runtime/src/main/java/io/quarkus/infinispan/client/CacheResult.java @@ -28,10 +28,13 @@ * annotations on a single method. Caching operations will always be executed in the same order: {@link CacheInvalidateAll} * first, then {@link CacheInvalidate} and finally {@link CacheResult}. *

+ * + * @deprecated Use Infinispan Cache Extension */ @InterceptorBinding @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) +@Deprecated(forRemoval = true) public @interface CacheResult { /** diff --git a/extensions/pom.xml b/extensions/pom.xml index ccd152cf03acc..aa266acf915e5 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -150,6 +150,7 @@ infinispan-client + infinispan-cache caffeine diff --git a/integration-tests/infinispan-cache/pom.xml b/integration-tests/infinispan-cache/pom.xml new file mode 100644 index 0000000000000..670df99e995ea --- /dev/null +++ b/integration-tests/infinispan-cache/pom.xml @@ -0,0 +1,144 @@ + + + 4.0.0 + + io.quarkus + quarkus-integration-tests-parent + 999-SNAPSHOT + ../pom.xml + + + quarkus-integration-test-infinispan-cache + Quarkus - Integration Tests - Infinispan Cache + + + + io.quarkus + quarkus-rest-jackson + + + io.quarkus + quarkus-rest-client-jackson + + + io.quarkus + quarkus-infinispan-cache + + + + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + + + + + io.quarkus + quarkus-infinispan-cache-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + io.quarkus + quarkus-rest-client-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-infinispan + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + maven-failsafe-plugin + + false + + + + + + + + diff --git a/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/ExpensiveResource.java b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/ExpensiveResource.java new file mode 100644 index 0000000000000..326c01a3c7b45 --- /dev/null +++ b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/ExpensiveResource.java @@ -0,0 +1,53 @@ +package io.quarkus.it.cache.infinispan; + +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 org.infinispan.protostream.GeneratedSchema; +import org.infinispan.protostream.annotations.Proto; +import org.infinispan.protostream.annotations.ProtoSchema; + +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(); + return new ExpensiveResponse(keyElement1 + " " + keyElement2 + " " + keyElement3 + " too!"); + } + + @POST + @CacheInvalidateAll(cacheName = "expensiveResourceCache") + public void invalidateAll() { + + } + + @GET + @Path("/invocations") + public int getInvocations() { + return invocations.get(); + } + + @Proto + public record ExpensiveResponse(String result) { + } + + @ProtoSchema(includeClasses = { ExpensiveResponse.class }) + interface Schema extends GeneratedSchema { + } +} diff --git a/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/RestClientResource.java b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/RestClientResource.java new file mode 100644 index 0000000000000..09d8ea5978660 --- /dev/null +++ b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/RestClientResource.java @@ -0,0 +1,88 @@ +package io.quarkus.it.cache.infinispan; + +import java.util.Set; +import java.util.function.Function; + +import jakarta.inject.Inject; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.HttpHeaders; + +import org.eclipse.microprofile.rest.client.inject.RestClient; +import org.jboss.resteasy.reactive.RestPath; +import org.jboss.resteasy.reactive.RestQuery; +import org.jboss.resteasy.reactive.RestResponse; + +import io.quarkus.runtime.BlockingOperationControl; +import io.smallrye.mutiny.Uni; + +@Path("rest-client") +public class RestClientResource { + + @RestClient + SunriseRestClient sunriseRestClient; + + @Inject + HttpHeaders headers; // used in order to make sure that @RequestScoped beans continue to work despite the cache coming into play + + @GET + @Path("time/{city}") + public RestResponse getSunriseTime(@RestPath String city, @RestQuery String date) { + Set incomingHeadersBeforeRestCall = headers.getRequestHeaders().keySet(); + String restResponse = sunriseRestClient.getSunriseTime(city, date); + Set incomingHeadersAfterRestCall = headers.getRequestHeaders().keySet(); + return RestResponse.ResponseBuilder + .ok(restResponse) + .header("before", String.join(", ", incomingHeadersBeforeRestCall)) + .header("after", String.join(", ", incomingHeadersAfterRestCall)) + .header("blockingAllowed", BlockingOperationControl.isBlockingAllowed()) + .build(); + } + + @GET + @Path("async/time/{city}") + public Uni> getAsyncSunriseTime(@RestPath String city, @RestQuery String date) { + Set incomingHeadersBeforeRestCall = headers.getRequestHeaders().keySet(); + return sunriseRestClient.getAsyncSunriseTime(city, date).onItem().transform(new Function<>() { + @Override + public RestResponse apply(String restResponse) { + Set incomingHeadersAfterRestCall = headers.getRequestHeaders().keySet(); + return RestResponse.ResponseBuilder + .ok(restResponse) + .header("before", String.join(", ", incomingHeadersBeforeRestCall)) + .header("after", String.join(", ", incomingHeadersAfterRestCall)) + .header("blockingAllowed", BlockingOperationControl.isBlockingAllowed()) + .build(); + } + }); + } + + @GET + @Path("invocations") + public Integer getSunriseTimeInvocations() { + return sunriseRestClient.getSunriseTimeInvocations(); + } + + @DELETE + @Path("invalidate/{city}") + public Uni> invalidate(@RestPath String city, @RestQuery String notPartOfTheCacheKey, + @RestQuery String date) { + return sunriseRestClient.invalidate(city, notPartOfTheCacheKey, date).onItem().transform( + new Function<>() { + @Override + public RestResponse apply(Void unused) { + return RestResponse.ResponseBuilder. create(RestResponse.Status.NO_CONTENT) + .header("blockingAllowed", BlockingOperationControl.isBlockingAllowed()) + .header("incoming", String.join(", ", headers.getRequestHeaders().keySet())) + .build(); + } + }); + } + + @DELETE + @Path("invalidate") + public void invalidateAll() { + sunriseRestClient.invalidateAll(); + } +} diff --git a/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestClient.java b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestClient.java new file mode 100644 index 0000000000000..f8ee388c9c2ee --- /dev/null +++ b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestClient.java @@ -0,0 +1,52 @@ +package io.quarkus.it.cache.infinispan; + +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; +import org.jboss.resteasy.reactive.RestPath; +import org.jboss.resteasy.reactive.RestQuery; + +import io.quarkus.cache.CacheInvalidate; +import io.quarkus.cache.CacheInvalidateAll; +import io.quarkus.cache.CacheKey; +import io.quarkus.cache.CacheResult; +import io.smallrye.mutiny.Uni; + +@RegisterRestClient +@Path("sunrise") +public interface SunriseRestClient { + + String CACHE_NAME = "sunrise-cache"; + + @GET + @Path("time/{city}") + @CacheResult(cacheName = CACHE_NAME) + String getSunriseTime(@RestPath String city, @RestQuery String date); + + @GET + @Path("time/{city}") + @CacheResult(cacheName = CACHE_NAME) + Uni getAsyncSunriseTime(@RestPath String city, @RestQuery String date); + + @GET + @Path("invocations") + Integer getSunriseTimeInvocations(); + + /* + * The following methods wouldn't make sense in a real-life application but it's not relevant here. We only need to check if + * the caching annotations work as intended with the rest-client extension. + */ + + @DELETE + @Path("invalidate/{city}") + @CacheInvalidate(cacheName = CACHE_NAME) + Uni invalidate(@CacheKey @RestPath String city, @RestQuery String notPartOfTheCacheKey, + @CacheKey @RestPath String date); + + @DELETE + @Path("invalidate") + @CacheInvalidateAll(cacheName = CACHE_NAME) + void invalidateAll(); +} diff --git a/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestServerResource.java b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestServerResource.java new file mode 100644 index 0000000000000..fb21d7c9e7e91 --- /dev/null +++ b/integration-tests/infinispan-cache/src/main/java/io/quarkus/it/cache/infinispan/SunriseRestServerResource.java @@ -0,0 +1,41 @@ +package io.quarkus.it.cache.infinispan; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.jboss.resteasy.reactive.RestPath; +import org.jboss.resteasy.reactive.RestQuery; + +@ApplicationScoped +@Path("sunrise") +public class SunriseRestServerResource { + + private int sunriseTimeInvocations; + + @GET + @Path("time/{city}") + public String getSunriseTime(@RestPath String city, @RestQuery String date) { + sunriseTimeInvocations++; + return "2020-12-20T10:15:30"; + } + + @GET + @Path("invocations") + public Integer getSunriseTimeInvocations() { + return sunriseTimeInvocations; + } + + @DELETE + @Path("invalidate/{city}") + public void invalidate(@RestPath String city, @RestQuery String notPartOfTheCacheKey, @RestQuery String date) { + // Do nothing. We only need to test the caching annotation on the client side. + } + + @DELETE + @Path("invalidate") + public void invalidateAll() { + // Do nothing. We only need to test the caching annotation on the client side. + } +} diff --git a/integration-tests/infinispan-cache/src/main/resources/application.properties b/integration-tests/infinispan-cache/src/main/resources/application.properties new file mode 100644 index 0000000000000..25525ca3c8e4f --- /dev/null +++ b/integration-tests/infinispan-cache/src/main/resources/application.properties @@ -0,0 +1 @@ +io.quarkus.it.cache.infinispan.SunriseRestClient/mp-rest/url=${test.url} diff --git a/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheIT.java b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheIT.java new file mode 100644 index 0000000000000..de4fb795de42b --- /dev/null +++ b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheIT.java @@ -0,0 +1,7 @@ +package io.quarkus.it.cache.infinispan; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +public class CacheIT extends CacheTest { +} diff --git a/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheTest.java b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheTest.java new file mode 100644 index 0000000000000..b5a73d4558a6f --- /dev/null +++ b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/CacheTest.java @@ -0,0 +1,33 @@ +package io.quarkus.it.cache.infinispan; + +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!")); + } +} diff --git a/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/InfinspanCacheClientTestCase.java b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/InfinspanCacheClientTestCase.java new file mode 100644 index 0000000000000..ccfa3c8dd5fc8 --- /dev/null +++ b/integration-tests/infinispan-cache/src/test/java/io/quarkus/it/cache/infinispan/InfinspanCacheClientTestCase.java @@ -0,0 +1,87 @@ +package io.quarkus.it.cache.infinispan; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.CoreMatchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.Headers; + +@QuarkusTest +@DisplayName("Tests the integration between the infinispan cache and the rest-client extensions") +public class InfinspanCacheClientTestCase { + + private static final String CITY = "Toulouse"; + private static final String TODAY = "2020-12-20"; + + @Test + public void test() { + assertInvocations("0"); + getSunriseTimeInvocations(); + assertInvocations("1"); + getSunriseTimeInvocations(); + assertInvocations("1"); + getAsyncSunriseTimeInvocations(); + assertInvocations("1"); + invalidate(); + getSunriseTimeInvocations(); + assertInvocations("2"); + invalidateAll(); + getSunriseTimeInvocations(); + assertInvocations("3"); + } + + private void assertInvocations(String expectedInvocations) { + given() + .when() + .get("/rest-client/invocations") + .then() + .statusCode(200) + .body(equalTo(expectedInvocations)); + } + + private void getSunriseTimeInvocations() { + doGetSunriseTimeInvocations("/rest-client/time/{city}", true); + } + + private void getAsyncSunriseTimeInvocations() { + doGetSunriseTimeInvocations("/rest-client/async/time/{city}", false); + } + + private void doGetSunriseTimeInvocations(String path, Boolean blockingAllowed) { + Headers headers = given() + .queryParam("date", TODAY) + .when() + .get(path, CITY) + .then() + .statusCode(200) + .extract().headers(); + assertEquals(headers.get("before").getValue(), headers.get("after").getValue()); + assertEquals(blockingAllowed.toString(), headers.get("blockingAllowed").getValue()); + } + + private void invalidate() { + Headers headers = given() + .queryParam("date", TODAY) + .queryParam("notPartOfTheCacheKey", "notPartOfTheCacheKey") + .when() + .delete("/rest-client/invalidate/{city}", CITY) + .then() + .statusCode(204) + .extract().headers(); + assertNotNull(headers.get("incoming").getValue()); + assertEquals("false", headers.get("blockingAllowed").getValue()); + } + + private void invalidateAll() { + given() + .when() + .delete("/rest-client/invalidate") + .then() + .statusCode(204); + } +} diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index a17b168d5cd73..e963d51266bd8 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -176,6 +176,7 @@ hibernate-validator-resteasy-reactive common-jpa-entities infinispan-client + infinispan-cache devtools devtools-registry-client gradle