diff --git a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py index 04f67d38a0b..cbc90c81559 100644 --- a/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py +++ b/litellm/proxy/db/db_transaction_queue/spend_log_cleanup.py @@ -108,16 +108,24 @@ def first_cleanup_run_kwargs(settings: Mapping[str, object], now: datetime) -> F """ Extra ``add_job`` kwargs for the interval cleanup job. Empty unless ``maximum_spend_logs_cleanup_run_on_startup`` is true, so the first run otherwise stays one interval out. + The startup delay is capped at the configured interval so opting in never makes the first run later. """ if ( settings.get("maximum_spend_logs_cleanup_cron") or settings.get("maximum_spend_logs_cleanup_run_on_startup") is not True ): return {} - delay_seconds: Final = SPEND_LOG_CLEANUP_STARTUP_DELAY_SECONDS + random.randint( + retention_interval: Final = settings.get("maximum_spend_logs_retention_interval", "1d") + if not isinstance(retention_interval, str): + return {} + try: + interval_seconds: Final = duration_in_seconds(retention_interval) + except (ValueError, OverflowError): + return {} + startup_delay_seconds: Final = SPEND_LOG_CLEANUP_STARTUP_DELAY_SECONDS + random.randint( 0, SPEND_LOG_CLEANUP_STARTUP_JITTER_SECONDS ) - return {"next_run_time": now + timedelta(seconds=delay_seconds)} + return {"next_run_time": now + timedelta(seconds=min(startup_delay_seconds, interval_seconds))} class SpendLogCleanup: diff --git a/tests/unit/proxy/proxy_server/test_proxy_config.py b/tests/unit/proxy/proxy_server/test_proxy_config.py index 95a1879bffa..10db1f121ff 100644 --- a/tests/unit/proxy/proxy_server/test_proxy_config.py +++ b/tests/unit/proxy/proxy_server/test_proxy_config.py @@ -4259,6 +4259,28 @@ async def test_ProxyConfig__reschedule_spend_log_cleanup_job_first_run_only_move real_scheduler.shutdown(wait=False) +@pytest.mark.asyncio +async def test_ProxyConfig__reschedule_spend_log_cleanup_job_opt_in_never_delays_a_short_interval( + monkeypatch: pytest.MonkeyPatch, +): + real_scheduler: Final = _paused_scheduler(monkeypatch) + monkeypatch.setattr( + "litellm.proxy.proxy_server.general_settings", + { + "maximum_health_check_retention_period": "90d", + "maximum_spend_logs_retention_interval": "30s", + "maximum_spend_logs_cleanup_run_on_startup": True, + }, + ) + before: Final = datetime.now(timezone.utc) + try: + await ProxyConfig()._reschedule_spend_log_cleanup_job() + delay: Final = (real_scheduler.get_job("spend_log_cleanup_job").next_run_time - before).total_seconds() + assert delay <= 30 + 1, delay + finally: + real_scheduler.shutdown(wait=False) + + def _scheduler_whose_first_add_job_raises(monkeypatch): from apscheduler.schedulers.asyncio import AsyncIOScheduler diff --git a/tests/unit/proxy/test_spend_log_cleanup.py b/tests/unit/proxy/test_spend_log_cleanup.py index 87249a4af4a..34966733c31 100644 --- a/tests/unit/proxy/test_spend_log_cleanup.py +++ b/tests/unit/proxy/test_spend_log_cleanup.py @@ -61,6 +61,29 @@ def test_first_cleanup_run_kwargs_runs_soon_after_startup_when_opted_in(): assert len(delays) > 1 +@pytest.mark.parametrize("interval, interval_seconds", [("30s", 30), ("2m", 120)]) +def test_first_cleanup_run_kwargs_never_runs_later_than_a_short_interval(interval: str, interval_seconds: int): + now: Final = datetime(2026, 1, 1, tzinfo=timezone.utc) + settings: Final = { + "maximum_spend_logs_cleanup_run_on_startup": True, + "maximum_spend_logs_retention_interval": interval, + } + delays: Final = { + (first_cleanup_run_kwargs(settings, now)["next_run_time"] - now).total_seconds() for _ in range(200) + } + assert max(delays) <= interval_seconds, delays + assert min(delays) >= min(SPEND_LOG_CLEANUP_STARTUP_DELAY_SECONDS, interval_seconds), delays + + +@pytest.mark.parametrize("interval", ["not-a-duration", 3600]) +def test_first_cleanup_run_kwargs_leaves_an_invalid_interval_to_the_caller(interval: object): + settings: Final = { + "maximum_spend_logs_cleanup_run_on_startup": True, + "maximum_spend_logs_retention_interval": interval, + } + assert dict(first_cleanup_run_kwargs(settings, datetime(2026, 1, 1, tzinfo=timezone.utc))) == {} + + def _far_deadline() -> float: """A run deadline far enough out that only the other bounds can stop a batch loop.""" return time.monotonic() + 3600