mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
Merge 0bd3431b88 into a44e44b980
This commit is contained in:
commit
fe565f6e2b
2 changed files with 49 additions and 4 deletions
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue