From cddc671a8c6eea118b1f7ef05f2b6d754e852f09 Mon Sep 17 00:00:00 2001 From: arno Date: Wed, 3 Jun 2026 15:16:16 +0800 Subject: [PATCH 1/2] Fix CronExpression day skip on midnight DST gap After rollForward, BitsCronField always searched for the next matching bit from zero. When daylight saving creates a gap at the start of a period (e.g. Africa/Cairo), the temporal lands on a non-zero field value and matching from zero could advance an entire period too far, skipping the calendar day. Search from the actual field value in the new period instead, falling back to zero only when no bit matches in that period. See gh-36865 Signed-off-by: arno Co-authored-by: Cursor --- .../scheduling/support/BitsCronField.java | 12 ++++++++++-- .../scheduling/support/CronExpressionTests.java | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java index 59043762dc46..ef6d34347c52 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java @@ -177,7 +177,11 @@ private static ValueRange parseRange(String value, Type type) { int next = nextSetBit(current); if (next == -1) { temporal = type().rollForward(temporal); - next = nextSetBit(0); + next = nextSetBit(type().get(temporal)); + if (next == -1) { + temporal = type().rollForward(temporal); + next = nextSetBit(0); + } } if (next == current) { return temporal; @@ -191,7 +195,11 @@ private static ValueRange parseRange(String value, Type type) { next = nextSetBit(current); if (next == -1) { temporal = type().rollForward(temporal); - next = nextSetBit(0); + next = nextSetBit(type().get(temporal)); + if (next == -1) { + temporal = type().rollForward(temporal); + next = nextSetBit(0); + } } } if (count >= CronExpression.MAX_ATTEMPTS) { diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java index a60c64e2526f..c35abbee6541 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java @@ -1367,6 +1367,14 @@ void daylightSaving() { actual = cronExpression.next(last); assertThat(actual).isNotNull(); assertThat(actual).isEqualTo(expected); + + cronExpression = CronExpression.parse("0 0 */2 * * ?"); + + last = ZonedDateTime.parse("2025-04-24T22:00:00+02:00[Africa/Cairo]"); + expected = ZonedDateTime.parse("2025-04-25T02:00:00+03:00[Africa/Cairo]"); + actual = cronExpression.next(last); + assertThat(actual).isNotNull(); + assertThat(actual).isEqualTo(expected); } @Test From 6467fca05b874f3e44fbd9451a252f035abbeafc Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Thu, 4 Jun 2026 10:19:27 +0200 Subject: [PATCH 2/2] Polishing contribution This fixes a potential regression introduced by the previous commit. Because the current value was not updated after the temporal was rolled forward, there were new cases where entire days would be skipped. Closes gh-36865 --- .../scheduling/support/BitsCronField.java | 1 + .../support/BitsCronFieldTests.java | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java index ef6d34347c52..9af70129bd05 100644 --- a/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java +++ b/spring-context/src/main/java/org/springframework/scheduling/support/BitsCronField.java @@ -200,6 +200,7 @@ private static ValueRange parseRange(String value, Type type) { temporal = type().rollForward(temporal); next = nextSetBit(0); } + current = type().get(temporal); } } if (count >= CronExpression.MAX_ATTEMPTS) { diff --git a/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java index d410d44de6e9..7361054fc3ec 100644 --- a/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java +++ b/spring-context/src/test/java/org/springframework/scheduling/support/BitsCronFieldTests.java @@ -16,6 +16,7 @@ package org.springframework.scheduling.support; +import java.time.ZonedDateTime; import java.util.Arrays; import org.assertj.core.api.Condition; @@ -29,6 +30,7 @@ * * @author Arjen Poutsma * @author Sam Brannen + * @author Brian Clozel */ class BitsCronFieldTests { @@ -112,6 +114,24 @@ void names() { .has(clear(0)).has(setRange(1, 7)); } + @Test + void nextOrSameWithMidnightGap() { + BitsCronField field = BitsCronField.parseHours("0-23/2"); + ZonedDateTime last = ZonedDateTime.parse("2025-04-24T23:00:00+02:00[Africa/Cairo]"); + ZonedDateTime expected = ZonedDateTime.parse("2025-04-25T02:00:00+03:00[Africa/Cairo]"); + ZonedDateTime actual = field.nextOrSame(last); + assertThat(actual).isEqualTo(expected); + } + + @Test + void nextOrSameWithGapAfterRollForward() { + BitsCronField field = BitsCronField.parseHours("0,2"); + ZonedDateTime last = ZonedDateTime.parse("2026-03-08T01:00:00-05:00[America/New_York]"); + ZonedDateTime expected = ZonedDateTime.parse("2026-03-09T00:00:00-04:00[America/New_York]"); + ZonedDateTime actual = field.nextOrSame(last); + assertThat(actual).isEqualTo(expected); + } + private static Condition set(int... indices) { return new Condition<>(String.format("set bits %s", Arrays.toString(indices))) {