Skip to content

Commit

Permalink
added LocalExpirationMode enum to allow expire after update/read beha…
Browse files Browse the repository at this point in the history
…vior (#24)

* added LocalExpirationMode enum to allow expire after update/read behavior

* use ThreadLocalRandom and some cleanup

* added javadoc

* updated RandomizedLocalExpiry javadoc

* added tests

* bump version; added more comments
  • Loading branch information
efenderbosch-atg authored Apr 24, 2024
1 parent cdb7ef8 commit 8720636
Show file tree
Hide file tree
Showing 7 changed files with 122 additions and 25 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ spring:
local:
max-size: 2000
expiry-jitter: 50
expiration-mode: after-create
# other valid values for expiration-mode: after-update, after-read
# Resilience4j Circuit Breaker properties for Redis
circuit-breaker:
failure-rate-threshold: 25
Expand All @@ -90,4 +92,4 @@ spring:
## Honorable mentions
- [Circuit Breaker Redis Cache by gee4vee](https://github.com/gee4vee/circuit-breaker-redis-cache)
- [Multilevel cache Spring Boot starter by pig777](https://github.com/pig-mesh/multilevel-cache-spring-boot-starter)
- [Multilevel cache Spring Boot starter by pig777](https://github.com/pig-mesh/multilevel-cache-spring-boot-starter)
4 changes: 2 additions & 2 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ SONATYPE_AUTOMATIC_RELEASE=true

GROUP=io.github.suppierk
POM_ARTIFACT_ID=spring-boot-multilevel-cache-starter
VERSION_NAME=3.2.5.0
VERSION_NAME=3.2.5.1
POM_PACKAGING=jar

POM_NAME=Spring Boot Multilevel Cache Starter
Expand All @@ -45,4 +45,4 @@ POM_SCM_DEV_CONNECTION=scm:git:ssh://[email protected]/SuppieRK/spring-boot-multile

POM_DEVELOPER_ID=SuppieRK
POM_DEVELOPER_NAME=Roman Khlebnov
POM_DEVELOPER_URL=https://github.com/SuppieRK/
POM_DEVELOPER_URL=https://github.com/SuppieRK/
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package io.github.suppie.spring.cache;

import com.github.benmanes.caffeine.cache.Expiry;

public enum LocalExpirationMode {
/** See {@link Expiry#expireAfterCreate(Object, Object, long)} */
AFTER_CREATE,
/** See {@link Expiry#expireAfterUpdate(Object, Object, long, long)} */
AFTER_UPDATE,
/** See {@link Expiry#expireAfterRead(Object, Object, long, long)} */
AFTER_READ
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import io.github.resilience4j.circuitbreaker.CircuitBreakerConfig.SlidingWindowType;
import java.time.Duration;
import java.util.Optional;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;
Expand Down Expand Up @@ -79,6 +80,12 @@ public static class LocalCacheProperties {

/** Percentage of time deviation for local cache entry expiration */
private int expiryJitter = 50;

/** Optional local TTL */
private Optional<Duration> timeToLive = Optional.empty();

/** Defaults to AFTER_CREATE to preserve previous behavior */
private LocalExpirationMode expirationMode = LocalExpirationMode.AFTER_CREATE;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

package io.github.suppie.spring.cache;

import static io.github.suppie.spring.cache.MultiLevelCacheConfigurationProperties.*;

import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import io.github.resilience4j.circuitbreaker.CircuitBreaker;
Expand All @@ -34,6 +36,7 @@
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.index.qual.NonNegative;
import org.checkerframework.checker.nullness.qual.NonNull;
Expand Down Expand Up @@ -106,7 +109,7 @@ public Cache getCache(@NonNull String name) {
redisTemplate,
Caffeine.newBuilder()
.maximumSize(properties.getLocal().getMaxSize())
.expireAfter(new RandomizedLocalExpiryOnWrite(properties))
.expireAfter(new RandomizedLocalExpiry(properties))
.build(),
circuitBreaker));
}
Expand All @@ -121,18 +124,18 @@ public Cache getCache(@NonNull String name) {
return Collections.unmodifiableSet(availableCaches.keySet());
}

/** Expiry policy enabling randomized expiry on writing for local entities */
static class RandomizedLocalExpiryOnWrite implements Expiry<Object, Object> {
/** Expiry policy enabling randomized expiry for local entities */
static class RandomizedLocalExpiry implements Expiry<Object, Object> {

private final Random random;
private final Duration timeToLive;
private final double expiryJitter;
private final LocalExpirationMode expirationMode;

public RandomizedLocalExpiryOnWrite(
@NonNull MultiLevelCacheConfigurationProperties properties) {
this.random = new Random(System.currentTimeMillis());
this.timeToLive = properties.getTimeToLive();
this.expiryJitter = properties.getLocal().getExpiryJitter();
public RandomizedLocalExpiry(@NonNull MultiLevelCacheConfigurationProperties properties) {
LocalCacheProperties localProperties = properties.getLocal();
this.timeToLive = localProperties.getTimeToLive().orElse(properties.getTimeToLive());
this.expiryJitter = localProperties.getExpiryJitter();
this.expirationMode = localProperties.getExpirationMode();

if (timeToLive.isNegative()) {
throw new IllegalArgumentException("Time to live duration must be positive");
Expand All @@ -153,11 +156,11 @@ public RandomizedLocalExpiryOnWrite(

@Override
public long expireAfterCreate(@NonNull Object key, @NonNull Object value, long currentTime) {
int jitterSign = random.nextBoolean() ? 1 : -1;
double randomJitter = 1 + (jitterSign * (expiryJitter / 100) * random.nextDouble());
Duration expiry = timeToLive.multipliedBy((long) (100 * randomJitter)).dividedBy(200);
log.trace("Key {} will expire in {}", key, expiry);
return expiry.toNanos();
if (expirationMode == LocalExpirationMode.AFTER_CREATE) {
return computeExpiration(key);
} else {
return Long.MAX_VALUE;
}
}

@Override
Expand All @@ -166,7 +169,11 @@ public long expireAfterUpdate(
@NonNull Object value,
long currentTime,
@NonNegative long currentDuration) {
return currentDuration;
if (expirationMode == LocalExpirationMode.AFTER_UPDATE) {
return computeExpiration(key);
} else {
return currentDuration;
}
}

@Override
Expand All @@ -175,7 +182,20 @@ public long expireAfterRead(
@NonNull Object value,
long currentTime,
@NonNegative long currentDuration) {
return currentDuration;
if (expirationMode == LocalExpirationMode.AFTER_READ) {
return computeExpiration(key);
} else {
return currentDuration;
}
}

private long computeExpiration(@NonNull Object key) {
Random random = ThreadLocalRandom.current();
int jitterSign = random.nextBoolean() ? 1 : -1;
double randomJitter = 1 + (jitterSign * (expiryJitter / 100) * random.nextDouble());
Duration expiry = timeToLive.multipliedBy((long) (100 * randomJitter)).dividedBy(200);
log.trace("Key {} will expire in {}", key, expiry);
return expiry.toNanos();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,40 @@ void instantiationTestWithDifferentCacheTypes(CacheType cacheType) {
});
}

@ParameterizedTest
@MethodSource("localExpirationModes")
void instantiationTestWithDifferentLocalExpirationModes(
String mode, LocalExpirationMode expected) {
ApplicationContextRunner runner =
this.runner
.withPropertyValues("spring.data.redis.host=" + System.getProperty("HOST"))
.withPropertyValues("spring.data.redis.port=" + System.getProperty("PORT"))
.withPropertyValues("spring.cache.type=" + CacheType.REDIS.name().toLowerCase());
if (mode != null) {
runner = runner.withPropertyValues("spring.cache.multilevel.local.expiration-mode=" + mode);
}
runner.run(
context -> {
MultiLevelCacheManager cacheManager = context.getBean(MultiLevelCacheManager.class);
Assertions.assertThat(cacheManager.getProperties().getLocal().getExpirationMode())
.isEqualTo(expected);
});
}

static Stream<Arguments> incorrectCacheTypes() {
return Arrays.stream(CacheType.values())
.filter(cacheType -> !CacheType.REDIS.equals(cacheType))
.map(Arguments::of);
}

static Stream<Arguments> localExpirationModes() {
return Stream.of(
Arguments.of(null, LocalExpirationMode.AFTER_CREATE),
Arguments.of("after-create", LocalExpirationMode.AFTER_CREATE),
Arguments.of("AFTER_CREATE", LocalExpirationMode.AFTER_CREATE),
Arguments.of("after-update", LocalExpirationMode.AFTER_UPDATE),
Arguments.of("AFTER_UPDATE", LocalExpirationMode.AFTER_UPDATE),
Arguments.of("after-read", LocalExpirationMode.AFTER_READ),
Arguments.of("AFTER_READ", LocalExpirationMode.AFTER_READ));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@

package io.github.suppie.spring.cache;

import io.github.suppie.spring.cache.MultiLevelCacheManager.RandomizedLocalExpiryOnWrite;
import io.github.suppie.spring.cache.MultiLevelCacheManager.RandomizedLocalExpiry;
import java.time.Duration;
import java.util.Optional;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
Expand Down Expand Up @@ -57,7 +58,7 @@ void cacheNamesTest() {
}

@Nested
class RandomizedLocalExpiryOnWriteTest {
class RandomizedLocalExpiryTest {
@Test
void negativeTimeToLive() {
MultiLevelCacheConfigurationProperties properties =
Expand All @@ -66,7 +67,7 @@ void negativeTimeToLive() {

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiryOnWrite(properties),
() -> new RandomizedLocalExpiry(properties),
"Negative TTL must throw an exception");
}

Expand All @@ -78,7 +79,7 @@ void zeroTimeToLive() {

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiryOnWrite(properties),
() -> new RandomizedLocalExpiry(properties),
"Zero TTL must throw an exception");
}

Expand All @@ -90,7 +91,7 @@ void negativeExpiryJitter() {

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiryOnWrite(properties),
() -> new RandomizedLocalExpiry(properties),
"Negative expiry jitter must throw an exception");
}

Expand All @@ -102,8 +103,32 @@ void tooBigExpiryJitter() {

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiryOnWrite(properties),
() -> new RandomizedLocalExpiry(properties),
"Too big expiry jitter must throw an exception");
}

@Test
void negativeLocalTimeToLive() {
MultiLevelCacheConfigurationProperties properties =
new MultiLevelCacheConfigurationProperties();
properties.getLocal().setTimeToLive(Optional.of(Duration.ofSeconds(1).negated()));

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiry(properties),
"Negative TTL must throw an exception");
}

@Test
void zeroLocalTimeToLive() {
MultiLevelCacheConfigurationProperties properties =
new MultiLevelCacheConfigurationProperties();
properties.getLocal().setTimeToLive(Optional.of(Duration.ZERO));

Assertions.assertThrows(
IllegalArgumentException.class,
() -> new RandomizedLocalExpiry(properties),
"Zero TTL must throw an exception");
}
}
}

0 comments on commit 8720636

Please sign in to comment.