diff --git a/eternalcode-commons-shared/build.gradle.kts b/eternalcode-commons-shared/build.gradle.kts index f6010c9..9be6cb5 100644 --- a/eternalcode-commons-shared/build.gradle.kts +++ b/eternalcode-commons-shared/build.gradle.kts @@ -5,6 +5,12 @@ plugins { `commons-java-unit-test` } +dependencies { + implementation("com.github.ben-manes.caffeine:caffeine:3.2.3") + testImplementation("org.assertj:assertj-core:3.27.7") + testImplementation("org.awaitility:awaitility:4.3.0") +} + tasks.test { useJUnitPlatform() } diff --git a/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/Delay.java b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/Delay.java new file mode 100644 index 0000000..6c7d3d3 --- /dev/null +++ b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/Delay.java @@ -0,0 +1,68 @@ +package com.eternalcode.commons.delay; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.Supplier; + +public class Delay { + + private final Cache cache; + private final Supplier defaultDelay; + + private Delay(Supplier defaultDelay) { + if (defaultDelay == null) { + throw new IllegalArgumentException("defaultDelay cannot be null"); + } + + this.defaultDelay = defaultDelay; + this.cache = Caffeine.newBuilder() + .expireAfter(new InstantExpiry()) + .build(); + } + + public static Delay withDefault(Supplier defaultDelay) { + return new Delay<>(defaultDelay); + } + + public void markDelay(T key, Duration delay) { + if (delay.isZero() || delay.isNegative()) { + this.cache.invalidate(key); + return; + } + + this.cache.put(key, Instant.now().plus(delay)); + } + + public void markDelay(T key) { + this.markDelay(key, this.defaultDelay.get()); + } + + public void unmarkDelay(T key) { + this.cache.invalidate(key); + } + + public boolean hasDelay(T key) { + Instant delayExpireMoment = this.getExpireAt(key); + if (delayExpireMoment == null) { + return false; + } + return Instant.now().isBefore(delayExpireMoment); + } + + public Duration getRemaining(T key) { + Instant expireAt = this.getExpireAt(key); + if (expireAt == null) { + return Duration.ZERO; + } + return Duration.between(Instant.now(), expireAt); + } + + @Nullable + private Instant getExpireAt(T key) { + return this.cache.getIfPresent(key); + } +} diff --git a/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/InstantExpiry.java b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/InstantExpiry.java new file mode 100644 index 0000000..0c48cd7 --- /dev/null +++ b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/InstantExpiry.java @@ -0,0 +1,35 @@ +package com.eternalcode.commons.delay; + +import com.github.benmanes.caffeine.cache.Expiry; + +import java.time.Duration; +import java.time.Instant; + +public class InstantExpiry implements Expiry { + + private long timeToExpire(Instant expireTime) { + Duration toExpire = Duration.between(Instant.now(), expireTime); + + try { + return toExpire.toNanos(); + } catch (ArithmeticException overflow) { + return toExpire.isNegative() ? Long.MIN_VALUE : Long.MAX_VALUE; + } + } + + @Override + public long expireAfterCreate(T key, Instant expireTime, long currentTime) { + return timeToExpire(expireTime); + } + + @Override + public long expireAfterUpdate(T key, Instant newExpireTime, long currentTime, long currentDuration) { + return timeToExpire(newExpireTime); + } + + @Override + public long expireAfterRead(T key, Instant expireTime, long currentTime, long currentDuration) { + return timeToExpire(expireTime); + } + +} diff --git a/eternalcode-commons-shared/test/com/eternalcode/commons/delay/DelayTest.java b/eternalcode-commons-shared/test/com/eternalcode/commons/delay/DelayTest.java new file mode 100644 index 0000000..cf961ce --- /dev/null +++ b/eternalcode-commons-shared/test/com/eternalcode/commons/delay/DelayTest.java @@ -0,0 +1,164 @@ +package com.eternalcode.commons.delay; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DelayTest { + + @Test + void shouldExpireAfterDefaultDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key); + assertThat(delay.hasDelay(key)).isTrue(); + + await() + .pollDelay(250, MILLISECONDS) + .atMost(500, MILLISECONDS) + .until(() -> delay.hasDelay(key)); + + await() + .atMost(Duration.ofMillis(350)) // After previously await (600 ms - 900 ms) + .until(() -> !delay.hasDelay(key)); + } + + @Test + void shouldDoNotExpireBeforeCustomDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key, Duration.ofMillis(1000)); + assertThat(delay.hasDelay(key)).isTrue(); + + await() + .pollDelay(500, MILLISECONDS) + .atMost(1000, MILLISECONDS) + .until(() -> delay.hasDelay(key)); + + await() + .atMost(600, MILLISECONDS) // After previously await (1100 ms - 1600 ms) + .until(() -> !delay.hasDelay(key)); + } + + @Test + void shouldUnmarkDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key); + assertThat(delay.hasDelay(key)).isTrue(); + + delay.unmarkDelay(key); + assertThat(delay.hasDelay(key)).isFalse(); + } + + @Test + void shouldNotHaveDelayOnNonExistentKey() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + assertThat(delay.hasDelay(key)).isFalse(); + } + + @Test + void shouldReturnCorrectRemainingTime() throws InterruptedException { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key, Duration.ofMillis(1000)); + + // Immediately after marking, remaining time should be close to the full delay + assertThat(delay.getRemaining(key)) + .isCloseTo(Duration.ofMillis(1000), Duration.ofMillis(150)); + + // Wait for some time + await() + .pollDelay(400, MILLISECONDS) + .atMost(550, MILLISECONDS) + .untilAsserted(() -> { + // After 400ms, remaining time should be less than the original + assertThat(delay.getRemaining(key)).isLessThan(Duration.ofMillis(1000).minus(Duration.ofMillis(300))); + }); + + await() + .atMost(Duration.ofMillis(1000).plus(Duration.ofMillis(150))) + .until(() -> !delay.hasDelay(key)); + + // After expiration, remaining time should be negative + assertThat(delay.getRemaining(key)).isZero(); + } + + @Test + void shouldHandleMultipleKeysIndependently() { + Delay delay = Delay.withDefault(() -> Duration.ofMillis(500)); + UUID shortTimeKey = UUID.randomUUID(); // 500ms + UUID longTimeKey = UUID.randomUUID(); // 1000ms + + delay.markDelay(shortTimeKey); + delay.markDelay(longTimeKey, Duration.ofMillis(1000)); + + assertThat(delay.hasDelay(shortTimeKey)).isTrue(); + assertThat(delay.hasDelay(longTimeKey)).isTrue(); + + // Wait for the first key to expire + await() + .atMost(Duration.ofMillis(500).plus(Duration.ofMillis(150))) + .until(() -> !delay.hasDelay(shortTimeKey)); + + // After first key expires, second should still be active + assertThat(delay.hasDelay(shortTimeKey)).isFalse(); + assertThat(delay.hasDelay(longTimeKey)).isTrue(); + + // Wait for the second key to expire + await() + .atMost(Duration.ofMillis(1000)) + .until(() -> !delay.hasDelay(longTimeKey)); + + assertThat(delay.hasDelay(longTimeKey)).isFalse(); + } + + @Test + void testExpireAfterCreate_withOverflow_shouldReturnMaxValue() { + InstantExpiry expiry = new InstantExpiry<>(); + Instant farFuture = Instant.now().plus(Duration.ofDays(1000000000)); + + long result = expiry.expireAfterCreate("key", farFuture, 0); + + assertEquals(Long.MAX_VALUE, result); + } + + @Test + void testExpireAfterCreate_withOverflow_shouldReturnMinValue() { + InstantExpiry expiry = new InstantExpiry<>(); + Instant farPast = Instant.now().minus(Duration.ofDays(1000000000)); + + long result = expiry.expireAfterCreate("key", farPast, 0); + + assertEquals(Long.MIN_VALUE, result); + } + + @Test + void testSuperLargeDelay() { + Delay delay = Delay.withDefault(() -> Duration.ofDays(1000000000)); + UUID key = UUID.randomUUID(); + + delay.markDelay(key); + assertThat(delay.hasDelay(key)).isTrue(); + + await() + .atMost(Duration.ofSeconds(1)) + .until(() -> delay.hasDelay(key)); + + // Even after waiting, the delay should still be active due to the large duration + assertThat(delay.hasDelay(key)).isTrue(); + } +}