fix: monthly tasks scheduled for day 29-31 skip every short month (#1668)
This commit is contained in:
@@ -159,7 +159,13 @@ def compute_next_run(schedule: str, scheduled_time: str,
|
|||||||
try:
|
try:
|
||||||
candidate = now.replace(day=day, hour=hour, minute=minute, second=0, microsecond=0)
|
candidate = now.replace(day=day, hour=hour, minute=minute, second=0, microsecond=0)
|
||||||
except ValueError:
|
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 candidate <= now:
|
||||||
if now.month == 12:
|
if now.month == 12:
|
||||||
next_month = now.replace(year=now.year + 1, month=1, day=1)
|
next_month = now.replace(year=now.year + 1, month=1, day=1)
|
||||||
|
|||||||
56
tests/test_compute_next_run_monthly_clamp.py
Normal file
56
tests/test_compute_next_run_monthly_clamp.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user