fix(roi): normalize scheduler timestamps to UTC

This commit is contained in:
moe-berri 2026-09-30 10:45:43 -07:00
parent 3cf6cd7a5c
commit b3aacaf49d
4 changed files with 42 additions and 5 deletions

View file

@ -549,7 +549,11 @@ def _next_update(settings: ROISettings, status: ROISyncStatus, report: ROIReport
):
return None
anchor: Final = status.finished_at or status.started_at or report["synced_at"]
return datetime.fromisoformat(anchor.replace("Z", "+00:00")) + timedelta(minutes=settings.update_interval_minutes)
parsed: Final = datetime.fromisoformat(anchor.replace("Z", "+00:00"))
utc_anchor: Final = (
parsed.replace(tzinfo=timezone.utc) if parsed.tzinfo is None else parsed.astimezone(timezone.utc)
)
return utc_anchor + timedelta(minutes=settings.update_interval_minutes)
async def run_scheduled_sync() -> None:

View file

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Final, Protocol, cast # noqa: TID251 - PrismaWrapper dynamically delegates database methods
@ -117,7 +117,7 @@ class SyncStore:
{
"running": False,
"phase": "error",
"finished_at": rows[0].last_run_at.isoformat(),
"finished_at": rows[0].last_run_at.replace(tzinfo=timezone.utc).isoformat(),
"stage": "Sync interrupted",
"error": "The worker stopped responding. Run analysis again to resume saved estimates.",
}

View file

@ -72,7 +72,18 @@ async def test_roi_cache_survives_scope_changes_and_uses_writer(monkeypatch: pyt
) == ("roi_calculator_pull_new", "roi_calculator_pull_outside-window")
assert not await store.acquire("scheduled", running, 1440)
assert await store.acquire("manual", running)
assert await store.finish("manual", complete, empty)
write_rows(
"UPDATE \"LiteLLM_Config\" SET last_run_at = NOW() - INTERVAL '2 minutes' WHERE param_name = %s",
("roi_calculator_sync",),
database_url=writer_url,
)
expired: Final = await store.status()
assert expired is not None and expired.phase == "error" and expired.finished_at is not None
assert datetime.fromisoformat(expired.finished_at).tzinfo == timezone.utc
assert not await store.heartbeat("manual", running)
assert await store.acquire("replacement", running)
assert not await store.finish("manual", complete, empty)
assert await store.finish("replacement", complete, empty)
assert (
len(
read_rows(

View file

@ -1,5 +1,6 @@
import json
from collections.abc import Mapping
from datetime import datetime, timezone
from types import MappingProxyType
from typing import Final, cast
@ -12,11 +13,13 @@ from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.roi_calculator_endpoints import (
_estimator_models_from_deployments,
_next_update,
get_roi_config_repository,
router,
)
from litellm.proxy.roi_calculator.estimator import estimator_options
from litellm.types.roi_calculator import ROISettings
from litellm.proxy.roi_calculator.sample import sample_report
from litellm.types.roi_calculator import ROISettings, ROISyncStatus
_JSON_HEADERS: Final = MappingProxyType({"content-type": "application/json"})
@ -187,3 +190,22 @@ def test_sample_preview_does_not_change_live_settings_or_report() -> None:
def test_schedule_rejects_intervals_under_five_minutes(interval: float) -> None:
client: Final = _client(LitellmUserRoles.PROXY_ADMIN, _ConfigRepository())
assert client.put("/roi-calculator/settings", json={"update_interval_minutes": interval}).status_code == 422
@pytest.mark.parametrize("anchor", ("2026-09-30T12:00:00", "2026-09-30T12:00:00Z", "2026-09-30T14:00:00+02:00"))
def test_schedule_normalizes_legacy_and_offset_timestamps(anchor: str) -> None:
settings: Final = ROISettings(repos=("example/repo",), estimator_model="estimator", update_interval_minutes=60)
status: Final = ROISyncStatus(
running=False,
phase="error",
stage="Interrupted",
done=0,
total=0,
estimated=0,
reused=0,
needs_attention=0,
error=None,
finished_at=anchor,
)
report: Final = sample_report(datetime(2026, 9, 30, tzinfo=timezone.utc))
assert _next_update(settings, status, report) == datetime(2026, 9, 30, 13, tzinfo=timezone.utc)