Skip to content

Commit 5a786ba

Browse files
committed
more tests for dst transition
1 parent 1b0ae4e commit 5a786ba

File tree

1 file changed

+128
-11
lines changed

1 file changed

+128
-11
lines changed

python_modules/dagster/dagster_tests/scheduler_tests/test_get_smallest_cron_interval.py

Lines changed: 128 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from unittest.mock import patch
33

44
import pytest
5+
from dagster._core.test_utils import freeze_time
56
from dagster._utils.schedules import (
67
_get_smallest_cron_interval_with_sampling,
78
get_smallest_cron_interval,
@@ -693,20 +694,136 @@ def test_sampling_dst_transitions():
693694
"""Test DST transition edge cases with sampling method.
694695
695696
The deterministic method cannot detect DST transitions, so this test validates
696-
that the sampling-based method correctly identifies 23-hour intervals during
697-
spring forward DST transitions.
697+
that the sampling-based method correctly handles both spring forward (23-hour intervals)
698+
and fall back (negative intervals that should be skipped) DST transitions.
699+
700+
We freeze time to ensure the sampling period will cross DST transitions.
701+
"""
702+
# Test spring forward (clocks jump ahead 1 hour, creating 23-hour intervals)
703+
# Freeze time to February 2024, so that sampling from a year ago (Feb 2023)
704+
# will cross the March 2024 spring forward DST transition
705+
freeze_datetime_spring = datetime.datetime(2024, 2, 15, 12, 0, 0)
706+
with freeze_time(freeze_datetime_spring):
707+
# Daily at 2am in America/New_York - should catch the 23-hour interval during spring forward
708+
# The spring forward happens on March 10, 2024 at 2:00 AM -> 3:00 AM
709+
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "America/New_York")
710+
assert interval == datetime.timedelta(hours=23)
711+
712+
# Hourly schedule should not be affected by DST for minimum interval
713+
interval = _get_smallest_cron_interval_with_sampling("0 * * * *", "America/New_York")
714+
assert interval == datetime.timedelta(hours=1)
715+
716+
# Test fall back (clocks go back 1 hour, creating negative intervals that should be skipped)
717+
# Freeze time to September 2024, so that sampling from a year ago (Sep 2023)
718+
# will cross the November 2024 fall back DST transition
719+
freeze_datetime_fall = datetime.datetime(2024, 9, 15, 12, 0, 0)
720+
with freeze_time(freeze_datetime_fall):
721+
# Daily at 2am in America/New_York during fall back
722+
# The fall back happens on November 3, 2024 at 2:00 AM -> 1:00 AM
723+
# Should still return 23 hours as minimum (not negative intervals)
724+
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "America/New_York")
725+
assert interval == datetime.timedelta(hours=23)
726+
727+
# Test Europe/Berlin DST transitions
728+
with freeze_time(freeze_datetime_spring):
729+
# Different timezone with DST (Europe/Berlin)
730+
# Spring forward happens on March 31, 2024 at 2:00 AM -> 3:00 AM
731+
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "Europe/Berlin")
732+
assert interval == datetime.timedelta(hours=23)
733+
734+
with freeze_time(freeze_datetime_fall):
735+
# Fall back happens on October 27, 2024 at 3:00 AM -> 2:00 AM
736+
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "Europe/Berlin")
737+
assert interval == datetime.timedelta(hours=23)
738+
739+
740+
def test_sampling_negative_interval_handling():
741+
"""Test that negative intervals during DST fall-back are properly skipped.
742+
743+
During a fall-back DST transition, the cron iterator may produce timestamps
744+
that appear to go backward in wall-clock time. This test ensures that these
745+
negative intervals are skipped and don't affect the minimum interval calculation.
746+
"""
747+
# Use a time just before the fall DST transition to maximize chances of hitting
748+
# the negative interval code path. We use hourly schedule which emits both
749+
# PRE_TRANSITION and POST_TRANSITION times during the ambiguous hour.
750+
freeze_datetime = datetime.datetime(2024, 11, 2, 12, 0, 0)
751+
with freeze_time(freeze_datetime):
752+
# Hourly schedule in America/New_York during fall back (Nov 3, 2024 at 2:00 AM -> 1:00 AM)
753+
# The hourly schedule will emit times during the ambiguous hour twice (fold=0 and fold=1)
754+
# which can create situations where wall-clock times appear to go backward
755+
interval = _get_smallest_cron_interval_with_sampling("0 * * * *", "America/New_York")
756+
# Should still return 1 hour as minimum, not negative intervals
757+
assert interval == datetime.timedelta(hours=1)
758+
759+
# Test with a different pattern that might expose negative intervals
760+
freeze_datetime = datetime.datetime(2024, 11, 2, 12, 0, 0)
761+
with freeze_time(freeze_datetime):
762+
# Every 30 minutes during the fall back transition
763+
interval = _get_smallest_cron_interval_with_sampling("0,30 * * * *", "America/New_York")
764+
# Should return 30 minutes, not negative intervals
765+
assert interval == datetime.timedelta(minutes=30)
766+
767+
768+
def test_sampling_zero_interval_with_mock():
769+
"""Test the error handling for genuine zero intervals (should not happen in practice).
770+
771+
This test uses mocking to force a scenario where a zero interval occurs without
772+
being a DST transition, which should raise an exception.
698773
"""
699-
# Daily at 2am in America/New_York - should catch the 23-hour interval during spring forward
700-
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "America/New_York")
701-
assert interval == datetime.timedelta(hours=23)
774+
from unittest.mock import patch
775+
776+
with patch("dagster._utils.schedules.schedule_execution_time_iterator") as mock_iter:
777+
# Create a mock iterator that returns two ticks with the same timestamp
778+
# but WITHOUT different fold values (not a DST transition)
779+
base_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
780+
same_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
781+
782+
mock_iter.return_value = iter([base_time, same_time])
783+
784+
# This should raise an exception for genuine zero interval
785+
with pytest.raises(Exception, match="Encountered a genuine zero interval"):
786+
_get_smallest_cron_interval_with_sampling("0 * * * *", "UTC")
787+
788+
789+
def test_sampling_stop_iteration_with_mock():
790+
"""Test the StopIteration exception handling (should not happen with cron iterators).
791+
792+
This test uses mocking to force a StopIteration exception from the iterator,
793+
which should be caught and handled gracefully.
794+
"""
795+
from unittest.mock import patch
796+
797+
with patch("dagster._utils.schedules.schedule_execution_time_iterator") as mock_iter:
798+
# Create a mock iterator that immediately raises StopIteration
799+
base_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
800+
mock_iter.return_value = iter([base_time]) # Only one tick, then stops
801+
802+
# This should handle the StopIteration and raise ValueError
803+
with pytest.raises(ValueError, match="Could not determine minimum interval"):
804+
_get_smallest_cron_interval_with_sampling("0 * * * *", "UTC")
805+
806+
807+
def test_sampling_no_valid_interval_with_mock():
808+
"""Test the ValueError when no valid interval can be determined.
809+
810+
This test uses mocking to create a scenario where min_interval remains None,
811+
which should raise a ValueError.
812+
"""
813+
from unittest.mock import patch
814+
815+
with patch("dagster._utils.schedules.schedule_execution_time_iterator") as mock_iter:
816+
# Create a mock iterator that returns only negative or zero intervals
817+
base_time = datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)
818+
# All subsequent times go backward
819+
time1 = datetime.datetime(2024, 6, 1, 11, 0, 0, tzinfo=datetime.timezone.utc)
820+
time2 = datetime.datetime(2024, 6, 1, 10, 0, 0, tzinfo=datetime.timezone.utc)
702821

703-
# Hourly schedule should not be affected by DST for minimum interval
704-
interval = _get_smallest_cron_interval_with_sampling("0 * * * *", "America/New_York")
705-
assert interval == datetime.timedelta(hours=1)
822+
mock_iter.return_value = iter([base_time, time1, time2])
706823

707-
# Different timezone with DST (Europe/Berlin)
708-
interval = _get_smallest_cron_interval_with_sampling("0 2 * * *", "Europe/Berlin")
709-
assert interval == datetime.timedelta(hours=23)
824+
# This should raise ValueError because no valid positive interval was found
825+
with pytest.raises(ValueError, match="Could not determine minimum interval"):
826+
_get_smallest_cron_interval_with_sampling("0 * * * *", "UTC")
710827

711828

712829
def test_sampling_specific_day_patterns():

0 commit comments

Comments
 (0)