Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions eternalcode-commons-shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
@@ -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<T> {

private final Cache<T, Instant> cache;
private final Supplier<Duration> defaultDelay;

private Delay(Supplier<Duration> defaultDelay) {
if (defaultDelay == null) {
throw new IllegalArgumentException("defaultDelay cannot be null");
}

this.defaultDelay = defaultDelay;
this.cache = Caffeine.newBuilder()
.expireAfter(new InstantExpiry<T>())
.build();
Comment on lines +23 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The Caffeine cache is initialized without a maximum size, which can lead to memory exhaustion if a large number of unique keys are added. This is especially risky if the keys are derived from untrusted user input (e.g., unique user IDs or IP addresses). Adding a limit prevents the cache from growing indefinitely.

        this.cache = Caffeine.newBuilder()
            .expireAfter(new InstantExpiry<T>())
            .maximumSize(1000) // TODO: Adjust this value based on expected usage
            .build();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if this will affect our use-cases

}

public static <T> Delay<T> withDefault(Supplier<Duration> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<T> implements Expiry<T, Instant> {

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);
}

}
Original file line number Diff line number Diff line change
@@ -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<UUID> 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<UUID> 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<UUID> 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<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
UUID key = UUID.randomUUID();

assertThat(delay.hasDelay(key)).isFalse();
}

@Test
void shouldReturnCorrectRemainingTime() throws InterruptedException {
Delay<UUID> 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<UUID> 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<String> 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<String> 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<UUID> 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();
}
}