Skip to content

Fix GetPreviousOccurrence for occurrences in the spring-forward gap - #94

Open
israellot wants to merge 1 commit into
HangfireIO:mainfrom
israellot:fix/previous-occurrence-invalid-time
Open

Fix GetPreviousOccurrence for occurrences in the spring-forward gap#94
israellot wants to merge 1 commit into
HangfireIO:mainfrom
israellot:fix/previous-occurrence-invalid-time

Conversation

@israellot

Copy link
Copy Markdown
Contributor

Problem

GetNextOccurrence and GetPreviousOccurrence disagree about occurrences that fall into the gap skipped when the clock jumps forward to daylight saving time.

GetNextOccurrence shifts such an occurrence forward to the moment DST starts (documented in the README: Mar 14, 03:00 +04:00 – run (adjusted)). GetPreviousOccurrence shifted it backward to the last valid second before the gap — a second that isn't an occurrence of the expression at all.

Reproducing #92 with 30 2 * * * in W. Europe Standard Time around the 2024-03-31 transition:

Direction Local (NL)
GetNextOccurrence 2024-03-31 03:00:00 +02:00
GetPreviousOccurrence (before the fix) 2024-03-31 01:59:59 +01:00

So walking a schedule backwards does not retrace the forward sequence, and GetOccurrences disagrees with itself depending on direction.

This isn't specific to one zone or expression. Using forward enumeration as the oracle and probing every minute in a ±26h window around every DST transition from 2017–2025, the pre-fix code mismatches in 12 distinct (zone, expression, inclusive/exclusive) groupsEurope/Berlin, America/New_York, Australia/Lord_Howe, Pacific/Chatham, America/Santiago, America/Sao_Paulo — and every one of them sits on a spring-forward transition.

Fix

GetPreviousOccurrenceConsideringTimeZone now shifts an invalid-time occurrence forward to the start of DST, exactly as GetNextOccurrence does.

Because that shifted instant can land at or after the requested from, the search loops and continues right before the invalid interval when that happens — so GetPreviousOccurrence still never returns a value later than from. Both the inclusive and exclusive boundaries at the shifted instant are covered by tests.

Two existing tests asserted the old backward-shift behaviour and are updated to the mirrored expectation; they were the encoding of this bug:

  • GetPreviousOccurrence_AdjustsInvalidTimeBackwardAcrossSpringForward..._ShiftsInvalidTimeForwardAcrossSpringForward
  • GetPreviousOccurrence_AdjustsInvalidHashTimeBackwardAcrossSpringForward..._ShiftsInvalidHashTimeForwardAcrossSpringForward

How to test

dotnet test tests/Cronos.Tests/Cronos.Tests.csproj -f net8.0

Result on this branch: 1867 passed, 12 failed. All 12 failures are the pre-existing Asia/Amman tzdb-dependent failures already tracked in #91 — they fail identically on unmodified main on this machine, are all GetNextOccurrence tests, and none are touched by this change.

New tests (9 added, 2 updated, all passing):

  • GetPreviousOccurrence_ShiftsInvalidTimeForwardAcrossSpringForward / ..._ShiftsInvalidHashTimeForwardAcrossSpringForward
  • GetPreviousOccurrence_SkipsInvalidTimeShiftedAfterFrom_AndReturnsEarlierOccurrence — the "shifted occurrence isn't in the past" path
  • GetPreviousOccurrence_ReturnsShiftedInvalidTime_WhenFromEqualsItAndInclusive / ..._SkipsShiftedInvalidTime_WhenFromEqualsItAndExclusive — boundary behaviour
  • GetPreviousOccurrence_MirrorsGetNextOccurrenceAcrossSpringForward — the exact instants from BUG - GetPreviousOccurrence returns incorrect value around daylight savings time #92
  • GetPreviousOccurrence_RetracesGetNextOccurrenceAroundSpringForward — 6 expressions, walks forward across the 2024-03-31 transition then walks back and asserts the sequences agree

Beyond the suite, an out-of-tree harness probed 7,321,866 GetPreviousOccurrence calls (6 zones × 13 expressions × every DST transition 2017–2025 × every minute in ±26h × inclusive and exclusive) against forward enumeration as ground truth: 0 mismatches after the fix, 12 mismatching groups before it.

Asia/Amman is excluded from that harness because GetNextOccurrence — the oracle — is itself wrong there on a Linux tzdb (it returns the same instant repeatedly with an offset that contradicts TimeZoneInfo.GetUtcOffset). That is the pre-existing behaviour behind #91 and is out of scope here.

Platforms tested

Linux (arm64, .NET 10 SDK) fully verified on net8.0 and net6.0 — identical results. net462 requires Mono and was not run; the change uses no new APIs and no platform-specific primitives (only existing TimeZoneHelper members already used by the forward path).

Related: #91 — independent, pre-existing Asia/Amman tzdb failures in GetNextOccurrence, not affected by this change.

GetNextOccurrence shifts an occurrence scheduled to an invalid local time
(the gap skipped when clocks jump forward) to the moment DST starts, but
GetPreviousOccurrence shifted it backward to the last valid second before
the gap. That second is not an occurrence of the expression at all, so the
two directions disagreed about the same slot and walking back through a
schedule did not retrace the forward sequence.

For "30 2 * * *" in W. Europe Standard Time around the 2024-03-31
transition, GetNextOccurrence returns 2024-03-31 03:00 +02:00 while
GetPreviousOccurrence returned 2024-03-31 01:59:59 +01:00.

Shift the occurrence forward to the start of daylight saving time, matching
GetNextOccurrence. Because that shifted instant can land at or after the
requested "from" value, keep searching before the invalid interval when it
does, so GetPreviousOccurrence never returns a value later than "from".

Fixes HangfireIO#92
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant