Start tracker implementation

This commit is contained in:
Anthony RAYMOND 2024-08-20 02:22:04 +02:00
parent 6fd281f332
commit 7466a1f0ff
8 changed files with 267 additions and 0 deletions

View file

@ -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 {
}

View file

@ -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 {

View file

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

View file

@ -0,0 +1,4 @@
package org.araymond.joalcore.core.trackers.domain;
public record AnnounceFailed() {
}

View file

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

View file

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

View file

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

View file

@ -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
}