From b396252af691a1c1d00438037a3c4644294ec6fc Mon Sep 17 00:00:00 2001 From: Afonso Coutinho Date: Wed, 3 Jun 2026 06:23:01 +0100 Subject: [PATCH] fix: monthly tasks scheduled for day 29-31 skip every short month (#1668) --- src/task_scheduler.py | 8 ++- tests/test_compute_next_run_monthly_clamp.py | 56 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 tests/test_compute_next_run_monthly_clamp.py diff --git a/src/task_scheduler.py b/src/task_scheduler.py index 67bb02a..1f8e0e6 100644 --- a/src/task_scheduler.py +++ b/src/task_scheduler.py @@ -159,7 +159,13 @@ def compute_next_run(schedule: str, scheduled_time: str, try: candidate = now.replace(day=day, hour=hour, minute=minute, second=0, microsecond=0) except ValueError: - candidate = now + # Short month: clamp to its last day (mirrors the next-month + # clamp below) instead of silently skipping the whole month. + if now.month == 12: + last = now.replace(year=now.year + 1, month=1, day=1) - timedelta(days=1) + else: + last = now.replace(month=now.month + 1, day=1) - timedelta(days=1) + candidate = last.replace(hour=hour, minute=minute, second=0, microsecond=0) if candidate <= now: if now.month == 12: next_month = now.replace(year=now.year + 1, month=1, day=1) diff --git a/tests/test_compute_next_run_monthly_clamp.py b/tests/test_compute_next_run_monthly_clamp.py new file mode 100644 index 0000000..3f1ed0d --- /dev/null +++ b/tests/test_compute_next_run_monthly_clamp.py @@ -0,0 +1,56 @@ +"""compute_next_run monthly must clamp to short months, not skip them. + +Old behavior: now.replace(day=31) raises ValueError in February, the +except set candidate = now, candidate <= now then jumped straight to the +NEXT month (which does clamp). A task scheduled for day 31 therefore never +fired in February, April, June, September or November. +""" + +from datetime import datetime + +import pytest + +from src.task_scheduler import compute_next_run + + +@pytest.mark.parametrize( + "day,after,expected", + [ + (31, datetime(2026, 2, 15, 12, 0), datetime(2026, 2, 28, 9, 0)), + (30, datetime(2026, 2, 1, 12, 0), datetime(2026, 2, 28, 9, 0)), + (29, datetime(2026, 2, 1, 12, 0), datetime(2026, 2, 28, 9, 0)), + (29, datetime(2028, 2, 1, 12, 0), datetime(2028, 2, 29, 9, 0)), + (31, datetime(2026, 4, 1, 12, 0), datetime(2026, 4, 30, 9, 0)), + ], +) +def test_monthly_clamps_to_last_day_of_current_short_month(day, after, expected): + out = compute_next_run("monthly", "09:00", scheduled_day=day, after=after) + assert out == expected + + +def test_monthly_clamped_slot_already_passed_rolls_to_next_month(): + out = compute_next_run( + "monthly", "09:00", scheduled_day=31, after=datetime(2026, 2, 28, 10, 0) + ) + assert out == datetime(2026, 3, 31, 9, 0) + + +def test_monthly_regular_day_still_fires_this_month(): + out = compute_next_run( + "monthly", "09:00", scheduled_day=15, after=datetime(2026, 6, 10, 12, 0) + ) + assert out == datetime(2026, 6, 15, 9, 0) + + +def test_monthly_regular_day_passed_rolls_to_next_month(): + out = compute_next_run( + "monthly", "09:00", scheduled_day=15, after=datetime(2026, 6, 20, 12, 0) + ) + assert out == datetime(2026, 7, 15, 9, 0) + + +def test_monthly_december_year_rollover(): + out = compute_next_run( + "monthly", "09:00", scheduled_day=31, after=datetime(2026, 12, 31, 10, 0) + ) + assert out == datetime(2027, 1, 31, 9, 0)