Skip to content

Quartz-style Nth Day of Week cron expressions can overflow to other month #34360

Closed
@RingoDev

Description

@RingoDev

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)

  1. 0 0 8 ? * MON#1 - run every first Monday of the month
  2. 0 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:

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;
   }
}

Metadata

Metadata

Assignees

Labels

in: coreIssues in core modules (aop, beans, core, context, expression)status: backportedAn issue that has been backported to maintenance branchestype: bugA general bug

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions