Skip to content

Commit 8a67f8d

Browse files
authored
GH-76 Add delay
* Add delay package based on Caffeine * fix: Improve delay handling and expiration logic in Delay and InstantExpiry classes * Update Delay classes, write additional tests * Adjust to DMK suggestion
1 parent bba6287 commit 8a67f8d

File tree

3 files changed

+268
-0
lines changed

3 files changed

+268
-0
lines changed

eternalcode-commons-shared/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ plugins {
55
`commons-java-unit-test`
66
}
77

8+
dependencies {
9+
implementation("com.github.ben-manes.caffeine:caffeine:3.2.3")
10+
testImplementation("org.assertj:assertj-core:3.27.7")
11+
testImplementation("org.awaitility:awaitility:4.3.0")
12+
}
13+
814
tasks.test {
915
useJUnitPlatform()
1016
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.eternalcode.commons.delay;
2+
3+
import com.github.benmanes.caffeine.cache.Cache;
4+
import com.github.benmanes.caffeine.cache.Caffeine;
5+
import com.github.benmanes.caffeine.cache.Expiry;
6+
import org.jspecify.annotations.Nullable;
7+
8+
import java.time.Duration;
9+
import java.time.Instant;
10+
import java.util.function.Supplier;
11+
12+
public class Delay<T> {
13+
14+
private final Cache<T, Instant> cache;
15+
private final Supplier<Duration> defaultDelay;
16+
17+
private Delay(Supplier<Duration> defaultDelay) {
18+
if (defaultDelay == null) {
19+
throw new IllegalArgumentException("defaultDelay cannot be null");
20+
}
21+
22+
this.defaultDelay = defaultDelay;
23+
this.cache = Caffeine.newBuilder()
24+
.expireAfter(new InstantExpiry<T>())
25+
.build();
26+
}
27+
28+
public static <T> Delay<T> withDefault(Supplier<Duration> defaultDelay) {
29+
return new Delay<>(defaultDelay);
30+
}
31+
32+
public void markDelay(T key, Duration delay) {
33+
if (delay.isZero() || delay.isNegative()) {
34+
this.cache.invalidate(key);
35+
return;
36+
}
37+
38+
this.cache.put(key, Instant.now().plus(delay));
39+
}
40+
41+
public void markDelay(T key) {
42+
this.markDelay(key, this.defaultDelay.get());
43+
}
44+
45+
public void unmarkDelay(T key) {
46+
this.cache.invalidate(key);
47+
}
48+
49+
public boolean hasDelay(T key) {
50+
Instant delayExpireMoment = this.getExpireAt(key);
51+
if (delayExpireMoment == null) {
52+
return false;
53+
}
54+
return Instant.now().isBefore(delayExpireMoment);
55+
}
56+
57+
public Duration getRemaining(T key) {
58+
Instant expireAt = this.getExpireAt(key);
59+
if (expireAt == null) {
60+
return Duration.ZERO;
61+
}
62+
return Duration.between(Instant.now(), expireAt);
63+
}
64+
65+
@Nullable
66+
private Instant getExpireAt(T key) {
67+
return this.cache.getIfPresent(key);
68+
}
69+
70+
public static class InstantExpiry<T> implements Expiry<T, Instant> {
71+
72+
private long timeToExpire(Instant expireTime) {
73+
Duration toExpire = Duration.between(Instant.now(), expireTime);
74+
75+
try {
76+
return toExpire.toNanos();
77+
} catch (ArithmeticException overflow) {
78+
return toExpire.isNegative() ? Long.MIN_VALUE : Long.MAX_VALUE;
79+
}
80+
}
81+
82+
@Override
83+
public long expireAfterCreate(T key, Instant expireTime, long currentTime) {
84+
return timeToExpire(expireTime);
85+
}
86+
87+
@Override
88+
public long expireAfterUpdate(T key, Instant newExpireTime, long currentTime, long currentDuration) {
89+
return timeToExpire(newExpireTime);
90+
}
91+
92+
@Override
93+
public long expireAfterRead(T key, Instant expireTime, long currentTime, long currentDuration) {
94+
return timeToExpire(expireTime);
95+
}
96+
97+
}
98+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package com.eternalcode.commons.delay;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.time.Duration;
6+
import java.time.Instant;
7+
import java.util.UUID;
8+
9+
import static java.util.concurrent.TimeUnit.MILLISECONDS;
10+
import static org.assertj.core.api.Assertions.assertThat;
11+
import static org.awaitility.Awaitility.await;
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
14+
class DelayTest {
15+
16+
@Test
17+
void shouldExpireAfterDefaultDelay() {
18+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
19+
UUID key = UUID.randomUUID();
20+
21+
delay.markDelay(key);
22+
assertThat(delay.hasDelay(key)).isTrue();
23+
24+
await()
25+
.pollDelay(250, MILLISECONDS)
26+
.atMost(500, MILLISECONDS)
27+
.until(() -> delay.hasDelay(key));
28+
29+
await()
30+
.atMost(Duration.ofMillis(350)) // After previously await (600 ms - 900 ms)
31+
.until(() -> !delay.hasDelay(key));
32+
}
33+
34+
@Test
35+
void shouldDoNotExpireBeforeCustomDelay() {
36+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
37+
UUID key = UUID.randomUUID();
38+
39+
delay.markDelay(key, Duration.ofMillis(1000));
40+
assertThat(delay.hasDelay(key)).isTrue();
41+
42+
await()
43+
.pollDelay(500, MILLISECONDS)
44+
.atMost(1000, MILLISECONDS)
45+
.until(() -> delay.hasDelay(key));
46+
47+
await()
48+
.atMost(600, MILLISECONDS) // After previously await (1100 ms - 1600 ms)
49+
.until(() -> !delay.hasDelay(key));
50+
}
51+
52+
@Test
53+
void shouldUnmarkDelay() {
54+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
55+
UUID key = UUID.randomUUID();
56+
57+
delay.markDelay(key);
58+
assertThat(delay.hasDelay(key)).isTrue();
59+
60+
delay.unmarkDelay(key);
61+
assertThat(delay.hasDelay(key)).isFalse();
62+
}
63+
64+
@Test
65+
void shouldNotHaveDelayOnNonExistentKey() {
66+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
67+
UUID key = UUID.randomUUID();
68+
69+
assertThat(delay.hasDelay(key)).isFalse();
70+
}
71+
72+
@Test
73+
void shouldReturnCorrectRemainingTime() {
74+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
75+
UUID key = UUID.randomUUID();
76+
77+
delay.markDelay(key, Duration.ofMillis(1000));
78+
79+
// Immediately after marking, remaining time should be close to the full delay
80+
assertThat(delay.getRemaining(key))
81+
.isCloseTo(Duration.ofMillis(1000), Duration.ofMillis(150));
82+
83+
// Wait for some time
84+
await()
85+
.pollDelay(400, MILLISECONDS)
86+
.atMost(550, MILLISECONDS)
87+
.untilAsserted(() -> {
88+
// After 400ms, remaining time should be less than the original
89+
assertThat(delay.getRemaining(key)).isLessThan(Duration.ofMillis(1000).minus(Duration.ofMillis(300)));
90+
});
91+
92+
await()
93+
.atMost(Duration.ofMillis(1000).plus(Duration.ofMillis(150)))
94+
.until(() -> !delay.hasDelay(key));
95+
96+
// After expiration, remaining time should be negative
97+
assertThat(delay.getRemaining(key)).isZero();
98+
}
99+
100+
@Test
101+
void shouldHandleMultipleKeysIndependently() {
102+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofMillis(500));
103+
UUID shortTimeKey = UUID.randomUUID(); // 500ms
104+
UUID longTimeKey = UUID.randomUUID(); // 1000ms
105+
106+
delay.markDelay(shortTimeKey);
107+
delay.markDelay(longTimeKey, Duration.ofMillis(1000));
108+
109+
assertThat(delay.hasDelay(shortTimeKey)).isTrue();
110+
assertThat(delay.hasDelay(longTimeKey)).isTrue();
111+
112+
// Wait for the first key to expire
113+
await()
114+
.atMost(Duration.ofMillis(500).plus(Duration.ofMillis(150)))
115+
.until(() -> !delay.hasDelay(shortTimeKey));
116+
117+
// After first key expires, second should still be active
118+
assertThat(delay.hasDelay(shortTimeKey)).isFalse();
119+
assertThat(delay.hasDelay(longTimeKey)).isTrue();
120+
121+
// Wait for the second key to expire
122+
await()
123+
.atMost(Duration.ofMillis(1000))
124+
.until(() -> !delay.hasDelay(longTimeKey));
125+
126+
assertThat(delay.hasDelay(longTimeKey)).isFalse();
127+
}
128+
129+
@Test
130+
void testExpireAfterCreate_withOverflow_shouldReturnMaxValue() {
131+
Delay.InstantExpiry<String> expiry = new Delay.InstantExpiry<>();
132+
Instant farFuture = Instant.now().plus(Duration.ofDays(1000000000));
133+
134+
long result = expiry.expireAfterCreate("key", farFuture, 0);
135+
136+
assertEquals(Long.MAX_VALUE, result);
137+
}
138+
139+
@Test
140+
void testExpireAfterCreate_withOverflow_shouldReturnMinValue() {
141+
Delay.InstantExpiry<String> expiry = new Delay.InstantExpiry<>();
142+
Instant farPast = Instant.now().minus(Duration.ofDays(1000000000));
143+
144+
long result = expiry.expireAfterCreate("key", farPast, 0);
145+
146+
assertEquals(Long.MIN_VALUE, result);
147+
}
148+
149+
@Test
150+
void testSuperLargeDelay() {
151+
Delay<UUID> delay = Delay.withDefault(() -> Duration.ofDays(1000000000));
152+
UUID key = UUID.randomUUID();
153+
154+
delay.markDelay(key);
155+
assertThat(delay.hasDelay(key)).isTrue();
156+
157+
await()
158+
.atMost(Duration.ofSeconds(1))
159+
.until(() -> delay.hasDelay(key));
160+
161+
// Even after waiting, the delay should still be active due to the large duration
162+
assertThat(delay.hasDelay(key)).isTrue();
163+
}
164+
}

0 commit comments

Comments
 (0)