diff --git a/app/server/pom.xml b/app/server/pom.xml index ec07573846..ff23f42546 100644 --- a/app/server/pom.xml +++ b/app/server/pom.xml @@ -72,6 +72,7 @@ appsmith-plugins appsmith-server appsmith-git + reactive-caching diff --git a/app/server/reactive-caching/pom.xml b/app/server/reactive-caching/pom.xml new file mode 100644 index 0000000000..b3ad052fb5 --- /dev/null +++ b/app/server/reactive-caching/pom.xml @@ -0,0 +1,125 @@ + + + + + com.appsmith + integrated + 1.0-SNAPSHOT + + + 4.0.0 + com.appsmith + reactiveCaching + 1.0-SNAPSHOT + + reactiveCaching + + + UTF-8 + 11 + 3.4.1 + 1.18.22 + 1.17.2 + 7.2.5.RELEASE + 2.22.0 + + + + + org.pf4j + pf4j + ${org.pf4j.version} + + + + io.projectreactor + reactor-core + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + + + + + + + org.junit.jupiter + junit-jupiter-api + test + + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.projectlombok + lombok + ${org.projectlombok.version} + provided + + + + org.assertj + assertj-core + test + + + + org.testcontainers + junit-jupiter + ${org.testcontainers.junit-jupiter.version} + test + + + + uk.co.jemos.podam + podam + ${uk.co.jemos.podam.podam.version} + test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + test + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + ${maven-surefire-plugin.version} + + + + + diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/Cache.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/Cache.java new file mode 100644 index 0000000000..4ac132a03d --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/Cache.java @@ -0,0 +1,26 @@ +package com.appsmith.caching.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to mark a method to cache the result of a method call. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Cache { + + /* + * This is the name of the cache. + */ + String cacheName(); + + /** + * SPEL expression used to generate the key for the method call + * All method arguments can be used in the expression + */ + String key() default ""; + +} diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/CacheEvict.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/CacheEvict.java new file mode 100644 index 0000000000..99cc9a7e9a --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/annotations/CacheEvict.java @@ -0,0 +1,31 @@ +package com.appsmith.caching.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to mark a method as a reactive cache evictor. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface CacheEvict { + + /* + * This is the name of the cache. + */ + String cacheName(); + + /** + * SPEL expression used to generate the key for the method call + * All method arguments can be used in the expression + */ + String key() default ""; + + /** + * Whether to evict all keys for a given cache name. + */ + boolean all() default false; + +} diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/CacheAspect.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/CacheAspect.java new file mode 100644 index 0000000000..3e66dbf43f --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/aspects/CacheAspect.java @@ -0,0 +1,204 @@ +package com.appsmith.caching.aspects; + +import java.lang.reflect.Method; +import java.util.List; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.interceptor.SimpleKey; +import org.springframework.context.annotation.Configuration; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import com.appsmith.caching.annotations.CacheEvict; +import com.appsmith.caching.annotations.Cache; +import com.appsmith.caching.components.CacheManager; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * CacheAspect is an aspect that is used to cache the results of a method call annotated with Cache. + * It is also possible to evict the cached result by annotating method with CacheEvict. + */ +@Aspect +@Configuration +@Slf4j +public class CacheAspect { + + private final CacheManager cacheManager; + + public static final ExpressionParser EXPRESSION_PARSER = new SpelExpressionParser(); + + @Autowired + public CacheAspect(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + /** + * This method is used to call original Mono returning method and return the the result after caching it with CacheManager + * @param joinPoint The join point of the method call + * @param cacheName The name of the cache + * @param key The key to be used for caching + * @return The result of the method call + */ + private Mono callMonoMethodAndCache(ProceedingJoinPoint joinPoint, String cacheName, String key) { + try { + return ((Mono) joinPoint.proceed()) + .zipWhen(value -> cacheManager.put(cacheName, key, value)) //Call CacheManager.put() to cache the object + .flatMap(value -> Mono.just(value.getT1())); //Maps to the original object + } catch (Throwable e) { + log.error("Error occurred in saving to cache when invoking function {}", joinPoint.getSignature().getName(), e); + return Mono.error(e); + } + } + + + /** + * This method is used to call original Flux returning method and return the the result after caching it with CacheManager + * @param joinPoint The join point + * @param cacheName The name of the cache + * @param key The key to be used for caching + * @return The result of the method call after caching + */ + private Flux callFluxMethodAndCache(ProceedingJoinPoint joinPoint, String cacheName, String key) { + try { + return ((Flux) joinPoint.proceed()) + .collectList() // Collect Flux into Mono> + .zipWhen(value -> cacheManager.put(cacheName, key, value)) //Call CacheManager.put() to cache the list + .flatMap(value -> Mono.just(value.getT1())) //Maps to the original list + .flatMapMany(Flux::fromIterable); //Convert it back to Flux + } catch (Throwable e) { + log.error("Error occurred in saving to cache when invoking function {}", joinPoint.getSignature().getName(), e); + return Flux.error(e); + } + } + + /** + * This method is used to derive the key name for caching the result of a method call based on method arguments. + * This uses original strategy used by Spring's Cacheable annotation. + * @param args Arguments of original method call + * @return Key name for caching the result of the method call + */ + private String deriveKeyWithArguments(Object[] args) { + if(args.length == 0) { //If there are no arguments, return SimpleKey.EMPTY + return SimpleKey.EMPTY.toString(); + } + + if(args.length == 1) { //If there is only one argument, return its toString() value + return args[0].toString(); + } + + SimpleKey simpleKey = new SimpleKey(args); //Create SimpleKey from arguments and return its toString() value + return simpleKey.toString(); + } + + /** + * This method is used to derive the key name for caching the result of a method call based on method arguments and expression provided. + * @param expression SPEL Expression to derive the key name + * @param parameterNames Names of the method arguments of original method call + * @param args Arguments of original method call + * @return Key name for caching the result of the method call + */ + private String deriveKeyWithExpression(String expression, String[] parameterNames, Object[] args) { + //Create EvaluationContext for the expression + EvaluationContext evaluationContext = new StandardEvaluationContext(); + for (int i = 0; i < args.length; i ++) { + //Add method arguments to evaluation context + evaluationContext.setVariable(parameterNames[i], args[i]); + } + //Parse expression and return the result + return EXPRESSION_PARSER.parseExpression(expression).getValue(evaluationContext, String.class); + } + + /** + * This method is used to derive the key name for caching the result of a method call + * @param expression SPEL Expression to derive the key name + * @param parameterNames Names of the method arguments of original method call + * @param args Arguments of original method call + * @return Key name for caching the result of the method call + */ + private String deriveKey(String expression, String[] parameterNames, Object[] args) { + if(expression.isEmpty()) { //If expression is empty, use default strategy + return deriveKeyWithArguments(args); + } + + //If expression is not empty, use expression strategy + return deriveKeyWithExpression(expression, parameterNames, args); + } + + /** + * This method defines a Aspect to handle method calls annotated with Cache. + * @param joinPoint ProceedingJoinPoint of the method call + * @return Result of the method call, either cached or after calling the original method + * @throws Throwable + */ + @Around("execution(public * *(..)) && @annotation(com.appsmith.caching.annotations.Cache)") + public Object cacheable(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + Cache annotation = method.getAnnotation(Cache.class); + String cacheName = annotation.cacheName(); + + //derive key + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + String key = deriveKey(annotation.key(), parameterNames, args); + + Class returnType = method.getReturnType(); + if (returnType.isAssignableFrom(Mono.class)) { //If method returns Mono + return cacheManager.get(cacheName, key) + .switchIfEmpty(Mono.defer(() -> callMonoMethodAndCache(joinPoint, cacheName, key))); //defer the creation of Mono until subscription as it will call original function + } + + if (returnType.isAssignableFrom(Flux.class)) { //If method returns Flux + return cacheManager.get(cacheName, key) + .switchIfEmpty(Mono.defer(() -> callFluxMethodAndCache(joinPoint, cacheName, key).collectList())) //defer the creation of Flux until subscription as it will call original function + .map(value -> (List) value) + .flatMapMany(Flux::fromIterable); + } + + //If method does not returns Mono or Flux raise exception + throw new RuntimeException("Invalid usage of @Cache annotation. Only reactive objects Mono and Flux are supported for caching."); + } + + /** + * This method defines a Aspect to handle method calls annotated with ReactiveEvict. + * Original method should return Mono + * @param joinPoint ProceedingJoinPoint of the method call + * @return Mono that will complete after evicting the key from the cache + * @throws Throwable + */ + @Around("execution(public * *(..)) && @annotation(com.appsmith.caching.annotations.CacheEvict)") + public Object cacheEvict(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + CacheEvict annotation = method.getAnnotation(CacheEvict.class); + String cacheName = annotation.cacheName(); + boolean all = annotation.all(); + Class returnType = method.getReturnType(); + + if (!returnType.isAssignableFrom(Mono.class)) { + throw new RuntimeException("Invalid usage of @CacheEvict for " + method.getName() + ". Only Mono is allowed."); + } + + if(all) { //If all is true, evict all keys from the cache + return cacheManager.evictAll(cacheName) + .then((Mono) joinPoint.proceed()); + } + + //derive key + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + String key = deriveKey(annotation.key(), parameterNames, args); + //Evict key from the cache then call the original method + return cacheManager.evict(cacheName, key) + .then((Mono) joinPoint.proceed()); + } +} diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/CacheManager.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/CacheManager.java new file mode 100644 index 0000000000..d433dd4998 --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/CacheManager.java @@ -0,0 +1,42 @@ +package com.appsmith.caching.components; + +import reactor.core.publisher.Mono; + +public interface CacheManager { + /** + * This will log the cache stats with INFO severity. + */ + void logStats(); + + /** + * This will get item from the cache, Mono.empty() if not found. + * @param cacheName The name of the cache. + * @param key The key of the item. + * @return The Mono of the item. + */ + Mono get(String cacheName, String key); + + /** + * This will put item into the cache. + * @param cacheName The name of the cache. + * @param key The key of the item. + * @param value The value of the item. + * @return Mono true if put was successful, false otherwise. + */ + Mono put(String cacheName, String key, Object value); + + /** + * This will remove item from the cache. + * @param cacheName The name of the cache. + * @param key The key of the item. + * @return Mono that will complete after the item is removed. + */ + Mono evict(String cacheName, String key); + + /** + * This will remove all items from the cache. + * @param cacheName The name of the cache. + * @return Mono that will complete after the items are removed. + */ + Mono evictAll(String cacheName); +} diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/RedisCacheManagerImpl.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/RedisCacheManagerImpl.java new file mode 100644 index 0000000000..896711cca6 --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/components/RedisCacheManagerImpl.java @@ -0,0 +1,109 @@ +package com.appsmith.caching.components; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import com.appsmith.caching.model.CacheStats; + +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +/** + * RedisCacheManagerImpl is a class that implements the CacheManager interface. + * Used Redis as the cache backend. + */ +@Component +@ConditionalOnClass({ReactiveRedisTemplate.class}) +@Slf4j +public class RedisCacheManagerImpl implements CacheManager { + + private final ReactiveRedisTemplate reactiveRedisTemplate; + private final ReactiveRedisOperations reactiveRedisOperations; + + Map statsMap = new ConcurrentHashMap<>(); + + /** + * Ensures that the key for cacheName is present in statsMap. + * @param cacheName The name of the cache. + */ + private void ensureStats(String cacheName) { + if (!statsMap.containsKey(cacheName)) { + statsMap.put(cacheName, CacheStats.newInstance()); + } + } + + @Override + public void logStats() { + statsMap.keySet().forEach(key -> { + CacheStats stats = statsMap.get(key); + log.info("Cache {} stats: hits = {}, misses = {}, singleEvictions = {}, completeEvictions = {}", key, stats.getHits(), stats.getMisses(), stats.getSingleEvictions(), stats.getCompleteEvictions()); + }); + } + + /** + * Resets the stats. + */ + public void resetStats() { + statsMap.clear(); + } + + @Autowired + public RedisCacheManagerImpl(ReactiveRedisTemplate reactiveRedisTemplate, + ReactiveRedisOperations reactiveRedisOperations) { + this.reactiveRedisTemplate = reactiveRedisTemplate; + this.reactiveRedisOperations = reactiveRedisOperations; + } + + @Override + public Mono get(String cacheName, String key) { + ensureStats(cacheName); + String path = cacheName + ":" + key; + return reactiveRedisTemplate.opsForValue().get(path) + .map(value -> { + //This is a cache hit, update stats and return value + statsMap.get(cacheName).getHits().incrementAndGet(); + return value; + }) + .switchIfEmpty(Mono.defer(() -> { + //This is a cache miss, update stats and return empty + statsMap.get(cacheName).getMisses().incrementAndGet(); + return Mono.empty(); + })); + } + + @Override + public Mono put(String cacheName, String key, Object value) { + ensureStats(cacheName); + String path = cacheName + ":" + key; + return reactiveRedisTemplate.opsForValue().set(path, value); + } + + @Override + public Mono evict(String cacheName, String key) { + ensureStats(cacheName); + statsMap.get(cacheName).getSingleEvictions().incrementAndGet(); + String path = cacheName + ":" + key; + return reactiveRedisTemplate.delete(path).then(); + } + + @Override + public Mono evictAll(String cacheName) { + ensureStats(cacheName); + statsMap.get(cacheName).getCompleteEvictions().incrementAndGet(); + String path = cacheName; + //Remove all matching keys with wildcard + final String script = + "for _,k in ipairs(redis.call('keys','" + path + ":*'))" + + " do redis.call('del',k) " + + "end"; + return reactiveRedisOperations.execute(RedisScript.of(script)).then(); + } + +} diff --git a/app/server/reactive-caching/src/main/java/com/appsmith/caching/model/CacheStats.java b/app/server/reactive-caching/src/main/java/com/appsmith/caching/model/CacheStats.java new file mode 100644 index 0000000000..cd39a54b1a --- /dev/null +++ b/app/server/reactive-caching/src/main/java/com/appsmith/caching/model/CacheStats.java @@ -0,0 +1,34 @@ +package com.appsmith.caching.model; + +import java.util.concurrent.atomic.AtomicInteger; + +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * This is a CacheStats class that is used to store the stats of a cache. + * It is maintained for all cacheNames in the memory + */ +@Data +@NoArgsConstructor(staticName = "newInstance") +public class CacheStats { + /** + * The number of times the cache was hit. + */ + private AtomicInteger hits = new AtomicInteger(0); + + /** + * The number of times the cache was missed. + */ + private AtomicInteger misses = new AtomicInteger(0); + + /** + * The number of times the cache was evicted (single key). + */ + private AtomicInteger singleEvictions = new AtomicInteger(0); + + /** + * The number of times the cache was evicted (all keys). + */ + private AtomicInteger completeEvictions = new AtomicInteger(0); +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/CacheApplication.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/CacheApplication.java new file mode 100644 index 0000000000..ffeb04006d --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/CacheApplication.java @@ -0,0 +1,11 @@ +package com.appsmith.caching; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CacheApplication { + public static void main(String[] args) { + SpringApplication.run(CacheApplication.class, args); + } +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/configuration/RedisTestContainerConfig.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/configuration/RedisTestContainerConfig.java new file mode 100644 index 0000000000..4e0fc637cf --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/configuration/RedisTestContainerConfig.java @@ -0,0 +1,58 @@ +package com.appsmith.caching.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.ReactiveRedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.ReactiveRedisOperations; +import org.springframework.data.redis.core.ReactiveRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Configuration +@Testcontainers +public class RedisTestContainerConfig { + + @Container + public static GenericContainer redisContainer = new GenericContainer(DockerImageName.parse("redis:6.2.6-alpine")) + .withExposedPorts(6379); + + @Bean + @Primary + public ReactiveRedisConnectionFactory reactiveRedisConnectionFactory() { + redisContainer.start(); + RedisStandaloneConfiguration redisConf = new RedisStandaloneConfiguration(redisContainer.getHost(), + redisContainer.getMappedPort(6379)); + return new LettuceConnectionFactory(redisConf); + } + + @Primary + @Bean + ReactiveRedisTemplate reactiveRedisTemplate(ReactiveRedisConnectionFactory factory) { + RedisSerializer keySerializer = new StringRedisSerializer(); + RedisSerializer defaultSerializer = new GenericJackson2JsonRedisSerializer(); + RedisSerializationContext serializationContext = RedisSerializationContext + .newSerializationContext(defaultSerializer).key(keySerializer).hashKey(keySerializer) + .build(); + return new ReactiveRedisTemplate<>(factory, serializationContext); + } + + @Primary + @Bean + ReactiveRedisOperations reactiveRedisOperations(ReactiveRedisConnectionFactory factory) { + Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer<>(String.class); + RedisSerializationContext.RedisSerializationContextBuilder builder = + RedisSerializationContext.newSerializationContext(new StringRedisSerializer()); + RedisSerializationContext context = builder.value(serializer).build(); + return new ReactiveRedisTemplate<>(factory, context); + } +} \ No newline at end of file diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ArgumentModel.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ArgumentModel.java new file mode 100644 index 0000000000..0d7255696f --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ArgumentModel.java @@ -0,0 +1,10 @@ +package com.appsmith.caching.model; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor(staticName = "of") +public class ArgumentModel { + private String name; +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/NestedModel.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/NestedModel.java new file mode 100644 index 0000000000..3795e2bd87 --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/NestedModel.java @@ -0,0 +1,10 @@ +package com.appsmith.caching.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode +public class NestedModel { + private int nestedIntValue; +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ParentModel.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ParentModel.java new file mode 100644 index 0000000000..946cb1f2b2 --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/ParentModel.java @@ -0,0 +1,10 @@ +package com.appsmith.caching.model; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode +public class ParentModel { + private int parentIntValue; +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/TestModel.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/TestModel.java new file mode 100644 index 0000000000..d6b4a6da23 --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/model/TestModel.java @@ -0,0 +1,19 @@ +package com.appsmith.caching.model; + +import java.time.Instant; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class TestModel extends ParentModel { + private int intValue; + private String stringValue; + private Integer integerValue; + private Boolean booleanValue; + private Long longValue; + private Double doubleValue; + private NestedModel nestedModel; + private String id; +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/service/CacheTestService.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/service/CacheTestService.java new file mode 100644 index 0000000000..a5a98995db --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/service/CacheTestService.java @@ -0,0 +1,113 @@ +package com.appsmith.caching.service; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.appsmith.caching.annotations.CacheEvict; +import com.appsmith.caching.annotations.Cache; +import com.appsmith.caching.model.ArgumentModel; +import com.appsmith.caching.model.TestModel; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import uk.co.jemos.podam.api.PodamFactory; +import uk.co.jemos.podam.api.PodamFactoryImpl; + +@Service +public class CacheTestService { + + PodamFactory factory = new PodamFactoryImpl(); + + /** + * This method is used to test the caching functionality for Mono. + * @param id The id + * @return The Mono object, random every time + */ + @Cache(cacheName = "objectcache") + public Mono getObjectFor(String id) { + TestModel model = factory.manufacturePojo(TestModel.class); + model.setId(id); + return Mono.just(model).delayElement(Duration.ofSeconds(2)); + } + + /** + * This method is used to test the eviction functionality for Mono. + * @param id The id + * @return Mono that completes after eviction + */ + @CacheEvict(cacheName = "objectcache") + public Mono evictObjectFor(String id) { + return Mono.empty(); + } + + /** + * This method is used to test eviction functionality for Mono, complete cache. + * @return Mono that completes after eviction + */ + @CacheEvict(cacheName = "objectcache", all = true) + public Mono evictAllObjects() { + return Mono.empty(); + } + + /** + * This method is used to test the caching functionality for Flux. + * @param id The id + * @return The Flux, random every time + */ + @Cache(cacheName = "listcache") + public Flux getListFor(String id) { + List testModels = new ArrayList<>(); + for(int i = 0;i < 5;i++) { + TestModel model = factory.manufacturePojo(TestModel.class); + model.setId(id); + testModels.add(model); + } + return Flux.fromIterable(testModels).delayElements(Duration.ofMillis(200)); + } + + /** + * This method is used to test the eviction functionality for Flux. + * @param id The id + * @return Mono that completes after eviction + */ + @CacheEvict(cacheName = "listcache") + public Mono evictListFor(String id) { + return Mono.empty(); + } + + /** + * This method is used to test eviction functionality for Flux, complete cache. + * @return Mono that completes after eviction + */ + @CacheEvict(cacheName = "listcache", all = true) + public Mono evictAllLists() { + return Mono.empty(); + } + + + /** + * This method is used to test SPEL expression in the caching annotation. + * @param ArgumentModel The argument model + * @return The Mono object, random every time + */ + @Cache(cacheName = "objectcache1", key = "#argumentModel.name") + public Mono getObjectForWithKey(ArgumentModel argumentModel) { + TestModel model = factory.manufacturePojo(TestModel.class); + model.setId(argumentModel.getName()); + return Mono.just(model).delayElement(Duration.ofSeconds(2)); + } + + /** + * This method is used to test SPEL expression in the caching annotation. + * Key generated will be same as getObjectForWithKey but with different expression + * @param id The id + * @return The Mono that will complete after the item is removed. + */ + @CacheEvict(cacheName = "objectcache1", key = "#id") + public Mono evictObjectForWithKey(String id) { + return Mono.empty(); + } +} diff --git a/app/server/reactive-caching/src/test/java/com/appsmith/caching/test/TestCachingMethods.java b/app/server/reactive-caching/src/test/java/com/appsmith/caching/test/TestCachingMethods.java new file mode 100644 index 0000000000..9684691910 --- /dev/null +++ b/app/server/reactive-caching/src/test/java/com/appsmith/caching/test/TestCachingMethods.java @@ -0,0 +1,124 @@ +package com.appsmith.caching.test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.appsmith.caching.components.CacheManager; +import com.appsmith.caching.model.ArgumentModel; +import com.appsmith.caching.model.TestModel; +import com.appsmith.caching.service.CacheTestService; + +import lombok.extern.slf4j.Slf4j; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Slf4j +public class TestCachingMethods { + + @Autowired + private CacheTestService cacheTestService; + + @Autowired + private CacheManager cacheManager; + + /** + * This Test is used to test the caching of a method that returns a Mono + */ + @Test + public void testCacheAndEvictMono() { + TestModel model = cacheTestService.getObjectFor("test1").block(); + TestModel model2 = cacheTestService.getObjectFor("test1").block(); + assertEquals(model, model2); + + cacheTestService.evictObjectFor("test1").block(); + + // If not evicted with above call, this will return the same object + model2 = cacheTestService.getObjectFor("test1").block(); + assertNotEquals(model, model2); + } + + /** + * This Test is used to test the caching of a method that returns a Flux + */ + @Test + public void testCacheAndEvictFlux() { + List model = cacheTestService.getListFor("test1").collectList().block(); + List model2 = cacheTestService.getListFor("test1").collectList().block(); + assertArrayEquals(model.toArray(), model2.toArray()); + + cacheTestService.evictListFor("test1").block(); + + // If not evicted with above call, this will return the same object + model2 = cacheTestService.getListFor("test1").collectList().block(); + for(int i = model.size() - 1; i >= 0; i--) { + assertNotEquals(model.get(i), model2.get(i)); + } + } + + /** + * This Test is used to test evict all + */ + @Test + public void testEvictAll() { + TestModel model1 = cacheTestService.getObjectFor("test1").block(); + TestModel model2 = cacheTestService.getObjectFor("test2").block(); + + cacheTestService.evictAllObjects().block(); + + TestModel model1_2 = cacheTestService.getObjectFor("test1").block(); + TestModel model2_2 = cacheTestService.getObjectFor("test2").block(); + + assertNotEquals(model1, model1_2); + assertNotEquals(model2, model2_2); + } + + /** + * This Test is used to test SPEL expression in key field. + */ + @Test + public void testExpression() { + TestModel model = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block(); + TestModel model2 = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block(); + assertEquals(model, model2); + + cacheTestService.evictObjectForWithKey("test1").block(); + + // If not evicted with above call, this will return the same object + model2 = cacheTestService.getObjectForWithKey(ArgumentModel.of("test1")).block(); + assertNotEquals(model, model2); + } + + /** + * Test to measure performance of caching + */ + @Test + public void measurePerformance() { + // Cache first + TestModel model1 = cacheTestService.getObjectFor("test1").block(); + long initialTime = System.nanoTime(); + int count = 100; + for(int i = 0; i < count; i++) { + cacheTestService.getObjectFor("test1").block(); + } + long finalTime = System.nanoTime(); + long timeTaken = finalTime - initialTime; + log.info("Time taken for cache operation " + (timeTaken / count) + " nanos"); + } + + /** + * Log stats in the end + */ + @AfterAll + public void tearDown() { + cacheManager.logStats(); + } +}