The issue
We are experiencing a weird issue with Cron Expressions. We are using the @Scheduled
annotation to run scheduled jobs every monday.
We have a use case where we want to run a schedule differently if it is the first Monday of the month, which is why we split our schedules up into 2 CRON expressions, using the "day of week" notation (documented here)
0 0 8 ? * MON#1
- run every first Monday of the month0 0 8 ? * MON#2,MON#3,MON#4,MON#5
- run on all other Mondays of the month
This worked fine until we observed weird behavior today on Monday the 3rd February 2025. The second CRON expression fired even though it should not fire on the first Monday of a month.
I run a test for the next 10000 invocations and these are the 10 dates that this unexpected overlap will happen next:
[
2025-02-03T08:00Z (java.time.OffsetDateTime),
2026-02-02T08:00Z (java.time.OffsetDateTime),
2027-02-01T08:00Z (java.time.OffsetDateTime),
2030-02-04T08:00Z (java.time.OffsetDateTime),
2031-02-03T08:00Z (java.time.OffsetDateTime),
2037-02-02T08:00Z (java.time.OffsetDateTime),
2038-02-01T08:00Z (java.time.OffsetDateTime),
2041-02-04T08:00Z (java.time.OffsetDateTime),
2042-02-03T08:00Z (java.time.OffsetDateTime),
2043-02-02T08:00Z (java.time.OffsetDateTime),
2047-02-04T08:00Z (java.time.OffsetDateTime),
]
Conclusion
It seems as if the day of week expression (e.g. MON#5
) overflows only from January to February in the years where January has less than 5 Mondays. (This is why 31.1.2028 is not up there for example).
Reproduction
This can be reproduced with spring-context-6.2.2 with following test case:
```java package com.dynatrace.security.vulnerabilityreminderservice.scheduler;
import org.junit.jupiter.api.Test; import org.springframework.scheduling.support.CronExpression;
import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
public class CronExpressionTest {
@Test
public void ensureNoOverlap() {
int numberOfInvocations = 10000;
var seed = OffsetDateTime.of(2025, 1, 1, 0, 0, 0, 0, ZoneOffset.UTC);
var monthly = CronExpression.parse("0 0 8 ? * MON#1");
var weekly = CronExpression.parse("0 0 8 ? * MON#2,MON#3,MON#4,MON#5");
var nextMonthlyInvocations = getNextInvocationsFromSeed(numberOfInvocations, monthly, seed);
var nextWeeklyInvocations = getNextInvocationsFromSeed(numberOfInvocations, weekly, seed);
List<OffsetDateTime> matches = new ArrayList<>();
// Assert that there are no overlaps between the next invocations
for (var invocation : nextWeeklyInvocations) {
if (nextMonthlyInvocations.contains(invocation)) {
matches.add(invocation);
}
}
assertThat(matches).isEmpty();
}
private List<OffsetDateTime> getNextInvocationsFromSeed(int numberOfInvocations, CronExpression expression, OffsetDateTime seed) {
var next = seed;
List<OffsetDateTime> nextInvocations = new ArrayList<>();
for (int i = 0; i < numberOfInvocations; i++) {
next = expression.next(next);
nextInvocations.add(next);
}
return nextInvocations;
}
}
**Comment From: RingoDev**
I created a simplified test case that fits into the `spring-context/src/test/java/org/springframework/scheduling/support/CronExpressionTests.java` test class. This test should pass in my opinion, but doesn't:
@Test void fifthOccurrenceOfDayOfWeekInMonth() { CronExpression expression = CronExpression.parse("0 0 0 ? * MON#5");
LocalDateTime last = LocalDateTime.of(2025, 1, 1, 0, 0, 0);
// first occurrence of 5 mondays in a month from last
LocalDateTime expected = LocalDateTime.of(2025, 3, 31, 0, 0, 0);
LocalDateTime actual = expression.next(last);
assertThat(actual).isNotNull();
assertThat(actual).isEqualTo(expected);
assertThat(actual.getDayOfWeek()).isEqualTo(MONDAY);
} ```
Comment From: bclozel
Thanks for the detailed test case @RingoDev , this helped a lot and I've pushed a fix for the next 6.2.x and 6.1.x maintenance releases due next week.