From bf51b127411c6546ee6abfc580d33352b3ad17f8 Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:05:35 +0100 Subject: [PATCH 1/3] Add delay package based on Caffeine --- eternalcode-commons-shared/build.gradle.kts | 4 ++ .../com/eternalcode/commons/delay/Delay.java | 58 +++++++++++++++++++ .../commons/delay/InstantExpiry.java | 39 +++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/Delay.java create mode 100644 eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/InstantExpiry.java diff --git a/eternalcode-commons-shared/build.gradle.kts b/eternalcode-commons-shared/build.gradle.kts index f6010c9..8952b1c 100644 --- a/eternalcode-commons-shared/build.gradle.kts +++ b/eternalcode-commons-shared/build.gradle.kts @@ -5,6 +5,10 @@ plugins { `commons-java-unit-test` } +dependencies { + implementation("com.github.ben-manes.caffeine:caffeine:3.2.3") +} + 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..77761a3 --- /dev/null +++ b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/Delay.java @@ -0,0 +1,58 @@ +package com.eternalcode.commons.delay; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; + +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); + } + + 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); + return Instant.now().isBefore(delayExpireMoment); + } + + public Duration getRemaining(T key) { + return Duration.between(Instant.now(), this.getExpireAt(key)); + } + + private Instant getExpireAt(T key) { + return this.cache.asMap().getOrDefault(key, Instant.MIN); + } +} 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..753ea8c --- /dev/null +++ b/eternalcode-commons-shared/src/main/java/com/eternalcode/commons/delay/InstantExpiry.java @@ -0,0 +1,39 @@ +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 static long timeToExpire(Instant expireTime) { + Duration toExpire = Duration.between(Instant.now(), expireTime); + if (toExpire.isNegative()) { + return 0; + } + + long nanos = toExpire.toNanos(); + if (nanos == 0) { + return 1; + } + + return nanos; + } + + @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 value, long currentTime, long currentDuration) { + return currentDuration; + } + +} From cde2846d1ec816aff96fc5345a7d507a309f406c Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:34:20 +0100 Subject: [PATCH 2/3] fix: Improve delay handling and expiration logic in Delay and InstantExpiry classes --- .../com/eternalcode/commons/delay/Delay.java | 7 +++++-- .../commons/delay/InstantExpiry.java | 21 ++++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) 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 index 77761a3..02f83b0 100644 --- 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 @@ -30,6 +30,7 @@ public static Delay withDefault(Supplier 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)); @@ -49,10 +50,12 @@ public boolean hasDelay(T key) { } public Duration getRemaining(T key) { - return Duration.between(Instant.now(), this.getExpireAt(key)); + Duration remaining = Duration.between(Instant.now(), this.getExpireAt(key)); + return remaining.isNegative() ? Duration.ZERO : remaining; } private Instant getExpireAt(T key) { - return this.cache.asMap().getOrDefault(key, Instant.MIN); + Instant expireAt = this.cache.getIfPresent(key); + return expireAt != null ? expireAt : Instant.MIN; } } 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 index 753ea8c..93c6a3d 100644 --- 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 @@ -7,28 +7,29 @@ public class InstantExpiry implements Expiry { - private static long timeToExpire(Instant expireTime) { - Duration toExpire = Duration.between(Instant.now(), expireTime); - if (toExpire.isNegative()) { + private long timeToExpire(Instant expireTime, long currentTimeNanos) { + Instant currentInstant = Instant.ofEpochSecond(0, currentTimeNanos); + Duration toExpire = Duration.between(currentInstant, expireTime); + + if (toExpire.isNegative() || toExpire.isZero()) { return 0; } - long nanos = toExpire.toNanos(); - if (nanos == 0) { - return 1; + try { + return toExpire.toNanos(); + } catch (ArithmeticException overflow) { + return Long.MAX_VALUE; } - - return nanos; } @Override public long expireAfterCreate(T key, Instant expireTime, long currentTime) { - return timeToExpire(expireTime); + return timeToExpire(expireTime, currentTime); } @Override public long expireAfterUpdate(T key, Instant newExpireTime, long currentTime, long currentDuration) { - return timeToExpire(newExpireTime); + return timeToExpire(newExpireTime, currentTime); } @Override From 4ce3c609f9818efc1cf1aa6c859bb17ce3995ecc Mon Sep 17 00:00:00 2001 From: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:55:48 +0100 Subject: [PATCH 3/3] Update Delay classes, write additional tests --- eternalcode-commons-shared/build.gradle.kts | 2 + .../com/eternalcode/commons/delay/Delay.java | 15 +- .../commons/delay/InstantExpiry.java | 19 +- .../eternalcode/commons/delay/DelayTest.java | 164 ++++++++++++++++++ 4 files changed, 184 insertions(+), 16 deletions(-) create mode 100644 eternalcode-commons-shared/test/com/eternalcode/commons/delay/DelayTest.java diff --git a/eternalcode-commons-shared/build.gradle.kts b/eternalcode-commons-shared/build.gradle.kts index 8952b1c..9be6cb5 100644 --- a/eternalcode-commons-shared/build.gradle.kts +++ b/eternalcode-commons-shared/build.gradle.kts @@ -7,6 +7,8 @@ plugins { 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 { 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 index 02f83b0..6c7d3d3 100644 --- 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 @@ -2,6 +2,7 @@ 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; @@ -46,16 +47,22 @@ public void unmarkDelay(T 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) { - Duration remaining = Duration.between(Instant.now(), this.getExpireAt(key)); - return remaining.isNegative() ? Duration.ZERO : remaining; + Instant expireAt = this.getExpireAt(key); + if (expireAt == null) { + return Duration.ZERO; + } + return Duration.between(Instant.now(), expireAt); } + @Nullable private Instant getExpireAt(T key) { - Instant expireAt = this.cache.getIfPresent(key); - return expireAt != null ? expireAt : Instant.MIN; + 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 index 93c6a3d..0c48cd7 100644 --- 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 @@ -7,34 +7,29 @@ public class InstantExpiry implements Expiry { - private long timeToExpire(Instant expireTime, long currentTimeNanos) { - Instant currentInstant = Instant.ofEpochSecond(0, currentTimeNanos); - Duration toExpire = Duration.between(currentInstant, expireTime); - - if (toExpire.isNegative() || toExpire.isZero()) { - return 0; - } + private long timeToExpire(Instant expireTime) { + Duration toExpire = Duration.between(Instant.now(), expireTime); try { return toExpire.toNanos(); } catch (ArithmeticException overflow) { - return Long.MAX_VALUE; + return toExpire.isNegative() ? Long.MIN_VALUE : Long.MAX_VALUE; } } @Override public long expireAfterCreate(T key, Instant expireTime, long currentTime) { - return timeToExpire(expireTime, currentTime); + return timeToExpire(expireTime); } @Override public long expireAfterUpdate(T key, Instant newExpireTime, long currentTime, long currentDuration) { - return timeToExpire(newExpireTime, currentTime); + return timeToExpire(newExpireTime); } @Override - public long expireAfterRead(T key, Instant value, long currentTime, long currentDuration) { - return currentDuration; + 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(); + } +}