Skip to content

Commit

Permalink
Start tracker implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
anthonyraymond committed Aug 20, 2024
1 parent 6fd281f commit 7466a1f
Show file tree
Hide file tree
Showing 8 changed files with 267 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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 {

}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package org.araymond.joalcore.core.trackers.domain;

public record AnnounceFailed() {
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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));
}

}
Original file line number Diff line number Diff line change
@@ -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

}

0 comments on commit 7466a1f

Please sign in to comment.