Repository: kagkarlsson/db-scheduler (java)

Issue:
Bug in Daily Schedule handling during DST transition
Hi,
I’ve encountered a bug in the Daily schedule related to the Daylight Saving Time transition.

**Description**
When using a daily schedule with a time like 01:30 in the Europe/London timezone, the DST transition on March 30, 2025 causes an issue. 

Specifically:
`ZonedDateTime.of(LocalDate.of(2025, 3, 30), LocalTime.of(1, 30), ZoneId.of("Europe/London"))` returns 02:30, since 01:30 does not exist on that day due to the clock jumping forward. This can be discussed but is a reasonable behavior. However, the bug arises in the next scheduled iteration, which also returns 02:30 instead of reverting to the correct 01:30.

**Cause**
This behavior seems to stem from the following line in the code:
`return ZonedDateTime.of(doneDate, times.get(0), zone).plusDays(1).toInstant();`

This constructs a ZonedDateTime with doneDate and times.get(0) first, and then adds one day. If doneDate was 2025-03-30 and the time was adjusted to 02:30 due to DST, then .plusDays(1) results in 2025-03-31T02:30, persisting the shifted time.

**Suggested Fix**

The correct logic should be:
`return ZonedDateTime.of(doneDate.plusDays(1), times.get(0), zone).toInstant();`

This way, doneDate is incremented before constructing the ZonedDateTime, so the intended time (01:30) is correctly applied to the new date.

Expected Behavior
	•	On March 30, 2025, the scheduled time should resolve to 02:30 (since 01:30 doesn’t exist).
	•	On March 31, 2025, it should go back to 01:30, as that time is valid again.

Let me know if you need a reproducible test case and a PR for the solution — happy to provide one. Thanks for your work on this great library!


Discussion:

Patch:
diff --git a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Daily.java b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Daily.java
--- a/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Daily.java
+++ b/db-scheduler/src/main/java/com/github/kagkarlsson/scheduler/task/schedule/Daily.java
@@ -51,7 +51,7 @@ public Daily(ZoneId zone, LocalTime... times) {
 
   public Daily(ZoneId zone, List<LocalTime> times) {
     this.zone = Objects.requireNonNull(zone, "zone cannot be null");
-    if (times.size() < 1) {
+    if (times.isEmpty()) {
       throw new IllegalArgumentException("times cannot be empty");
     }
     this.times = times.stream().sorted().collect(Collectors.toList());
@@ -69,7 +69,7 @@ public Instant getNextExecutionTime(ExecutionComplete executionComplete) {
       }
     }
 
-    return ZonedDateTime.of(doneDate, times.get(0), zone).plusDays(1).toInstant();
+    return ZonedDateTime.of(doneDate.plusDays(1), times.get(0), zone).toInstant();
   }
 
   @Override


Test patch:
diff --git a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/DailyTest.java b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/DailyTest.java
--- a/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/DailyTest.java
+++ b/db-scheduler/src/test/java/com/github/kagkarlsson/scheduler/task/DailyTest.java
@@ -143,7 +143,6 @@ public void should_generate_next_date_correctly_on_the_day_before_time_changes_t
     // Europe/Rome is UTC+2 during daylight saving time
     // Daylight saving begins on 29 March 2020 at 2AM
     LocalDate currentDate = LocalDate.of(2020, 3, 29);
-    LocalDate nextDay = currentDate.plusDays(1);
 
     assertThat(
         new Daily(ROME, HOUR_2).getNextExecutionTime(complete(instant(currentDate, HOUR_0, UTC))),
@@ -169,6 +168,62 @@ public void should_generate_next_date_correctly_on_the_day_when_time_changes_to_
         is(instant(currentDate, HOUR_2, UTC)));
   }
 
+  @Test
+  public void
+      should_generate_next_date_correctly_on_the_day_when_time_changes_to_standard_exists_twice_should_run_once() {
+    // Europe/Rome is UTC+1 during standard time
+    // Europe/Rome is UTC+2 during daylight saving time
+    // Standard time begins in Rome on 25 October 2020
+    LocalDate currentDate = LocalDate.of(2020, 10, 25);
+    LocalDate previousDate = currentDate.minusDays(1);
+    LocalDate nextDate = currentDate.plusDays(1);
+
+    // 02:00 appear twice in Rome at 2020-10-25, one at 00:00 UTC, one at 01:00 UTC
+    Daily dailyAt2 = new Daily(ROME, HOUR_2);
+    // expect the instance with earlier offset (02:00 UTC+2)
+    Instant expectedCurrentDateExecutionTime = instant(currentDate, HOUR_0, UTC);
+    assertThat(expectedCurrentDateExecutionTime, is(instant(currentDate, HOUR_2, ROME)));
+    // next execution time should be the day after with new offset (UTC+1)
+    Instant expectedNextDateExecutionTime = instant(nextDate, HOUR_1, UTC);
+    assertThat(expectedNextDateExecutionTime, is(instant(nextDate, HOUR_2, ROME)));
+
+    assertThat(
+        dailyAt2.getNextExecutionTime(complete(instant(previousDate, HOUR_0, UTC))),
+        is(expectedCurrentDateExecutionTime));
+    assertThat(
+        dailyAt2.getNextExecutionTime(complete(instant(currentDate, HOUR_0, UTC))),
+        is(expectedNextDateExecutionTime));
+  }
+
+  @Test
+  public void
+      should_generate_next_date_correctly_on_the_day_when_time_changes_to_daylight_saving_time_doesnt_exists_should_run_later_then_recover() {
+    // Europe/Rome is UTC+1 during standard time
+    // Europe/Rome is UTC+2 during daylight saving time
+    // Daylight saving begins on 29 March 2020 at 2AM
+    LocalDate currentDate = LocalDate.of(2020, 3, 29);
+    LocalDate previousDate = currentDate.minusDays(1);
+    LocalDate nextDate = currentDate.plusDays(1);
+
+    // 02:00 never appear in Rome at 2020-03-29, because after 01:59 UTC+1, we have 03:00 UTC+2
+    Daily dailyAtTwo = new Daily(ROME, HOUR_2);
+
+    // expect the instance with earlier offset (02:00 UTC+1, which is 03:00 UTC+2 or 01:00 UTC+0)
+    Instant expectedCurrentDateExecutionTime = instant(currentDate, HOUR_1, UTC);
+    assertThat(expectedCurrentDateExecutionTime, is(instant(currentDate, HOUR_2, ROME)));
+
+    // next execution time should be the day after with new offset (UTC+2)
+    Instant expectedNextDateExecutionTime = instant(nextDate, HOUR_0, UTC);
+    assertThat(expectedNextDateExecutionTime, is(instant(nextDate, HOUR_2, ROME)));
+
+    assertThat(
+        dailyAtTwo.getNextExecutionTime(complete(instant(previousDate, HOUR_2, ROME))),
+        is(expectedCurrentDateExecutionTime));
+    assertThat(
+        dailyAtTwo.getNextExecutionTime(complete(instant(currentDate, HOUR_2, ROME))),
+        is(expectedNextDateExecutionTime));
+  }
+
   @Test
   public void equals_and_hash_code() {
     EqualsVerifier.forClass(Daily.class).verify();