Fix GetPreviousOccurrence for occurrences in the spring-forward gap - #94
Open
israellot wants to merge 1 commit into
Open
Fix GetPreviousOccurrence for occurrences in the spring-forward gap#94israellot wants to merge 1 commit into
israellot wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
GetNextOccurrenceandGetPreviousOccurrencedisagree about occurrences that fall into the gap skipped when the clock jumps forward to daylight saving time.GetNextOccurrenceshifts such an occurrence forward to the moment DST starts (documented in the README:Mar 14, 03:00 +04:00 – run (adjusted)).GetPreviousOccurrenceshifted 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:GetNextOccurrence2024-03-31 03:00:00 +02:00GetPreviousOccurrence(before the fix)2024-03-31 01:59:59 +01:00So walking a schedule backwards does not retrace the forward sequence, and
GetOccurrencesdisagrees 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) groups —
Europe/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
GetPreviousOccurrenceConsideringTimeZonenow shifts an invalid-time occurrence forward to the start of DST, exactly asGetNextOccurrencedoes.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 — soGetPreviousOccurrencestill never returns a value later thanfrom. 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→..._ShiftsInvalidTimeForwardAcrossSpringForwardGetPreviousOccurrence_AdjustsInvalidHashTimeBackwardAcrossSpringForward→..._ShiftsInvalidHashTimeForwardAcrossSpringForwardHow to test
Result on this branch: 1867 passed, 12 failed. All 12 failures are the pre-existing
Asia/Ammantzdb-dependent failures already tracked in #91 — they fail identically on unmodifiedmainon this machine, are allGetNextOccurrencetests, and none are touched by this change.New tests (9 added, 2 updated, all passing):
GetPreviousOccurrence_ShiftsInvalidTimeForwardAcrossSpringForward/..._ShiftsInvalidHashTimeForwardAcrossSpringForwardGetPreviousOccurrence_SkipsInvalidTimeShiftedAfterFrom_AndReturnsEarlierOccurrence— the "shifted occurrence isn't in the past" pathGetPreviousOccurrence_ReturnsShiftedInvalidTime_WhenFromEqualsItAndInclusive/..._SkipsShiftedInvalidTime_WhenFromEqualsItAndExclusive— boundary behaviourGetPreviousOccurrence_MirrorsGetNextOccurrenceAcrossSpringForward— the exact instants from BUG - GetPreviousOccurrence returns incorrect value around daylight savings time #92GetPreviousOccurrence_RetracesGetNextOccurrenceAroundSpringForward— 6 expressions, walks forward across the 2024-03-31 transition then walks back and asserts the sequences agreeBeyond the suite, an out-of-tree harness probed 7,321,866
GetPreviousOccurrencecalls (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/Ammanis excluded from that harness becauseGetNextOccurrence— the oracle — is itself wrong there on a Linux tzdb (it returns the same instant repeatedly with an offset that contradictsTimeZoneInfo.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.0andnet6.0— identical results.net462requires Mono and was not run; the change uses no new APIs and no platform-specific primitives (only existingTimeZoneHelpermembers already used by the forward path).Related: #91 — independent, pre-existing
Asia/Ammantzdb failures inGetNextOccurrence, not affected by this change.