Skip to content

Fix infinite cron trigger fires during DST fall-back transition#2865

Merged
lahma merged 2 commits into
mainfrom
copilot/fix-infinite-trigger-fires
Mar 8, 2026
Merged

Fix infinite cron trigger fires during DST fall-back transition#2865
lahma merged 2 commits into
mainfrom
copilot/fix-infinite-trigger-fires

Conversation

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor

During the first hour after a DST fall-back transition, cron triggers with a local timezone would fire in an infinite loop because GetTimeAfter computed a NextFireTimeUtc that was one hour in the past.

Root Cause

CronExpression.GetTimeAfter converts the input UTC time to local time, advances the cron cursor, then maps back to UTC via TimeZoneUtil.GetUtcOffset. During fall-back, the resulting local time is ambiguous (e.g., 02:01:00 in Amsterdam exists as both CEST+2 and CET+1). GetUtcOffset always picks the DST (Max) offset for ambiguous times — the first occurrence — producing a UTC result before the input:

FireTimeUtc:     2023-10-29 01:00:49 UTC  (= 02:00:49 CET, after fall-back)
NextFireTimeUtc: 2023-10-29 00:01:00 UTC  ← wrong, 1 hour in the past
                                             (02:01:00 CEST instead of 02:01:00 CET)

Fix

After computing the UTC result in GetTimeAfter, if it falls before afterTimeUtc and the computed local time is ambiguous, use the standard (non-DST) offset — Min() of the ambiguous offsets. DST always shifts UTC offset by +1h, so Max() is always DST and Min() is always standard, regardless of hemisphere.

if (d.ToUniversalTime() < afterTimeUtc && TimeZone.IsAmbiguousTime(localDateTime))
{
    TimeSpan standardOffset = TimeZone.GetAmbiguousTimeOffsets(localDateTime).Min();
    d = new DateTimeOffset(localDateTime, standardOffset);
}

Changes

  • src/Quartz/CronExpression.cs — Added post-computation guard in GetTimeAfter to select the standard offset when the DST offset produces a result in the past.
  • src/Quartz.Tests.Unit/DaylightSavingTimeTests.cs — Added regression test ShouldNotFireInfinitelyAfterDstFallBackTransition reproducing the Amsterdam/October 29, 2023 scenario from the issue.
Original prompt

This section details on the original issue you should resolve

<issue_title>Infinite trigger fires in the first hour after switching from DST to ST</issue_title>
<issue_description>Describe the bug

We have a trigger with a cron schedule that should fire once every minute at second 0. During the first hour after switching from daylight saving time to standard time, the trigger fires non-stop. The NextFireTimeUtc seems to be 1 hour too early on the IJobExecutionContext.

Version used

3.11.0 (also tested against 3.8.0 and 2.6.1)

To Reproduce

I have created a project which I have used to test: https://github.com/kkloet1/QuartzNetDST

When run just before the switch from DST to ST we get regular behaviour (with the 1 hour jump as described in the documentation)

---------
Recovering False; FireInstanceId 638341378807257526; FireTimeUtc 29/10/2023 00:58:00 +00:00; NextFireTimeUtc 29/10/2023 00:59:00 +00:00; PreviousFireTimeUtc ; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 00:58:00 +00:00;
---------
Recovering False; FireInstanceId 638341378807257527; FireTimeUtc 29/10/2023 00:59:00 +00:00; NextFireTimeUtc 29/10/2023 02:00:00 +00:00; PreviousFireTimeUtc 29/10/2023 00:58:00 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 00:59:00 +00:00;

But when we start the application in the hour after the switch from DST to ST we get this:

---------
Recovering False; FireInstanceId 638341380475357880; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;
---------
Recovering False; FireInstanceId 638341380475357881; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;
---------
Recovering False; FireInstanceId 638341380475357882; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;
---------
Recovering False; FireInstanceId 638341380475357883; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;
---------
Recovering False; FireInstanceId 638341380475357884; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;
---------
Recovering False; FireInstanceId 638341380475357885; FireTimeUtc 29/10/2023 01:00:49 +00:00; NextFireTimeUtc 29/10/2023 00:01:00 +00:00; PreviousFireTimeUtc 29/10/2023 01:00:49 +00:00; RefireCount 0; ScheduledFireTimeUtc 29/10/2023 01:00:49 +00:00;

Expected behavior

I expect the trigger to only fire once per minute. When FireTimeUtc is 29/10/2023 01:00:49 +00:00 the next NextFireTimeUtc should be 29/10/2023 01:01:00 +00:00

Additional context
The logging given above was generated from running the published test project on a Windows 10 VM that has the date manually set to October 29th 2023 with the timezone set to Europe/Amsterdam.
</issue_description>

Comments on the Issue (you are @copilot in this section)

@lahma Well, I think this should be quite 1:1 port from Java, so it originates from there:

https://github.com/quartz-scheduler/quartz/blob/a5c4d27e963f51097f9b2777489d310a88897ca4/quartz/src/main/java/org/quartz/impl/triggers/CronTriggerImpl.java#L535-L537</comment_new>
<comment_new>@lahma
I wouldn't hold my breath with this one. If you have the time, it would be appreciated if you can come up with a solution.</comment_new>


✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

When computing next fire time in CronExpression.GetTimeAfter, ambiguous
local times during DST fall-back transitions were assigned the DST
(summer) UTC offset, resulting in a UTC time that was before the input.
This caused triggers to always compute a next fire time in the past,
leading to infinite refires.

Fix: after computing the UTC result, if it falls before afterTimeUtc
and the local time is ambiguous, use the standard (non-DST) offset
instead — the minimum of the two ambiguous offsets — which corresponds
to the second (post-fallback) occurrence of the ambiguous local time.

Adds regression test ShouldNotFireInfinitelyAfterDstFallBackTransition
reproducing the Amsterdam/Europe scenario from the issue report.

Co-authored-by: lahma <[email protected]>
@sonarqubecloud

sonarqubecloud Bot commented Mar 5, 2026

Copy link
Copy Markdown

Copilot AI changed the title [WIP] Fix infinite trigger fires after switching from DST to ST Fix infinite cron trigger fires during DST fall-back transition Mar 5, 2026
Copilot finished work on behalf of lahma March 5, 2026 21:55
@lahma lahma added the port-3.x label Mar 8, 2026
@lahma
lahma marked this pull request as ready for review March 8, 2026 18:31
@lahma
lahma merged commit cac2ddf into main Mar 8, 2026
9 checks passed
@lahma
lahma deleted the copilot/fix-infinite-trigger-fires branch March 8, 2026 18:31
lahma added a commit that referenced this pull request Mar 8, 2026
When computing next fire time in CronExpression.GetTimeAfter, ambiguous
local times during DST fall-back transitions were assigned the DST
(summer) UTC offset, resulting in a UTC time that was before the input.
This caused triggers to always compute a next fire time in the past,
leading to infinite refires.

Fix: after computing the UTC result, if it falls before afterTimeUtc
and the local time is ambiguous, use the standard (non-DST) offset
instead — the minimum of the two ambiguous offsets — which corresponds
to the second (post-fallback) occurrence of the ambiguous local time.

Adds regression test ShouldNotFireInfinitelyAfterDstFallBackTransition
reproducing the Amsterdam/Europe scenario from the issue report.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
@lahma lahma removed the port-3.x label Mar 8, 2026
lahma added a commit that referenced this pull request Mar 8, 2026
When computing next fire time in CronExpression.GetTimeAfter, ambiguous
local times during DST fall-back transitions were assigned the DST
(summer) UTC offset, resulting in a UTC time that was before the input.
This caused triggers to always compute a next fire time in the past,
leading to infinite refires.

Fix: after computing the UTC result, if it falls before afterTimeUtc
and the local time is ambiguous, use the standard (non-DST) offset
instead — the minimum of the two ambiguous offsets — which corresponds
to the second (post-fallback) occurrence of the ambiguous local time.

Adds regression test ShouldNotFireInfinitelyAfterDstFallBackTransition
reproducing the Amsterdam/Europe scenario from the issue report.

Co-authored-by: Claude Opus 4.6 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Infinite trigger fires in the first hour after switching from DST to ST

2 participants