From 8e596bcac67676da852fcab135d0214670b1a527 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 10:45:23 +0100 Subject: [PATCH 1/2] fix(crons): Compute next_checkin_latest in UTC during DST fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the check-in margin to the next expected check-in in the monitor's timezone uses wall-clock arithmetic, which resets the datetime's fold. During the repeated hour after DST ends, next_checkin_latest then lands an hour early, so check_missed marks every slot missed although the check-in arrives on time. 🤖 Generated with Claude Code --- src/sentry/monitors/models.py | 5 +++-- tests/sentry/monitors/test_models.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/sentry/monitors/models.py b/src/sentry/monitors/models.py index ab74d6378294..c6cadd13fe39 100644 --- a/src/sentry/monitors/models.py +++ b/src/sentry/monitors/models.py @@ -4,7 +4,7 @@ import uuid import zoneinfo from collections.abc import Sequence -from datetime import datetime +from datetime import UTC, datetime from functools import cache from typing import TYPE_CHECKING, ClassVar, Self, override from uuid import uuid4 @@ -353,7 +353,8 @@ def get_next_expected_checkin_latest(self, last_checkin: datetime) -> datetime: """ from sentry.monitors.utils import get_checkin_margin - next_checkin = self.get_next_expected_checkin(last_checkin) + # Add the margin in UTC: local wall-clock arithmetic drops the DST fold + next_checkin = self.get_next_expected_checkin(last_checkin).astimezone(UTC) return next_checkin + get_checkin_margin(self.config.get("checkin_margin")) def update_config(self, config_payload, validated_config): diff --git a/tests/sentry/monitors/test_models.py b/tests/sentry/monitors/test_models.py index 6d2c52c81bfd..62986f7e8210 100644 --- a/tests/sentry/monitors/test_models.py +++ b/tests/sentry/monitors/test_models.py @@ -71,6 +71,27 @@ def test_next_run_latest_crontab_with_margin(self) -> None: 2019, 1, 1, 1, 16, tzinfo=timezone.utc ) + def test_next_run_latest_crontab_during_dst_fold(self) -> None: + # Europe/Berlin repeats 02:00-02:59 local time on 2024-10-27 from 01:00 UTC + ts = datetime(2024, 10, 27, 1, 0, 3, tzinfo=timezone.utc) + monitor = Monitor( + config={ + "schedule_type": ScheduleType.CRONTAB, + "schedule": "* * * * *", + "timezone": "Europe/Berlin", + "checkin_margin": 5, + "max_runtime": None, + } + ) + monitor_environment = MonitorEnvironment(monitor=monitor, last_checkin=ts) + + assert monitor_environment.monitor.get_next_expected_checkin(ts) == datetime( + 2024, 10, 27, 1, 1, tzinfo=timezone.utc + ) + assert monitor_environment.monitor.get_next_expected_checkin_latest(ts) == datetime( + 2024, 10, 27, 1, 6, tzinfo=timezone.utc + ) + def test_next_run_crontab_with_timezone(self) -> None: ts = datetime(2019, 1, 1, 1, 10, 20, tzinfo=timezone.utc) monitor = Monitor( From 8a19828400eed34b16d158ec745c18486f3dbd75 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 10:59:59 +0100 Subject: [PATCH 2/2] test(crons): Compare the fold=1 next check-in in UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python returns False for an inter-zone == when one operand is an ambiguous local time with fold=1 (PEP 495). 🤖 Generated with Claude Code --- tests/sentry/monitors/test_models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/sentry/monitors/test_models.py b/tests/sentry/monitors/test_models.py index 62986f7e8210..ef3026dc1524 100644 --- a/tests/sentry/monitors/test_models.py +++ b/tests/sentry/monitors/test_models.py @@ -85,9 +85,10 @@ def test_next_run_latest_crontab_during_dst_fold(self) -> None: ) monitor_environment = MonitorEnvironment(monitor=monitor, last_checkin=ts) - assert monitor_environment.monitor.get_next_expected_checkin(ts) == datetime( - 2024, 10, 27, 1, 1, tzinfo=timezone.utc - ) + # PEP 495: inter-zone == is always False for a time with fold=1 + assert monitor_environment.monitor.get_next_expected_checkin(ts).astimezone( + timezone.utc + ) == datetime(2024, 10, 27, 1, 1, tzinfo=timezone.utc) assert monitor_environment.monitor.get_next_expected_checkin_latest(ts) == datetime( 2024, 10, 27, 1, 6, tzinfo=timezone.utc )