|
2 | 2 | from unittest.mock import patch |
3 | 3 |
|
4 | 4 | import pytest |
| 5 | +from dagster._core.test_utils import freeze_time |
5 | 6 | from dagster._utils.schedules import ( |
6 | 7 | _get_smallest_cron_interval_with_sampling, |
7 | 8 | get_smallest_cron_interval, |
@@ -693,20 +694,136 @@ def test_sampling_dst_transitions(): |
693 | 694 | """Test DST transition edge cases with sampling method. |
694 | 695 |
|
695 | 696 | 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. |
698 | 773 | """ |
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) |
702 | 821 |
|
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]) |
706 | 823 |
|
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") |
710 | 827 |
|
711 | 828 |
|
712 | 829 | def test_sampling_specific_day_patterns(): |
|
0 commit comments