diff --git a/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/Schedule.java b/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/Schedule.java index 1877edc..d357194 100644 --- a/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/Schedule.java +++ b/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/Schedule.java @@ -12,7 +12,7 @@ static Cron cron(String expression){ } static Interval interval(Duration interval){ - return new Interval(interval); + return new Interval(interval, TimeRange.infinite()); } static Fixed fixed(Instant at){ @@ -44,7 +44,7 @@ public String scheduleExpression() { * @param interval A positive Duration object that represents the time interval between each job execution. * At least 1 second */ - record Interval(Duration interval) implements Schedule { + record Interval(Duration interval, TimeRange timeRange) implements Schedule { public Interval { if (interval.isNegative()) { throw new IllegalArgumentException("Interval must be positive"); @@ -52,12 +52,31 @@ record Interval(Duration interval) implements Schedule { if (interval.getSeconds() < 1) { throw new IllegalArgumentException("Interval must be at least 1 second"); } + if(timeRange == null){ + throw new IllegalArgumentException("Interval must have a specified time range in which it will be active"); + } } @Override public String scheduleExpression() { return "@every %ss".formatted(interval.getSeconds()); } + + /** + * @param startInstant date after which Schedule will be active + * @return new Schedule Interval with a start date instant + */ + public Interval startAfter(Instant startInstant){ + return new Interval(interval, timeRange.withStart(startInstant)); + } + + /** + * @param endInstant date after which Schedule will NOT be active + * @return new Schedule Interval with an end date instant + */ + public Interval endBefore(Instant endInstant){ + return new Interval(interval, timeRange.withEnd(endInstant)); + } } /** diff --git a/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/TimeRange.java b/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/TimeRange.java new file mode 100644 index 0000000..8613a5d --- /dev/null +++ b/dkron-scheduler-client/src/main/java/com/github/bloowper/schedulerclient/api/TimeRange.java @@ -0,0 +1,49 @@ +package com.github.bloowper.schedulerclient.api; + +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.Optional; + +public class TimeRange { + @Nullable + private final Instant start; + @Nullable + private final Instant end; + + public TimeRange(@Nullable Instant start, @Nullable Instant end) { + if(start != null && end != null && start.isAfter(end)) { + throw new IllegalArgumentException("Start have to be smaller than end"); + } + this.start = start; + this.end = end; + } + + TimeRange withStart(Instant start) { + return new TimeRange(start, this.end); + } + + TimeRange withEnd(Instant end) { + return new TimeRange(this.start, end); + } + + static TimeRange start(Instant start){ + return new TimeRange(start, null); + } + + static TimeRange end(Instant end){ + return new TimeRange(null, end); + } + + static TimeRange infinite(){ + return new TimeRange(null, null); + } + + Optional getStart(){ + return Optional.ofNullable(start); + } + + Optional getEnd(){ + return Optional.ofNullable(end); + } +} diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/SharedTestInitializer.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/SharedTestInitializer.java index 681af5c..273fa5d 100644 --- a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/SharedTestInitializer.java +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/SharedTestInitializer.java @@ -10,7 +10,7 @@ import static org.mockito.Mockito.mock; -class SharedTestInitializer { +public class SharedTestInitializer { protected static final Instant NOW = Instant.parse("2021-01-01T00:00:00.00Z"); protected static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/IntervalScheduleTest.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/IntervalScheduleTest.java new file mode 100644 index 0000000..aa2abb8 --- /dev/null +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/IntervalScheduleTest.java @@ -0,0 +1,72 @@ +package com.github.bloowper.schedulerclient.api; + + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class IntervalScheduleTest { + public static final Instant EARLY_INSTANT = Instant.parse("2021-01-01T00:00:00Z"); + + @Test + void shouldCreateIntervalSchedule() { + // Given + Duration interval = Duration.ofSeconds(60); + + // When + Schedule.Interval schedule = Schedule.interval(interval); + + // Then + assertEquals("@every 60s", schedule.scheduleExpression()); + } + + @Test + void intervalScheduleCreationShouldFailedOnToShortDuration() { + // Given + Duration interval = Duration.ofMillis(250); + + // Then + assertThatThrownBy(() -> Schedule.interval(interval)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Interval must be at least 1 second"); + } + + @Test + void intervalScheduleCreationShouldFailedOnNegativeDuration() { + // Given + Duration interval = Duration.ofSeconds(-60); + + // Then + assertThatThrownBy(() -> Schedule.interval(interval)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Interval must be positive"); + } + + @Test + void shouldAddStartInstantToInterval() { + // Given + Schedule.Interval schedule = Schedule.interval(Duration.ofSeconds(60)); + + // When + Schedule.Interval scheduleWithStartInstant = schedule.startAfter(EARLY_INSTANT); + + // Then + assertEquals(scheduleWithStartInstant.timeRange().getStart().get(), EARLY_INSTANT); + } + + @Test + void shouldAddEndInstantToInterval() { + // Given + Schedule.Interval schedule = Schedule.interval(Duration.ofSeconds(60)); + + // When + Schedule.Interval scheduleWithEndInstant = schedule.endBefore(EARLY_INSTANT); + + // Then + assertEquals(scheduleWithEndInstant.timeRange().getEnd().get(), EARLY_INSTANT); + } +} diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/JobSchedulerTest.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/JobSchedulerTest.java similarity index 78% rename from dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/JobSchedulerTest.java rename to dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/JobSchedulerTest.java index 9ce9adf..b72a69b 100644 --- a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/JobSchedulerTest.java +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/api/JobSchedulerTest.java @@ -1,14 +1,11 @@ -package com.github.bloowper.schedulerclient; +package com.github.bloowper.schedulerclient.api; -import com.github.bloowper.schedulerclient.api.JobDescription; -import com.github.bloowper.schedulerclient.api.JobId; -import com.github.bloowper.schedulerclient.api.Schedule; +import com.github.bloowper.schedulerclient.SharedTestInitializer; import org.junit.jupiter.api.Test; import java.time.Instant; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; class JobSchedulerTest extends SharedTestInitializer { diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/common/TestStopper.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/common/TestStopper.java new file mode 100644 index 0000000..40eec2a --- /dev/null +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/common/TestStopper.java @@ -0,0 +1,29 @@ +package com.github.bloowper.schedulerclient.common; + +import lombok.Getter; + +import java.time.Duration; +import java.time.Instant; + +@Getter +public class TestStopper { + private final Instant start; + private Instant end; + + public TestStopper(Instant start) { + this.start = start; + } + + public static TestStopper start(){ + return new TestStopper(Instant.now()); + } + + public TestStopper stop(){ + end = Instant.now(); + return this; + } + + public Duration getElapsedTime(){ + return Duration.between(start, end); + } +} diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/JobExecutionListener.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/JobExecutionListener.java index 5966843..1112b38 100644 --- a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/JobExecutionListener.java +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/JobExecutionListener.java @@ -5,23 +5,28 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; +import java.time.Instant; import java.util.ArrayList; import java.util.List; @Slf4j class JobExecutionListener { - private List dummyNotificationDtos = new ArrayList<>(); + final private List dummyNotificationDtos = new ArrayList<>(); + final private List executionDates = new ArrayList<>(30); @EventListener void onJobExecution(DummyNotificationDto dummyNotificationDto) { - log.info("Received notification: {}", dummyNotificationDto); + Instant now = Instant.now(); + log.info("Received notification: {} at: {}", dummyNotificationDto,now); dummyNotificationDtos.add(dummyNotificationDto); + executionDates.add(now); } - public Boolean wasExecuted(DummyNotificationDto dummyNotificationDto, Integer times) { + public Boolean wasExecutedWith(DummyNotificationDto dummyNotificationDto, Integer times) { return dummyNotificationDtos.stream().filter(dummyNotificationDto::equals).count() == times; } - public void resetExecutionStatus() { - dummyNotificationDtos = new ArrayList<>(); + public Boolean wasExecutedWith(DummyNotificationDto dummyNotificationDto){ + return dummyNotificationDtos.stream().anyMatch(dummyNotificationDto::equals); } + } diff --git a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/SchedulingExecutionIT.java b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/SchedulingExecutionIT.java index 5bbd70e..508e4f1 100644 --- a/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/SchedulingExecutionIT.java +++ b/dkron-scheduler-client/src/test/java/com/github/bloowper/schedulerclient/integration/SchedulingExecutionIT.java @@ -5,6 +5,8 @@ import com.github.bloowper.schedulerclient.api.JobId; import com.github.bloowper.schedulerclient.api.JobScheduler; import com.github.bloowper.schedulerclient.api.Schedule; +import com.github.bloowper.schedulerclient.common.TestStopper; +import lombok.SneakyThrows; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.annotation.DirtiesContext; @@ -12,11 +14,13 @@ import java.time.Duration; import java.time.Instant; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; class SchedulingExecutionIT extends IntegrationTestBase { -// Uses default JonExecutionNotifier + private static final Duration ALLOWED_DIFFERENCE = Duration.ofMillis(950); + // Uses default JonExecutionNotifier @Autowired private JobScheduler jobScheduler; @Autowired @@ -24,66 +28,85 @@ class SchedulingExecutionIT extends IntegrationTestBase { // TODO: better handling of job execution listener then @DirtyContext @Test - @DirtiesContext - void createFixedJobAndAwaitForExecution() { + @SneakyThrows + void fixedJobShouldBeExecuted() { // Given - DummyNotificationDto notificationDto = new DummyNotificationDto("dummyValue1"); - System.out.println("notificationDto: " + notificationDto); - JobId id = JobId.autoGenerated(); - Instant now = Instant.now(); + TestStopper stopper = TestStopper.start(); + Duration delay = Duration.ofSeconds(30); + DummyNotificationDto dummyNotificationDto = new DummyNotificationDto("fixed job should not be called before execution date"); JobDescription jobDescription = new JobDescription( - id, - notificationDto, - Schedule.fixed(now.plus(Duration.ofSeconds(3))) + dummyNotificationDto, + Schedule.fixed(stopper.getStart().plus(delay)) ); // When - jobScheduler.scheduleJob(jobDescription); + JobId jobId = jobScheduler.scheduleJob(jobDescription); // Then - await().atMost(Duration.ofSeconds(30)).until(() -> jobExecutionListener.wasExecuted(notificationDto,1)); + await().atMost(delay).until(() -> jobExecutionListener.wasExecutedWith(dummyNotificationDto,1)); + stopper.stop(); + Duration elapsedTime = stopper.getElapsedTime(); + assertThat(elapsedTime).isCloseTo(delay, ALLOWED_DIFFERENCE); } + @Test @DirtiesContext - void createIntervalJobAndAwaitForExecution() { + void createCronJobAndAwaitForExecution() { // Given - DummyNotificationDto notificationDto = new DummyNotificationDto("dummyValue2"); - System.out.println("notificationDto: " + notificationDto); + DummyNotificationDto notificationDto = new DummyNotificationDto("dummyValue3"); JobId id = JobId.autoGenerated(); JobDescription jobDescription = new JobDescription( id, notificationDto, - Schedule.interval(Duration.ofSeconds(3)) + Schedule.cron("*/4 * * * * *") ); // When jobScheduler.scheduleJob(jobDescription); // Then - await().atMost(Duration.ofSeconds(30)).until(() -> jobExecutionListener.wasExecuted(notificationDto,2)); + await().atMost(Duration.ofSeconds(30)).until(() -> jobExecutionListener.wasExecutedWith(notificationDto,1)); } @Test @DirtiesContext - void createCronJobAndAwaitForExecution() { + void createIntervalJobAndAwaitForExecutions() { // Given - DummyNotificationDto notificationDto = new DummyNotificationDto("dummyValue3"); - System.out.println("notificationDto: " + notificationDto); + DummyNotificationDto notificationDto = new DummyNotificationDto("interval job execution test"); JobId id = JobId.autoGenerated(); JobDescription jobDescription = new JobDescription( id, notificationDto, - Schedule.cron("*/4 * * * * *") + Schedule.interval(Duration.ofSeconds(3)) ); // When jobScheduler.scheduleJob(jobDescription); // Then - await().atMost(Duration.ofSeconds(30)).until(() -> jobExecutionListener.wasExecuted(notificationDto,1)); + await().atMost(Duration.ofSeconds(30)).until(() -> jobExecutionListener.wasExecutedWith(notificationDto,2)); } + @Test + void createIntervalJobWithDelayAndAwaitForExecution(){ + // Given + TestStopper stopper = TestStopper.start(); + Instant now = stopper.getStart(); + Duration interval = Duration.ofSeconds(1); + Duration delay = Duration.ofSeconds(10); + DummyNotificationDto notificationDto = new DummyNotificationDto("job that should be called every second after given instant"); + Schedule.Interval schedule = Schedule.interval(interval).startAfter(now.plus(delay)); // Job that run every 1 second but after 10-second delay + JobDescription jobDescription = new JobDescription(notificationDto, schedule); + + // When + jobScheduler.scheduleJob(jobDescription); + + // Then + await().atMost(delay).until(() -> jobExecutionListener.wasExecutedWith(notificationDto)); + Duration elapsedTime = stopper.stop().getElapsedTime(); + assertThat(elapsedTime).isCloseTo(delay, ALLOWED_DIFFERENCE); + } }