From 7466a1f0ff2a7de1f9484f9558aec07cd4dc7902 Mon Sep 17 00:00:00 2001 From: Anthony RAYMOND Date: Tue, 20 Aug 2024 02:22:04 +0200 Subject: [PATCH] Start tracker implementation --- .../annotations/concurency/Immutable.java | 12 +++ .../joalcore/annotations/ddd/ValueObject.java | 3 + .../domain/AnnounceBackoffService.java | 50 ++++++++++++ .../core/trackers/domain/AnnounceFailed.java | 4 + .../core/trackers/domain/AnnounceSucceed.java | 10 +++ .../core/trackers/domain/Tracker.java | 67 ++++++++++++++++ .../domain/AnnounceBackoffServiceTest.java | 43 ++++++++++ .../core/trackers/domain/TrackerTest.java | 78 +++++++++++++++++++ 8 files changed, 267 insertions(+) create mode 100644 src/main/java/org/araymond/joalcore/annotations/concurency/Immutable.java create mode 100644 src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffService.java create mode 100644 src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceFailed.java create mode 100644 src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceSucceed.java create mode 100644 src/test/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffServiceTest.java create mode 100644 src/test/java/org/araymond/joalcore/core/trackers/domain/TrackerTest.java diff --git a/src/main/java/org/araymond/joalcore/annotations/concurency/Immutable.java b/src/main/java/org/araymond/joalcore/annotations/concurency/Immutable.java new file mode 100644 index 00000000..42e7f61d --- /dev/null +++ b/src/main/java/org/araymond/joalcore/annotations/concurency/Immutable.java @@ -0,0 +1,12 @@ +package org.araymond.joalcore.annotations.concurency; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.SOURCE) +@Target(ElementType.TYPE) +public @interface Immutable { + +} diff --git a/src/main/java/org/araymond/joalcore/annotations/ddd/ValueObject.java b/src/main/java/org/araymond/joalcore/annotations/ddd/ValueObject.java index a0fe985c..df2f202b 100644 --- a/src/main/java/org/araymond/joalcore/annotations/ddd/ValueObject.java +++ b/src/main/java/org/araymond/joalcore/annotations/ddd/ValueObject.java @@ -1,10 +1,13 @@ package org.araymond.joalcore.annotations.ddd; +import org.araymond.joalcore.annotations.concurency.Immutable; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@Immutable @Retention(RetentionPolicy.SOURCE) @Target(ElementType.TYPE) public @interface ValueObject { diff --git a/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffService.java b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffService.java new file mode 100644 index 00000000..d741292f --- /dev/null +++ b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffService.java @@ -0,0 +1,50 @@ +package org.araymond.joalcore.core.trackers.domain; + +import org.araymond.joalcore.annotations.DomainService; + +import java.time.Duration; + +@DomainService +public interface AnnounceBackoffService { + Duration backoff(int consecutiveFails); + + class DefaultBackoffService implements AnnounceBackoffService { + private static final Duration minimumRetryDelay = Duration.ofSeconds(5); + private static final Duration maximumRetryDelay = Duration.ofHours(1); + + private final long backoffRatio; + + public DefaultBackoffService() { + this(250); + } + + public DefaultBackoffService(long backoffRatio) { + if (backoffRatio < 0.0) { + throw new IllegalArgumentException("backoffRatio must be greater than 0.0"); + } + this.backoffRatio = backoffRatio; + } + + @Override + public Duration backoff(int consecutiveFails) { + try { + long failSquare = (long) consecutiveFails * consecutiveFails; + + var backoff = Duration.ofSeconds(failSquare) + .multipliedBy(backoffRatio) + .dividedBy(100); + + return min( + maximumRetryDelay, + minimumRetryDelay.plus(backoff) + ); + } catch (ArithmeticException ignore) { + return maximumRetryDelay; + } + } + + private Duration min(Duration d1, Duration d2) { + return d1.compareTo(d2) < 0 ? d1 : d2; + } + } +} diff --git a/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceFailed.java b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceFailed.java new file mode 100644 index 00000000..743732bd --- /dev/null +++ b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceFailed.java @@ -0,0 +1,4 @@ +package org.araymond.joalcore.core.trackers.domain; + +public record AnnounceFailed() { +} diff --git a/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceSucceed.java b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceSucceed.java new file mode 100644 index 00000000..5a2caca4 --- /dev/null +++ b/src/main/java/org/araymond/joalcore/core/trackers/domain/AnnounceSucceed.java @@ -0,0 +1,10 @@ +package org.araymond.joalcore.core.trackers.domain; + +import java.time.Duration; +import java.util.Objects; + +public record AnnounceSucceed(Duration interval) { + public AnnounceSucceed { + Objects.requireNonNull("Duration required a non-null interval"); + } +} diff --git a/src/main/java/org/araymond/joalcore/core/trackers/domain/Tracker.java b/src/main/java/org/araymond/joalcore/core/trackers/domain/Tracker.java index 95dde737..44bca53a 100644 --- a/src/main/java/org/araymond/joalcore/core/trackers/domain/Tracker.java +++ b/src/main/java/org/araymond/joalcore/core/trackers/domain/Tracker.java @@ -1,4 +1,71 @@ package org.araymond.joalcore.core.trackers.domain; +import org.araymond.joalcore.annotations.concurency.Immutable; +import org.araymond.joalcore.annotations.ddd.DomainEntity; +import org.araymond.joalcore.annotations.ddd.ValueObject; + +import java.time.Instant; + +@DomainEntity public class Tracker { + private boolean announcing; + private Instant nextAnnounceAt; + private Counter consecutiveFails = new Counter(); + + public Tracker() { + announcing = false; + nextAnnounceAt = Instant.now(); + } + + public boolean requireAnnounce(Instant at) { + if (announcing) { + return false; + } + if (nextAnnounceAt.isAfter(at)) { + return false; + } + return true; + } + + public void announce() { + announcing = true; + } + + public void announceSucceed(AnnounceSucceed response) { + announcing = false; + consecutiveFails = new Counter(); + + nextAnnounceAt = Instant.now().plus(response.interval()); + } + + public void announceFailed(AnnounceFailed response, AnnounceBackoffService backoff) { + announcing = false; + consecutiveFails = consecutiveFails.increment(); + + nextAnnounceAt = Instant.now().plus(backoff.backoff(consecutiveFails.count())); + } + + @Immutable + private static final class Counter { + private final int count; + + public Counter() { + count = 0; + } + + private Counter(int count) { + this.count = count; + } + + public Counter increment() { + if (count == Integer.MAX_VALUE) { + return new Counter(count); + } + return new Counter(count + 1); + } + + public int count() { + return count; + } + } } diff --git a/src/test/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffServiceTest.java b/src/test/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffServiceTest.java new file mode 100644 index 00000000..0d04b6e4 --- /dev/null +++ b/src/test/java/org/araymond/joalcore/core/trackers/domain/AnnounceBackoffServiceTest.java @@ -0,0 +1,43 @@ +package org.araymond.joalcore.core.trackers.domain; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; + +class AnnounceBackoffServiceTest { + + @Test + public void shouldIncrementDelayForEachCall() { + var backoff = new AnnounceBackoffService.DefaultBackoffService(250); + + var durations = IntStream.range(1, 10) + .mapToObj(backoff::backoff) + .toList(); + + List sorted = new ArrayList<>(durations); + sorted.sort(Duration::compareTo); + + // if the original list is the same as the sorted one it means that the duration increase over time + assertThat(durations).isEqualTo(sorted); + } + + @Test + public void shouldNotGoOverMaxRetryDelay() { + var backoff = new AnnounceBackoffService.DefaultBackoffService(250); + + assertThat(backoff.backoff(Integer.MAX_VALUE)).isEqualTo(Duration.ofHours(1)); + } + + @Test + public void shouldNotGoBelowMinRetryDelay() { + var backoff = new AnnounceBackoffService.DefaultBackoffService(1); + + assertThat(backoff.backoff(1)).isGreaterThanOrEqualTo(Duration.ofSeconds(5)); + } + +} \ No newline at end of file diff --git a/src/test/java/org/araymond/joalcore/core/trackers/domain/TrackerTest.java b/src/test/java/org/araymond/joalcore/core/trackers/domain/TrackerTest.java new file mode 100644 index 00000000..17ec89fc --- /dev/null +++ b/src/test/java/org/araymond/joalcore/core/trackers/domain/TrackerTest.java @@ -0,0 +1,78 @@ +package org.araymond.joalcore.core.trackers.domain; + +import org.junit.jupiter.api.Test; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Random; + +import static org.assertj.core.api.Assertions.assertThat; + +class TrackerTest { + + public Instant inThirtyMinutes() { + return Instant.now().plus(Duration.ofMinutes(30)); + } + public Instant now() { + return Instant.now(); + } + + private URL randomUrl() { + try { + var rand = new Random(); + return URI.create("http://a.%d.com/path".formatted(rand.nextInt())).toURL(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + + private Tracker randomTracker() { + return new Tracker(); + } + + @Test + public void shouldRequireAnnounceOnCreation() { + var tracker = randomTracker(); + + assertThat(tracker.requireAnnounce(now())).isTrue(); + } + + @Test + public void shouldNotRequireAnnounceAfterAnAnnounceWhileAResponseIsNotReceived() { + var tracker = randomTracker(); + + tracker.announce(); + assertThat(tracker.requireAnnounce(now())).isFalse(); + assertThat(tracker.requireAnnounce(inThirtyMinutes())).isFalse(); + + tracker.announceSucceed(new AnnounceSucceed(Duration.ofMinutes(30))); + assertThat(tracker.requireAnnounce(now())).isFalse(); + assertThat(tracker.requireAnnounce(inThirtyMinutes())).isTrue(); + } + + + @Test + public void shouldNotRequireAnnounceAfterAnAnnounceWhileAnErrorIsNotReceived() { + var tracker = randomTracker(); + + tracker.announce(); + assertThat(tracker.requireAnnounce(now())).isFalse(); + assertThat(tracker.requireAnnounce(inThirtyMinutes())).isFalse(); + + tracker.announceFailed(new AnnounceFailed(), new AnnounceBackoffService.DefaultBackoffService()); + assertThat(tracker.requireAnnounce(now())).isFalse(); + assertThat(tracker.requireAnnounce(inThirtyMinutes())).isTrue(); + } + + // TODO: add disable method + // TODO: impossible to announce while disabled + // TODO: requireAnnounce returns False for disabled + // TODO: make it not possible to announce while still awaiting answer (unless STOPPED) + // TODO: make it possible to announce COMPLETED AND STOPPED even when nextAnnounce is not reached + // TODO: test that the backoff is reset after a successful announce + +} \ No newline at end of file