This commit is contained in:
Anisayak Mitra 2026-10-30 08:19:27 -04:00 • committed by GitHub
commit fe565f6e2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 49 additions and 4 deletions

View file

@ -33,7 +33,7 @@ import hashlib
import os
import socket
from collections.abc import Callable, Mapping, Sequence
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import Final, Protocol
@ -108,6 +108,20 @@ class JobSubmission(Protocol):
def scheduled_run_times(self) -> Sequence[datetime]: ...
def _shift_datetime(dt: datetime, delta: timedelta) -> datetime:
"""
Shift a datetime by delta preserving absolute time (timeline) arithmetic.
Standard `datetime - delta` on timezone-aware datetimes resets `fold = 0` during
ambiguous hours (such as DST fall-back). Converting to UTC before arithmetic and
then converting back to the source tzinfo ensures the shift operates on absolute
time and preserves fold and correct fire ordering.
"""
if dt.tzinfo is None:
return dt + delta
return (dt.astimezone(timezone.utc) + delta).astimezone(dt.tzinfo)
class _OffsetTrigger:
"""
Delegates to ``base`` on a clock rolled back by ``offset``, then rolls the answer
@ -125,9 +139,11 @@ class _OffsetTrigger:
self.offset = offset
def get_next_fire_time(self, previous_fire_time: datetime | None, now: datetime) -> datetime | None:
shifted_previous: Final = None if previous_fire_time is None else previous_fire_time - self.offset
next_fire_time: Final = self.base.get_next_fire_time(shifted_previous, now - self.offset)
return None if next_fire_time is None else next_fire_time + self.offset
shifted_previous: Final = (
None if previous_fire_time is None else _shift_datetime(previous_fire_time, -self.offset)
)
next_fire_time: Final = self.base.get_next_fire_time(shifted_previous, _shift_datetime(now, -self.offset))
return None if next_fire_time is None else _shift_datetime(next_fire_time, self.offset)
def __str__(self) -> str:
return f"{self.base}[+{int(self.offset.total_seconds())}s]"

View file

@ -3,6 +3,7 @@ import logging
import os
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from zoneinfo import ZoneInfo
import pytest
from apscheduler.executors.asyncio import AsyncIOExecutor
@ -14,6 +15,8 @@ from apscheduler.triggers.interval import IntervalTrigger
from litellm.constants import PTU_ROLLUP_JOB_ID, PTU_ROLLUP_LOCK_TTL_SECONDS
from litellm.proxy._types import ScheduledJobStaggerSettings
from litellm.proxy.common_utils.scheduled_job_stagger import (
_OffsetTrigger,
_shift_datetime,
apply_scheduled_job_stagger,
attach_job_timing_logger,
offset_seconds,
@ -309,3 +312,29 @@ def test_job_timing_is_logged_with_scheduled_and_actual_start(caplog):
assert f"scheduled_run_time={scheduled.isoformat()}" in message
assert "actual_start_time=" in message
assert "delay=2." in message
def test_offset_trigger_handles_dst_fall_back_without_looping():
"""Regression test for #48286: during DST fall-back (fold=1), shifted time must not land in fold=0 and loop."""
tz = ZoneInfo("Europe/Paris")
base = IntervalTrigger(seconds=60, timezone=tz)
trigger = _OffsetTrigger(base, timedelta(seconds=30))
# Test normal CEST, DST fall-back hour (fold=1), and normal CET
for label, now in [
("01:30 CEST", datetime(2026, 10, 25, 1, 30, tzinfo=tz)),
("02:30 CET (repeated hour)", datetime(2026, 10, 25, 2, 30, fold=1, tzinfo=tz)),
("03:30 CET", datetime(2026, 10, 25, 3, 30, tzinfo=tz)),
]:
nrt = datetime.fromtimestamp(now.timestamp() - 90, tz)
count = 0
while nrt and nrt.astimezone(timezone.utc) <= now.astimezone(timezone.utc) and count < 100:
count += 1
nrt = trigger.get_next_fire_time(nrt, now)
assert count == 2, f"Failed for {label}: count={count}"
def test_shift_datetime_handles_naive_and_timezone_aware():
naive = datetime(2026, 1, 1, 12, 0, 0)
assert _shift_datetime(naive, timedelta(seconds=10)) == datetime(2026, 1, 1, 12, 0, 10)
assert _shift_datetime(naive, -timedelta(seconds=10)) == datetime(2026, 1, 1, 11, 59, 50)