feat(proxy): size-triggered spend log cleanup, oldest rows first (#47450)

Adds general_settings.maximum_spend_logs_size,
maximum_spend_logs_cleanup_target_size and
maximum_spend_logs_size_check_interval, the spend_log_size_check job,
maximum_spend_logs_cleanup_cron precedence, oldest-first bounded deletes
down to the target, storage fields in the run summary and the
litellm_spend_log_storage_bytes gauge, with a Postgres integration test
covering the post-VACUUM no-refire case

Resolves LIT-9831
Fixes #44589

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Kerry Lu 2026-10-20 10:49:02 -07:00 • committed by GitHub
parent 4b1928387f
commit b245cc0d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 962 additions and 75 deletions

View file

@ -1855,6 +1855,9 @@ SPEND_LOG_CLEANUP_BATCH_FAILURE_BACKOFF_SECONDS: Final = get_env_float(
SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS: Final = get_env_float("SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS", 300.0)
SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS: Final = get_env_float("SPEND_LOG_CLEANUP_BATCH_TIMEOUT_SECONDS", 30.0)
SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP: Final = get_env_int("SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP", 100000)
SPEND_LOG_SIZE_CHECK_JOB_NAME: Final = "spend_log_size_check"
SPEND_LOG_SIZE_CHECK_INTERVAL: Final = os.getenv("SPEND_LOG_SIZE_CHECK_INTERVAL", "1d")
SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES: Final = get_env_int("SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES", 10)
SCHEDULED_JOB_SHUTDOWN_FINISH_TIMEOUT_SECONDS: Final = get_env_float(
"SCHEDULED_JOB_SHUTDOWN_FINISH_TIMEOUT_SECONDS", 5.0
)

View file

@ -2884,6 +2884,30 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"deleted. Only historical tag usage analytics are affected; tag budgets read the lifetime counter."
),
)
maximum_spend_logs_size: str | None = Field(
None,
description=(
"Size rule for spend logs (e.g. '180GB'): when LiteLLM_SpendLogs plus LiteLLM_SpendLogToolIndex "
"(heap, indexes and TOAST, measured with pg_total_relation_size) hold more live data than this, the "
"cleanup job deletes the oldest spend logs first until they are under "
"maximum_spend_logs_cleanup_target_size. Independent of maximum_spend_logs_retention_period: a row is "
"deleted when either rule says so. Unset means no size rule."
),
)
maximum_spend_logs_cleanup_target_size: str | None = Field(
None,
description=(
"Level a size-triggered cleanup run deletes down to (e.g. '20GB'). Must be below maximum_spend_logs_size. "
"Unset means the run stops as soon as the tables are back under maximum_spend_logs_size."
),
)
maximum_spend_logs_size_check_interval: str | None = Field(
None,
description=(
"How often the size rule is checked (e.g. '10m'). Defaults to '1d'. Ignored when "
"maximum_spend_logs_cleanup_cron is set, which then drives both the age and the size cleanup."
),
)
use_spend_logs_partitioning: bool | None = Field(
None,
description="If True and LiteLLM_SpendLogs has been converted to a range-partitioned table (db_scripts/partition_spend_logs.sql), retention cleanup drops expired partitions instead of deleting rows, and pre-creates upcoming partitions. Default is False.",

View file

@ -1,6 +1,8 @@
import asyncio
import math
import re
import time
from collections.abc import Callable
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@ -19,6 +21,7 @@ from litellm.constants import (
SPEND_LOG_CLEANUP_REMAINING_COUNT_CAP,
SPEND_LOG_CLEANUP_RUN_BUDGET_SECONDS,
SPEND_LOG_RUN_LOOPS,
SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES,
)
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
from litellm.proxy.db.db_span import db_span
@ -35,7 +38,7 @@ from litellm.proxy.db.db_transaction_queue.spend_logs_partition_manager import (
from litellm.proxy.utils import PrismaClient
from litellm.types.llms.base import LiteLLMBaseModel
StopReason: TypeAlias = Literal["exhausted", "budget_exhausted", "batch_cap_reached", "aborted"]
StopReason: TypeAlias = Literal["exhausted", "target_reached", "budget_exhausted", "batch_cap_reached", "aborted"]
_SIZE_RE: Final = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([KMGT]?)I?B?\s*$", re.IGNORECASE)
_SIZE_UNITS: Final = {"": 1, "K": 1024, "M": 1024**2, "G": 1024**3, "T": 1024**4}
@ -61,6 +64,14 @@ def size_in_bytes(value: str | int) -> int:
return size
@dataclass(frozen=True, slots=True)
class SizeRule:
"""The size cleanup rule as configured: start cleaning above `limit_bytes`, stop under `target_bytes`."""
limit_bytes: int
target_bytes: int
@dataclass(frozen=True, slots=True)
class StorageProbe:
"""
@ -218,6 +229,42 @@ class TableCleanupResult:
stop_reason: StopReason
@dataclass(frozen=True, slots=True)
class _SizeStepState:
"""Progress of the size pass across its re-check steps."""
logs_deleted: int
index_deleted: int
batches_left: int
stop_reason: StopReason | None
def stopped(self, reason: StopReason) -> "_SizeStepState":
return _SizeStepState(
logs_deleted=self.logs_deleted,
index_deleted=self.index_deleted,
batches_left=self.batches_left,
stop_reason=reason,
)
@dataclass(frozen=True, slots=True)
class SizePassReport:
"""What one size pass measured and achieved, for the run summary and the storage gauges."""
rule: SizeRule
before: SpendLogStorage
after: SpendLogStorage
results: tuple[TableCleanupResult, ...]
@property
def target_reached(self) -> bool:
return self.after.estimated_live_bytes <= self.rule.target_bytes
@property
def above_target_bytes(self) -> int:
return max(0, self.after.estimated_live_bytes - self.rule.target_bytes)
class _RunProgress:
"""How far one cleanup run has got, reported if that run is cancelled"""
@ -277,6 +324,13 @@ _SAVE_SIZE_STATE_SQL: Final = """
"""
class _OffsetRow(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
startTime: datetime
_OFFSET_ROWS: Final = TypeAdapter(list[_OffsetRow])
_SPEND_LOG_TABLES: Final = ("LiteLLM_SpendLogs", "LiteLLM_SpendLogToolIndex")
# Partition children are counted with their parent: a partitioned parent has no storage of its own.
@ -456,6 +510,53 @@ class SpendLogCleanup:
self.retention_seconds = self._retention_seconds_for("maximum_spend_logs_retention_period")
return self.retention_seconds is not None
def _setting(self, setting_name: str) -> object:
return self.general_settings.get(setting_name) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # untyped settings dict
def _size_bytes_setting(self, setting_name: str) -> int | None:
"""Parse one size setting into bytes, or None when unset or unusable."""
raw: Final = self._setting(setting_name)
if raw is None:
return None
if not isinstance(raw, (str, int)):
verbose_proxy_logger.warning("Invalid %s value: %r, ignoring it", setting_name, raw)
return None
try:
return size_in_bytes(raw)
except ValueError as e:
verbose_proxy_logger.warning("Invalid %s value: %r (%s), ignoring it", setting_name, raw, e)
return None
def _size_rule(self) -> SizeRule | None:
"""
The size rule from settings, or None when maximum_spend_logs_size is unset.
A target at or above the limit cannot be what the operator meant, so it is reported and the run
cleans only until it is back under the limit, which is also what an unset target means.
"""
limit_bytes: Final = self._size_bytes_setting("maximum_spend_logs_size")
if limit_bytes is None:
return None
target_bytes: Final = self._size_bytes_setting("maximum_spend_logs_cleanup_target_size")
if target_bytes is None:
return SizeRule(limit_bytes=limit_bytes, target_bytes=limit_bytes)
if target_bytes >= limit_bytes:
verbose_proxy_logger.error(
"maximum_spend_logs_cleanup_target_size (%s bytes) must be below maximum_spend_logs_size (%s bytes); "
"cleaning down to maximum_spend_logs_size instead",
target_bytes,
limit_bytes,
)
return SizeRule(limit_bytes=limit_bytes, target_bytes=limit_bytes)
return SizeRule(limit_bytes=limit_bytes, target_bytes=target_bytes)
def _size_pass_runs_with_age_pass(self) -> bool:
"""
With maximum_spend_logs_cleanup_cron set there is one schedule for both rules, so the age run carries
the size pass; otherwise the size pass has its own job on maximum_spend_logs_size_check_interval.
"""
return bool(self._setting("maximum_spend_logs_cleanup_cron"))
async def _probe_storage(self, prisma_client: PrismaClient, deadline: float) -> StorageProbe | None:
"""Read what the spend-log tables weigh from the catalog, or None when the probe fails or has no budget."""
if time.monotonic() >= deadline:
@ -508,6 +609,32 @@ class SpendLogCleanup:
except Exception as e: # noqa: BLE001 - a lost save costs one re-seed from the catalog, it must not fail the run
verbose_proxy_logger.warning("Could not save the spend-log size cleanup state: %s: %s", type(e).__name__, e)
async def _size_step_cutoff(self, prisma_client: PrismaClient, deadline: float, rows: int) -> datetime | None:
"""
The startTime that puts the `rows` oldest rows behind it.
Expressing a size step as a cutoff lets the size pass reuse the retention DELETE unchanged, including
the tool-index rows that expire with their spend logs. When fewer rows than that remain, the step is
everything up to now.
"""
if time.monotonic() >= deadline:
return None
timeout_ms: Final = self._timeout_ms(deadline)
async with db_span("size_step_cutoff", "LiteLLM_SpendLogs"), bounded_tx(prisma_client, timeout_ms) as tx:
await tx.execute_raw(trusted_sql(f"SET LOCAL statement_timeout = {timeout_ms}"))
found: Final = _OFFSET_ROWS.validate_python(
await tx.query_raw(
trusted_sql(
'SELECT "startTime" FROM "LiteLLM_SpendLogs" ORDER BY "startTime" ASC OFFSET $1 LIMIT 1'
),
rows,
)
)
if not found:
return datetime.now(timezone.utc)
start_time: Final = found[0].startTime
return start_time if start_time.tzinfo is not None else start_time.replace(tzinfo=timezone.utc)
def _timeout_ms(self, deadline: float) -> int:
"""
The per-statement bound in milliseconds: the batch timeout, or whatever
@ -621,14 +748,17 @@ class SpendLogCleanup:
key_columns: tuple[str, ...],
time_column: str,
deadline: float,
batch_cap: int | None = None,
) -> TableCleanupResult:
"""
Delete a table's rows older than the cutoff in batches.
Stops at whichever bound is reached first: the backlog running out, the
shared wall-clock deadline, the per-table batch cap, or too many
consecutive batch failures.
shared wall-clock deadline, the per-table batch cap (`batch_cap` when a
caller spreads one table's cap over several calls, else the configured
one), or too many consecutive batch failures.
"""
max_batches: Final = self.max_batches if batch_cap is None else batch_cap
key_list: Final = ", ".join(f'"{col}"' for col in key_columns)
delete_sql: Final = f"""
DELETE FROM "{table_name}"
@ -651,7 +781,7 @@ class SpendLogCleanup:
return await self._finish_table(
prisma_client, cutoff_date, table_name, time_column, total_deleted, "budget_exhausted", deadline
)
if run_count >= self.max_batches:
if run_count >= max_batches:
verbose_proxy_logger.info(
"Max batches reached for %s cleanup, remaining rows will be deleted in next run", table_name
)
@ -891,6 +1021,163 @@ class SpendLogCleanup:
verbose_proxy_logger.info("Deleted %s expired tool index rows", index_result.rows_deleted)
return (logs_result, index_result)
async def _clean_spend_logs_by_size(
self, prisma_client: PrismaClient, rule: SizeRule, deadline: float
) -> SizePassReport | None:
"""
Bring the spend-log tables under the size target, oldest rows first.
Live bytes are the files on disk less the reclaimable count carried in LiteLLM_Config (see
`SizeCleanupState`), rolled forward from the catalog's cumulative insert and delete counters. Rows go in steps: the rows still needed to reach the target follow
from bytes per row, and each step turns that many of the oldest rows (at most
SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES batches) into a cutoff and runs the same bounded DELETE the age
rule uses, for the spend logs and the tool index rows derived from them. Progress is counted from the
deletes themselves, not re-read from pg_stat_user_tables, whose counters trail a DELETE by up to a
second. The run's batch cap and budget apply to the whole pass, so a pass that stops short reports how
far above the target it still is and the next check continues from there.
"""
probe: Final = await self._probe_storage(prisma_client, deadline)
if probe is None:
return None
if probe.stats_are_stale:
verbose_proxy_logger.warning(
"Spend-log row statistics are stale (allocated=%s bytes, live_rows=0, dead_rows=0); skipping the "
'size pass until pg_stat_user_tables is repopulated, ANALYZE "LiteLLM_SpendLogs" refreshes it',
probe.allocated_bytes,
)
return None
state: Final = advance_size_state(await self._load_size_state(prisma_client, deadline), probe)
before: Final = SpendLogStorage(
allocated_bytes=probe.allocated_bytes,
reclaimable_bytes=state.reclaimable_bytes,
bytes_per_row=state.bytes_per_row,
live_rows=probe.live_rows,
)
SpendLogCleanupMetrics.set_storage_bytes("allocated", before.allocated_bytes)
SpendLogCleanupMetrics.set_storage_bytes("estimated_live", before.estimated_live_bytes)
if before.estimated_live_bytes <= rule.limit_bytes:
verbose_proxy_logger.info(
"Spend-log storage is under maximum_spend_logs_size (estimated_live=%s allocated=%s reclaimable=%s "
"limit=%s bytes)",
before.estimated_live_bytes,
before.allocated_bytes,
before.reclaimable_bytes,
rule.limit_bytes,
)
SpendLogCleanupMetrics.set_storage_bytes("above_target", 0)
await self._save_size_state(prisma_client, deadline, state)
return SizePassReport(rule=rule, before=before, after=before, results=())
verbose_proxy_logger.warning(
"Spend-log storage is over maximum_spend_logs_size (estimated_live=%s allocated=%s reclaimable=%s "
"limit=%s bytes); deleting the oldest spend logs down to %s bytes",
before.estimated_live_bytes,
before.allocated_bytes,
before.reclaimable_bytes,
rule.limit_bytes,
rule.target_bytes,
)
rows_to_delete: Final = max(
0, math.ceil((before.estimated_live_bytes - rule.target_bytes) / before.bytes_per_row)
)
def rows_still_needed(rows_deleted: int) -> int:
return rows_to_delete - rows_deleted
results: Final = await self._delete_oldest_rows_for_size(prisma_client, deadline, rows_still_needed)
rows_deleted: Final = sum(result.rows_deleted for result in results)
remeasured: Final = await self._probe_storage(prisma_client, deadline)
after: Final = before.after_deleting(
rows_deleted, remeasured.allocated_bytes if remeasured is not None else before.allocated_bytes
)
await self._save_size_state(
prisma_client,
deadline,
after.to_state(inserted_total=probe.inserted_total, deleted_total=probe.deleted_total + rows_deleted),
)
report: Final = SizePassReport(rule=rule, before=before, after=after, results=results)
SpendLogCleanupMetrics.set_storage_bytes("allocated", after.allocated_bytes)
SpendLogCleanupMetrics.set_storage_bytes("estimated_live", after.estimated_live_bytes)
SpendLogCleanupMetrics.set_storage_bytes("above_target", report.above_target_bytes)
return report
async def _delete_oldest_rows_for_size(
self,
prisma_client: PrismaClient,
deadline: float,
rows_still_needed: Callable[[int], int],
) -> tuple[TableCleanupResult, ...]:
state = _SizeStepState( # rebind-ok: one value threaded through the bounded step loop
logs_deleted=0, index_deleted=0, batches_left=self.max_batches, stop_reason=None
)
for _ in range(self.max_batches // SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES + 2):
if state.stop_reason is not None:
break
state = await self._run_size_step(prisma_client, deadline, rows_still_needed, state)
final_reason: Final[StopReason] = state.stop_reason or "batch_cap_reached"
return (
TableCleanupResult(
table_name="LiteLLM_SpendLogs (size rule)", rows_deleted=state.logs_deleted, stop_reason=final_reason
),
TableCleanupResult(
table_name="LiteLLM_SpendLogToolIndex (size rule)",
rows_deleted=state.index_deleted,
stop_reason=final_reason,
),
)
async def _run_size_step(
self,
prisma_client: PrismaClient,
deadline: float,
rows_still_needed: Callable[[int], int],
state: "_SizeStepState",
) -> "_SizeStepState":
"""One step: delete the rows still needed, at most SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES batches of the oldest."""
needed: Final = rows_still_needed(state.logs_deleted + state.index_deleted)
if needed <= 0:
return state.stopped("target_reached")
if time.monotonic() >= deadline:
return state.stopped("budget_exhausted")
if state.batches_left <= 0:
return state.stopped("batch_cap_reached")
step_cap: Final = min(
SPEND_LOG_SIZE_CLEANUP_RECHECK_BATCHES, state.batches_left, math.ceil(needed / self.batch_size)
)
cutoff: Final = await self._size_step_cutoff(prisma_client, deadline, step_cap * self.batch_size)
if cutoff is None:
return state.stopped("budget_exhausted")
logs_result: Final = await self._delete_old_rows_batched(
prisma_client,
cutoff,
table_name="LiteLLM_SpendLogs",
key_columns=("request_id", "startTime"),
time_column="startTime",
deadline=deadline,
batch_cap=step_cap,
)
index_result: Final = await self._delete_old_rows_batched(
prisma_client,
cutoff,
table_name="LiteLLM_SpendLogToolIndex",
key_columns=("request_id", "tool_name"),
time_column="start_time",
deadline=deadline,
batch_cap=step_cap,
)
advanced: Final = _SizeStepState(
logs_deleted=state.logs_deleted + logs_result.rows_deleted,
index_deleted=state.index_deleted + index_result.rows_deleted,
batches_left=state.batches_left - step_cap,
stop_reason=None,
)
if rows_still_needed(advanced.logs_deleted + advanced.index_deleted) <= 0:
return advanced.stopped("target_reached")
if logs_result.stop_reason in ("aborted", "budget_exhausted") or index_result.stop_reason == "aborted":
return advanced.stopped(logs_result.stop_reason if logs_result.stop_reason != "exhausted" else "aborted")
if logs_result.rows_deleted == 0:
return advanced.stopped("exhausted")
return advanced
async def _clean_session_rollup(
self, prisma_client: PrismaClient, retention_seconds: int, deadline: float
) -> tuple[TableCleanupResult, ...]:
@ -968,22 +1255,60 @@ class SpendLogCleanup:
return "completed"
@staticmethod
def _log_run_summary(outcome: RunOutcome, results: tuple[TableCleanupResult, ...], elapsed_seconds: float) -> None:
def _storage_summary(report: SizePassReport | None) -> str:
"""
The size pass in one clause: what was measured, where it ended against the target, and whether the
operator still has to act to get the disk back.
"""
if report is None:
return ""
rows_deleted: Final = sum(result.rows_deleted for result in report.results)
reclaim_note: Final = (
"; allocated bytes stay until autovacuum reuses them, only VACUUM FULL or pg_repack on the spend-log "
"tables returns them to the filesystem"
if rows_deleted > 0 and report.after.allocated_bytes >= report.before.allocated_bytes
else ""
)
return (
f" storage: allocated_before={report.before.allocated_bytes} "
f"estimated_live_before={report.before.estimated_live_bytes} "
f"allocated_after={report.after.allocated_bytes} estimated_live_after={report.after.estimated_live_bytes} "
f"reclaimable_after={report.after.reclaimable_bytes} "
f"limit={report.rule.limit_bytes} target={report.rule.target_bytes} "
f"target_reached={str(report.target_reached).lower()} above_target={report.above_target_bytes}{reclaim_note}"
)
@classmethod
def _log_run_summary(
cls,
outcome: RunOutcome,
results: tuple[TableCleanupResult, ...],
elapsed_seconds: float,
size_report: SizePassReport | None = None,
) -> None:
per_table: Final = ", ".join(
f"{result.table_name}: deleted={result.rows_deleted} stop_reason={result.stop_reason}" for result in results
)
message: Final = "Spend log cleanup run finished: outcome=%s elapsed=%.1fs [%s]"
if outcome == "completed":
verbose_proxy_logger.info(message, outcome, elapsed_seconds, per_table)
message: Final = "Spend log cleanup run finished: outcome=%s elapsed=%.1fs [%s]%s"
storage: Final = cls._storage_summary(size_report)
if outcome == "completed" and (size_report is None or size_report.target_reached):
verbose_proxy_logger.info(message, outcome, elapsed_seconds, per_table, storage)
return
verbose_proxy_logger.warning(message, outcome, elapsed_seconds, per_table)
verbose_proxy_logger.warning(message, outcome, elapsed_seconds, per_table, storage)
async def cleanup_old_spend_logs(self, prisma_client: PrismaClient) -> None:
"""
Main cleanup function. Deletes old spend logs in batches.
The scheduled retention run: every age rule, plus the size rule when one cron drives both.
If pod_lock_manager is available, ensures only one pod runs cleanup.
If no pod_lock_manager, runs cleanup without distributed locking.
"""
await self._run(prisma_client, age_rules=True, size_rule=self._size_pass_runs_with_age_pass())
async def cleanup_spend_logs_by_size(self, prisma_client: PrismaClient) -> None:
"""The size-check run scheduled on maximum_spend_logs_size_check_interval: the size rule only."""
await self._run(prisma_client, age_rules=False, size_rule=True)
async def _run(self, prisma_client: PrismaClient, age_rules: bool, size_rule: bool) -> None:
lock_acquired = False
run_started_at: Final = time.monotonic()
progress: Final = _RunProgress()
@ -992,19 +1317,23 @@ class SpendLogCleanup:
verbose_proxy_logger.info("Cleanup job triggered at %s", datetime.now())
self._refresh_bounds()
delete_spend_logs: Final = self._should_delete_spend_logs()
autorouter_retention_seconds: Final = self._retention_seconds_for(
"maximum_autorouter_session_retention_period"
delete_spend_logs: Final = age_rules and self._should_delete_spend_logs()
active_size_rule: Final = self._size_rule() if size_rule else None
autorouter_retention_seconds: Final = (
self._retention_seconds_for("maximum_autorouter_session_retention_period") if age_rules else None
)
health_check_retention_seconds: Final = self._retention_seconds_for("maximum_health_check_retention_period")
daily_tag_spend_retention_seconds: Final = self._retention_seconds_for(
"maximum_daily_tag_spend_retention_period"
health_check_retention_seconds: Final = (
self._retention_seconds_for("maximum_health_check_retention_period") if age_rules else None
)
daily_tag_spend_retention_seconds: Final = (
self._retention_seconds_for("maximum_daily_tag_spend_retention_period") if age_rules else None
)
if (
not delete_spend_logs
and autorouter_retention_seconds is None
and health_check_retention_seconds is None
and daily_tag_spend_retention_seconds is None
and active_size_rule is None
):
SpendLogCleanupMetrics.record_run("skipped_disabled")
return
@ -1032,11 +1361,13 @@ class SpendLogCleanup:
return
deadline: Final = time.monotonic() + self.run_budget_seconds
size_group: Final = int(active_size_rule is not None)
configured_group_count: Final = (
int(delete_spend_logs and self.retention_seconds is not None)
+ int(autorouter_retention_seconds is not None)
+ int(health_check_retention_seconds is not None)
+ int(daily_tag_spend_retention_seconds is not None)
+ size_group
)
spend_log_results: Final = (
@ -1051,9 +1382,12 @@ class SpendLogCleanup:
int(autorouter_retention_seconds is not None)
+ int(health_check_retention_seconds is not None)
+ int(daily_tag_spend_retention_seconds is not None)
+ size_group
)
remaining_groups_after_sessions: Final = int(health_check_retention_seconds is not None) + int(
daily_tag_spend_retention_seconds is not None
remaining_groups_after_sessions: Final = (
int(health_check_retention_seconds is not None)
+ int(daily_tag_spend_retention_seconds is not None)
+ size_group
)
session_results: Final = (
await self._clean_session_rollup(
@ -1074,15 +1408,27 @@ class SpendLogCleanup:
else ()
)
daily_tag_spend_results: Final = (
await self._clean_daily_tag_spend(prisma_client, daily_tag_spend_retention_seconds, deadline)
await self._clean_daily_tag_spend(
prisma_client,
daily_tag_spend_retention_seconds,
self._group_deadline(deadline, 1 + size_group),
)
if daily_tag_spend_retention_seconds is not None
else ()
)
size_report: Final = (
await self._clean_spend_logs_by_size(prisma_client, active_size_rule, deadline)
if active_size_rule is not None
else None
)
size_results: Final = size_report.results if size_report is not None else ()
results: Final = spend_log_results + session_results + health_check_results + daily_tag_spend_results
results: Final = (
spend_log_results + session_results + health_check_results + daily_tag_spend_results + size_results
)
outcome: Final = self._run_outcome(results)
SpendLogCleanupMetrics.record_run(outcome)
self._log_run_summary(outcome, results, time.monotonic() - run_started_at)
self._log_run_summary(outcome, results, time.monotonic() - run_started_at, size_report)
except asyncio.CancelledError:
verbose_proxy_logger.error(

View file

@ -32,6 +32,9 @@ RunOutcome: TypeAlias = Literal[
_BATCH_DURATION_BUCKETS: Final = (0.005, 0.025, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0)
_TABLE_LABEL: Final = ("table",)
_OUTCOME_LABEL: Final = ("outcome",)
_MEASURE_LABEL: Final = ("measure",)
StorageMeasure: TypeAlias = Literal["allocated", "estimated_live", "above_target"]
class SpendLogCleanupMetrics:
@ -49,6 +52,7 @@ class SpendLogCleanupMetrics:
rows_remaining: "PrometheusGauge | None" = None
batch_failures: "PrometheusCounter | None" = None
runs: "PrometheusCounter | None" = None
storage_bytes: "PrometheusGauge | None" = None
@classmethod
def _ensure_initialized(cls) -> None:
@ -90,6 +94,15 @@ class SpendLogCleanupMetrics:
"Retention cleanup runs, labelled by why the run ended",
labelnames=_OUTCOME_LABEL,
)
cls.storage_bytes = Gauge(
"litellm_spend_log_storage_bytes",
"Disk held by LiteLLM_SpendLogs plus LiteLLM_SpendLogToolIndex as last measured by the cleanup "
"job: allocated is pg_total_relation_size (heap, indexes, TOAST), estimated_live scales it by the "
"live share of rows from pg_stat_user_tables, above_target is estimated_live minus the size "
"cleanup target (0 once the target is reached)",
labelnames=_MEASURE_LABEL,
multiprocess_mode="livemax",
)
except Exception as e: # noqa: BLE001 - a metrics problem must never fail the cleanup run
# Covers the extra being absent, a duplicate registration (repeated
# imports under a test runner), and registry misconfiguration alike.
@ -120,3 +133,9 @@ class SpendLogCleanupMetrics:
cls._ensure_initialized()
if cls.runs is not None:
cls.runs.labels(outcome=outcome).inc()
@classmethod
def set_storage_bytes(cls, measure: StorageMeasure, value: int) -> None:
cls._ensure_initialized()
if cls.storage_bytes is not None:
cls.storage_bytes.labels(measure=measure).set(value)

View file

@ -340,6 +340,7 @@ from litellm.constants import (
REALTIME_SESSION_SUCCESS_LOGGED_KEY,
ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG,
SPEND_CAPTURE_RATE_CHECK_JOB_ID,
SPEND_LOG_SIZE_CHECK_INTERVAL,
USER_SPEND_ALERTS_JOB_ID,
WEEKLY_SPEND_REPORT_JOB_ID,
)
@ -5522,20 +5523,44 @@ def _current_general_settings() -> Mapping[str, object]:
return general_settings
_CLEANUP_SCHEDULE_KEYS: Final = (
_RETENTION_RULE_KEYS: Final = (
"maximum_spend_logs_retention_period",
"maximum_autorouter_session_retention_period",
"maximum_health_check_retention_period",
"maximum_daily_tag_spend_retention_period",
)
_CLEANUP_SCHEDULE_KEYS: Final = (
*_RETENTION_RULE_KEYS,
"maximum_spend_logs_cleanup_cron",
"maximum_spend_logs_retention_interval",
"maximum_spend_logs_size",
"maximum_spend_logs_cleanup_target_size",
"maximum_spend_logs_size_check_interval",
)
_SPEND_LOG_CLEANUP_JOB_ID: Final = "spend_log_cleanup_job"
_SPEND_LOG_SIZE_CHECK_JOB_ID: Final = "spend_log_size_check_job"
def _cleanup_schedule_of(settings: Mapping[str, object]) -> tuple[object, ...]:
return tuple(settings.get(key) for key in _CLEANUP_SCHEDULE_KEYS)
def _wants_retention_job(settings: Mapping[str, object]) -> bool:
"""The retention job runs the age rules, and the size rule too when one cron drives both."""
return any(settings.get(key) is not None for key in _RETENTION_RULE_KEYS) or (
settings.get("maximum_spend_logs_size") is not None and bool(settings.get("maximum_spend_logs_cleanup_cron"))
)
def _wants_size_check_job(settings: Mapping[str, object]) -> bool:
"""The size rule gets its own job on maximum_spend_logs_size_check_interval unless a cleanup cron is set."""
return settings.get("maximum_spend_logs_size") is not None and not settings.get("maximum_spend_logs_cleanup_cron")
def _wants_cleanup_jobs(settings: Mapping[str, object]) -> bool:
return _wants_retention_job(settings) or _wants_size_check_job(settings)
@lru_cache(maxsize=4096)
def _log_ignored_cost_map_copy(model_id: str, fields: tuple[str, ...]) -> None:
verbose_proxy_logger.warning(
@ -8052,39 +8077,73 @@ class ProxyConfig:
if scheduler is None:
return
wants_job: Final = any(
general_settings.get(key) is not None
for key in (
"maximum_spend_logs_retention_period",
"maximum_autorouter_session_retention_period",
"maximum_health_check_retention_period",
"maximum_daily_tag_spend_retention_period",
)
)
if not wants_job:
if scheduler.get_job("spend_log_cleanup_job") is not None:
scheduler.remove_job("spend_log_cleanup_job")
verbose_proxy_logger.info("Removed existing spend log cleanup job")
return
trigger: Final = self._spend_log_cleanup_trigger()
if trigger is None:
return
from litellm.proxy.db.db_transaction_queue.spend_log_cleanup import (
SpendLogCleanup,
)
scheduler.add_job(
SpendLogCleanup().cleanup_old_spend_logs,
trigger,
if not _wants_retention_job(general_settings):
if scheduler.get_job(_SPEND_LOG_CLEANUP_JOB_ID) is not None:
scheduler.remove_job(_SPEND_LOG_CLEANUP_JOB_ID)
verbose_proxy_logger.info("Removed existing spend log cleanup job")
else:
trigger: Final = self._spend_log_cleanup_trigger()
if trigger is not None:
scheduler.add_job(
SpendLogCleanup().cleanup_old_spend_logs,
trigger,
args=[prisma_client],
id=_SPEND_LOG_CLEANUP_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
verbose_proxy_logger.info("Spend log cleanup rescheduled with trigger: %s", trigger)
if not _wants_size_check_job(general_settings): # pyright: ignore[reportUnknownArgumentType] # general_settings is an untyped module dict
if scheduler.get_job(_SPEND_LOG_SIZE_CHECK_JOB_ID) is not None: # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped
scheduler.remove_job(_SPEND_LOG_SIZE_CHECK_JOB_ID) # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped
verbose_proxy_logger.info("Removed existing spend log size check job")
return
size_trigger: Final = self._spend_log_size_check_trigger()
if size_trigger is None:
return
scheduler.add_job( # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped
SpendLogCleanup().cleanup_spend_logs_by_size,
size_trigger,
args=[prisma_client],
id="spend_log_cleanup_job",
id=_SPEND_LOG_SIZE_CHECK_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
verbose_proxy_logger.info("Spend log cleanup rescheduled with trigger: %s", trigger)
verbose_proxy_logger.info("Spend log size check rescheduled with trigger: %s", size_trigger)
def _spend_log_cleanup_trigger(self) -> BaseTrigger | None:
def _spend_log_size_check_trigger(self) -> object | None:
return self._staggered_interval_trigger(
_SPEND_LOG_SIZE_CHECK_JOB_ID,
"maximum_spend_logs_size_check_interval",
general_settings.get("maximum_spend_logs_size_check_interval", SPEND_LOG_SIZE_CHECK_INTERVAL), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # general_settings is an untyped module dict
)
@staticmethod
def _staggered_interval_trigger(job_id: str, setting_name: str, interval: object) -> object | None:
if not isinstance(interval, str):
verbose_proxy_logger.error("Invalid %s value: %r", setting_name, interval)
return None
# this runs against a started scheduler, which the startup stagger sweep
# cannot reach, so the offset is applied here or the job reconverges across
# replicas the first time an admin edits the settings
try:
interval_seconds: Final = duration_in_seconds(interval)
return stagger_trigger(
job_id=job_id,
trigger=IntervalTrigger(seconds=interval_seconds),
period_seconds=interval_seconds,
settings=parse_stagger_settings(general_settings), # pyright: ignore[reportUnknownArgumentType] # general_settings is an untyped module dict
)
except (ValueError, OverflowError):
verbose_proxy_logger.error("Invalid %s value: %r", setting_name, interval)
return None
def _spend_log_cleanup_trigger(self) -> object | None:
cleanup_cron: Final[object] = general_settings.get("maximum_spend_logs_cleanup_cron")
if cleanup_cron:
from apscheduler.triggers.cron import CronTrigger
@ -8095,24 +8154,11 @@ class ProxyConfig:
verbose_proxy_logger.error("Invalid maximum_spend_logs_cleanup_cron value: %s", cleanup_cron)
return None
return cron_trigger
retention_interval: Final[object] = general_settings.get("maximum_spend_logs_retention_interval", "1d")
if not isinstance(retention_interval, str):
verbose_proxy_logger.error("Invalid maximum_spend_logs_retention_interval value: %r", retention_interval)
return None
# this runs against a started scheduler, which the startup stagger sweep
# cannot reach, so the offset is applied here or the job reconverges across
# replicas the first time an admin edits the retention settings
try:
interval_seconds: Final = duration_in_seconds(retention_interval)
return stagger_trigger(
job_id="spend_log_cleanup_job",
trigger=IntervalTrigger(seconds=interval_seconds),
period_seconds=interval_seconds,
settings=parse_stagger_settings(general_settings),
)
except (ValueError, OverflowError):
verbose_proxy_logger.error("Invalid maximum_spend_logs_retention_interval value: %r", retention_interval)
return None
return self._staggered_interval_trigger(
_SPEND_LOG_CLEANUP_JOB_ID,
"maximum_spend_logs_retention_interval",
general_settings.get("maximum_spend_logs_retention_interval", "1d"),
)
async def _update_general_settings(self, db_general_settings: Mapping[str, SettingsJsonValue] | None) -> None:
global general_settings
@ -8269,8 +8315,11 @@ class ProxyConfig:
if scheduler is not None and scheduler.state == STATE_STOPPED:
return
schedule: Final = self._resolved_cleanup_schedule()
wants_job: Final = any(value is not None for value in schedule[:4])
has_job: Final = scheduler is not None and scheduler.get_job("spend_log_cleanup_job") is not None
wants_job: Final = _wants_cleanup_jobs(self.settings)
has_job: Final = scheduler is not None and (
scheduler.get_job(_SPEND_LOG_CLEANUP_JOB_ID) is not None
or scheduler.get_job(_SPEND_LOG_SIZE_CHECK_JOB_ID) is not None # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped
)
baseline: Final = (
self._last_cleanup_schedule_attempt
if has_job and self._last_cleanup_schedule_attempt is not None
@ -11417,12 +11466,29 @@ class ProxyStartupEvent:
### SPEND LOG CLEANUP ###
cleanup_settings: Final = _current_general_settings()
if (
cleanup_settings.get("maximum_spend_logs_retention_period") is not None
or cleanup_settings.get("maximum_autorouter_session_retention_period") is not None
or cleanup_settings.get("maximum_health_check_retention_period") is not None
or cleanup_settings.get("maximum_daily_tag_spend_retention_period") is not None
):
if _wants_size_check_job(cleanup_settings):
size_check_interval: Final = cleanup_settings.get(
"maximum_spend_logs_size_check_interval", SPEND_LOG_SIZE_CHECK_INTERVAL
)
try:
if not isinstance(size_check_interval, str):
raise TypeError(size_check_interval)
size_check_seconds: Final = duration_in_seconds(size_check_interval)
scheduler.add_job( # pyright: ignore[reportUnknownMemberType] # apscheduler is untyped
SpendLogCleanup().cleanup_spend_logs_by_size,
"interval",
seconds=size_check_seconds + random.randint(0, 60),
args=[prisma_client],
id=_SPEND_LOG_SIZE_CHECK_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
verbose_proxy_logger.info("Spend log size check scheduled every %s", size_check_interval)
except (TypeError, ValueError, OverflowError):
verbose_proxy_logger.error(
"Invalid maximum_spend_logs_size_check_interval value: %r", size_check_interval
)
if _wants_retention_job(cleanup_settings):
spend_log_cleanup: Final = SpendLogCleanup()
cleanup_cron: Final = cleanup_settings.get("maximum_spend_logs_cleanup_cron")
@ -11437,7 +11503,7 @@ class ProxyStartupEvent:
spend_log_cleanup.cleanup_old_spend_logs,
cron_trigger,
args=[prisma_client],
id="spend_log_cleanup_job",
id=_SPEND_LOG_CLEANUP_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
@ -11456,7 +11522,7 @@ class ProxyStartupEvent:
"interval",
seconds=interval_seconds + random.randint(0, 60),
args=[prisma_client],
id="spend_log_cleanup_job",
id=_SPEND_LOG_CLEANUP_JOB_ID,
replace_existing=True,
misfire_grace_time=APSCHEDULER_MISFIRE_GRACE_TIME,
)
@ -11464,6 +11530,7 @@ class ProxyStartupEvent:
verbose_proxy_logger.error(
"Invalid maximum_spend_logs_retention_interval value: %r", retention_interval
)
if _wants_cleanup_jobs(cleanup_settings):
proxy_config.record_cleanup_schedule_attempt(cleanup_settings)
### CHECK BATCH COST ###
if llm_router is not None and PROXY_BATCH_POLLING_ENABLED:

View file

@ -105,3 +105,166 @@ async def test_delete_batch_survives_witnessed_lock_past_transaction_default(cap
await database.disconnect()
finally:
setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
async def test_size_rule_deletes_the_oldest_rows_until_under_the_target_and_does_not_refire_after_vacuum(
caplog: pytest.LogCaptureFixture,
) -> None:
"""
Deleting rows never shrinks the table file, so the size pass must stop on live bytes, not on
pg_total_relation_size, taking the oldest rows first and keeping the tool index on the same cutoff. Once
autovacuum has turned the deleted rows into free space the catalog no longer shows them as dead, and the
files still measure the same; the next check must read what the last run freed from its saved count and
leave the table alone instead of calling the whole file live again.
"""
caplog.set_level(logging.INFO, logger="LiteLLM Proxy")
schema: Final = f"integration_{uuid.uuid4().hex}"
url: Final = os.environ["DATABASE_URL"]
parsed: Final = urlsplit(url)
scoped_url: Final = urlunsplit(
parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema}))
)
logs: Final = sql.Identifier(schema, "LiteLLM_SpendLogs")
index: Final = sql.Identifier(schema, "LiteLLM_SpendLogToolIndex")
config: Final = sql.Identifier(schema, "LiteLLM_Config")
seeded: Final = 6000
now: Final = datetime.now(timezone.utc).replace(tzinfo=None)
stats_sql: Final = (
"SELECT sum(n_live_tup)::int AS live, sum(n_dead_tup)::int AS dead, sum(n_tup_ins)::int AS inserted "
"FROM pg_stat_user_tables "
"WHERE schemaname = %s AND relname IN ('LiteLLM_SpendLogs', 'LiteLLM_SpendLogToolIndex')"
)
with psycopg.connect(url, autocommit=True) as setup:
setup.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
try:
setup.execute(
sql.SQL(
'CREATE TABLE {} (request_id text, "startTime" timestamp NOT NULL, payload text, '
'PRIMARY KEY (request_id, "startTime"))'
).format(logs)
)
setup.execute(
sql.SQL(
"CREATE TABLE {} (request_id text, tool_name text, start_time timestamp NOT NULL, "
"PRIMARY KEY (request_id, tool_name))"
).format(index)
)
setup.execute(sql.SQL("CREATE TABLE {} (param_name text PRIMARY KEY, param_value jsonb)").format(config))
setup.execute(
sql.SQL(
"INSERT INTO {} SELECT 'req-' || lpad(i::text, 6, '0'), %s - (%s - i) * interval '1 minute', "
"repeat('x', 1000) FROM generate_series(1, %s) AS i"
).format(logs),
(now, seeded, seeded),
)
setup.execute(
sql.SQL(
"INSERT INTO {} SELECT request_id, 'tool', \"startTime\" FROM {} WHERE right(request_id, 1) = '0'"
).format(index, logs)
)
index_rows: Final = seeded // 10
inserts_deadline: Final = time.monotonic() + 10
while True:
# a backend only ships its pending row counters when it runs another command, so give the
# seeding connection one; the size rule rolls its count forward from n_tup_ins, which must
# show the seed inserts before ANALYZE sets the live estimate or the two add up to double
setup.execute("SELECT 1")
(inserts,) = read_rows(stats_sql, (schema,))
if inserts["inserted"] == seeded + index_rows:
break
assert time.monotonic() < inserts_deadline, f"row statistics never saw the seed inserts: {inserts}"
await asyncio.sleep(0.1)
setup.execute(sql.SQL("ANALYZE {}").format(logs))
setup.execute(sql.SQL("ANALYZE {}").format(index))
stats_deadline: Final = time.monotonic() + 10
while True:
setup.execute("SELECT 1")
(stats,) = read_rows(stats_sql, (schema,))
if stats["live"] == seeded + index_rows:
break
assert time.monotonic() < stats_deadline, f"row statistics never caught up with ANALYZE: {stats}"
await asyncio.sleep(0.1)
(allocated,) = read_rows(
"SELECT (pg_total_relation_size(%s::regclass) + pg_total_relation_size(%s::regclass))::bigint "
"AS allocated",
(logs.as_string(setup), index.as_string(setup)),
)
allocated_before: Final = int(str(allocated["allocated"]))
assert allocated_before > 0
cleanup: Final = SpendLogCleanup(
general_settings={
# trigger below what is there, clean down to half of it
"maximum_spend_logs_size": str(allocated_before * 3 // 4),
"maximum_spend_logs_cleanup_target_size": str(allocated_before // 2),
"maximum_spend_logs_cleanup_batch_size": 100,
}
)
cleanup.pod_lock_manager = None
async def size_check() -> None:
database: Final = Prisma(datasource={"url": scoped_url})
await database.connect()
try:
await cleanup.cleanup_spend_logs_by_size(PartitionConnection(database))
finally:
await database.disconnect()
await size_check()
remaining: Final = read_rows(
f'SELECT request_id FROM {logs.as_string(setup)} ORDER BY "startTime"',
(),
)
# half the bytes is half the rows: the oldest 3000 spend logs and their 300 index rows go, counted
# from the deletes themselves, so the stats collector's lag cannot make the run overshoot. A page the
# files grew by between this measurement and the run's probe costs at most one more batch
assert 2900 <= len(remaining) <= 3000, f"{len(remaining)} spend logs remain, expected 3000 less one batch"
assert remaining[0]["request_id"] == f"req-{seeded - len(remaining) + 1:06d}"
assert remaining[-1]["request_id"] == f"req-{seeded:06d}"
(orphans,) = read_rows(
f"SELECT count(*)::int AS n FROM {index.as_string(setup)} i "
f"WHERE NOT EXISTS (SELECT 1 FROM {logs.as_string(setup)} l WHERE l.request_id = i.request_id)",
(),
)
assert orphans["n"] == 0
(kept_index,) = read_rows(f"SELECT count(*)::int AS n FROM {index.as_string(setup)}", ())
assert kept_index["n"] == len(remaining) // 10
summary: Final = next(
record.getMessage()
for record in caplog.records
if "Spend log cleanup run finished" in record.getMessage()
)
assert "target_reached=true" in summary
assert f"allocated_before={allocated_before}" in summary
# the file did not shrink: that is the whole reason the reclaimable count exists
assert f"allocated_after={allocated_before}" in summary
assert "VACUUM FULL or pg_repack" in summary
deletes_deadline: Final = time.monotonic() + 10
while True:
(after_deletes,) = read_rows(stats_sql, (schema,))
if after_deletes["dead"] == 3300:
break
assert time.monotonic() < deletes_deadline, f"row statistics never saw the deletes: {after_deletes}"
await asyncio.sleep(0.1)
setup.execute(sql.SQL("VACUUM (ANALYZE) {}").format(logs))
setup.execute(sql.SQL("VACUUM (ANALYZE) {}").format(index))
vacuum_deadline: Final = time.monotonic() + 10
while True:
(after_vacuum,) = read_rows(stats_sql, (schema,))
if after_vacuum["dead"] == 0:
break
assert time.monotonic() < vacuum_deadline, f"row statistics never caught up with VACUUM: {after_vacuum}"
await asyncio.sleep(0.1)
caplog.clear()
await size_check()
(still_there,) = read_rows(f"SELECT count(*)::int AS n FROM {logs.as_string(setup)}", ())
assert still_there["n"] == len(remaining), (
"the second check deleted rows the first one had already brought under the limit"
)
assert any("is under maximum_spend_logs_size" in record.getMessage() for record in caplog.records)
finally:
setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))

View file

@ -1681,7 +1681,7 @@ async def test_a_cleanup_delete_batch_renders_a_postgres_delete_span_for_its_tab
assert await postgres_span_names() == ("postgres.delete LiteLLM_SpendLogs",)
# --- spend log storage measurement and the reclaimable-bytes state ---------------------------------------
# --- size rule (maximum_spend_logs_size) ---------------------------------------------------------------
def _storage(allocated: int, live: int, dead: int, inserted: int | None = None, deleted: int | None = None) -> dict:
@ -1694,6 +1694,50 @@ def _storage(allocated: int, live: int, dead: int, inserted: int | None = None,
}
def _wire_size_probes(
db, storage_rows: list[dict], cutoff: datetime, saved_state: SizeCleanupState | None = None
) -> list[SizeCleanupState]:
"""
Answer the size pass's catalog reads: the storage probe walks `storage_rows` one measurement per call
(repeating the last), the step-cutoff query returns `cutoff`, the remaining-rows probe returns 0, and the
LiteLLM_Config row holds `saved_state`. Writes of that row land in the returned list instead of
db.execute_raw, so DELETE counts stay what tests configure.
"""
measurements = iter(storage_rows)
last = [storage_rows[-1]]
saves: list[SizeCleanupState] = []
deletes = db.execute_raw
async def _query_raw(sql, *args):
if "pg_total_relation_size" in sql:
row = next(measurements, None)
if row is None:
row = last[0]
return [row]
if "OFFSET" in sql:
return [{"startTime": cutoff}]
if '"LiteLLM_Config"' in sql:
return [{"state_json": saved_state.model_dump_json()}] if saved_state is not None else []
return [{"remaining": 0}]
async def _execute_raw(sql, *args):
if '"LiteLLM_Config"' in sql:
saves.append(SizeCleanupState.model_validate_json(args[1]))
return 1
return await deletes(sql, *args)
db.query_raw = AsyncMock(side_effect=_query_raw)
db.execute_raw = _execute_raw
db.deletes = deletes
return saves
def _size_cleaner(settings: dict, **kwargs) -> SpendLogCleanup:
cleaner = SpendLogCleanup(general_settings={"maximum_spend_logs_size": "1GB", **settings}, **kwargs)
cleaner.pod_lock_manager = None
return cleaner
@pytest.mark.parametrize(
("value", "expected"),
[
@ -1719,6 +1763,160 @@ def test_size_in_bytes_rejects_what_cannot_be_a_size(value):
size_in_bytes(value)
def test_an_unset_size_means_no_size_rule_and_a_bad_target_falls_back_to_the_size(caplog):
assert SpendLogCleanup(general_settings={})._size_rule() is None
rule = _size_cleaner({"maximum_spend_logs_cleanup_target_size": "200MB"})._size_rule()
assert rule is not None
assert (rule.limit_bytes, rule.target_bytes) == (1024**3, 200 * 1024**2)
with caplog.at_level(logging.ERROR, logger="LiteLLM Proxy"):
rule_without_room = _size_cleaner({"maximum_spend_logs_cleanup_target_size": "2GB"})._size_rule()
assert rule_without_room is not None
assert rule_without_room.target_bytes == rule_without_room.limit_bytes == 1024**3
assert any("must be below maximum_spend_logs_size" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
async def test_size_pass_under_the_limit_issues_no_delete():
db = MagicMock()
_wire_tx(db)
db.execute_raw = AsyncMock(return_value=0)
_wire_size_probes(db, [_storage(allocated=500 * 1024**2, live=1000, dead=0)], cutoff=datetime.now(timezone.utc))
client = MagicMock()
client.db = db
await _size_cleaner({}).cleanup_spend_logs_by_size(client)
assert db.deletes.await_count == 0
@pytest.mark.asyncio
async def test_size_pass_deletes_oldest_first_in_steps_until_the_live_estimate_is_under_the_target(caplog):
"""
Over the limit, the pass turns the next step of oldest rows into a cutoff and runs the retention DELETE
for the spend logs and their tool-index rows, re-measuring after each step; it stops on the first
measurement under the target, not when the file size (which deletes never shrink) moves.
"""
db = MagicMock()
_wire_tx(db)
# 3 GiB over 3000 rows is 1 MiB a row, so 2560 rows take it from 3 GiB to the 512 MiB target: one step
# of 3 batches; the tool index is cleaned on the same cutoff
db.execute_raw = AsyncMock(side_effect=[1000, 1000, 1000, 300, 0])
step_cutoff = datetime(2026, 9, 1, tzinfo=timezone.utc)
allocated = 3 * 1024**3
_wire_size_probes(db, [_storage(allocated, live=3000, dead=0)], cutoff=step_cutoff)
client = MagicMock()
client.db = db
cleaner = _size_cleaner({"maximum_spend_logs_cleanup_target_size": "512MB"})
with caplog.at_level(logging.INFO, logger="LiteLLM Proxy"):
await cleaner.cleanup_spend_logs_by_size(client)
deletes = [(call[0][0], call[0][1:]) for call in db.deletes.call_args_list]
assert len(deletes) == 5
assert 'DELETE FROM "LiteLLM_SpendLogs"' in deletes[0][0]
assert 'WHERE "startTime" < $1' in deletes[0][0]
assert deletes[0][1][0] == step_cutoff
assert 'DELETE FROM "LiteLLM_SpendLogToolIndex"' in deletes[3][0]
assert deletes[3][1][0] == step_cutoff
offset_query = next(call for call in db.query_raw.call_args_list if "OFFSET" in call[0][0])
assert offset_query[0][1] == 3000
summary = next(r.getMessage() for r in caplog.records if "Spend log cleanup run finished" in r.getMessage())
assert "outcome=completed" in summary
assert "LiteLLM_SpendLogs (size rule): deleted=3000 stop_reason=target_reached" in summary
assert "LiteLLM_SpendLogToolIndex (size rule): deleted=300 stop_reason=target_reached" in summary
assert f"allocated_before={allocated}" in summary
assert "estimated_live_before=3221225472" in summary
assert "estimated_live_after=0" in summary
assert "target_reached=true above_target=0" in summary
assert "VACUUM FULL or pg_repack" in summary
@pytest.mark.asyncio
async def test_size_pass_stops_at_the_run_batch_cap_and_reports_how_far_above_the_target_it_is(caplog):
db = MagicMock()
_wire_tx(db)
# 4 GiB over 4000 rows: 3584 rows must go to reach the 512 MiB target, the cap allows 3 batches
db.execute_raw = AsyncMock(side_effect=[1000, 1000, 1000, 0])
allocated = 4 * 1024**3
saves = _wire_size_probes(db, [_storage(allocated, live=4000, dead=0)], cutoff=datetime.now(timezone.utc))
client = MagicMock()
client.db = db
cleaner = _size_cleaner(
{"maximum_spend_logs_cleanup_target_size": "512MB", "maximum_spend_logs_cleanup_max_batches": 3}
)
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
await cleaner.cleanup_spend_logs_by_size(client)
assert db.deletes.await_count == 4
summary = next(r for r in caplog.records if "Spend log cleanup run finished" in r.getMessage())
assert summary.levelno == logging.WARNING
assert "outcome=batch_cap_reached" in summary.getMessage()
assert "LiteLLM_SpendLogs (size rule): deleted=3000 stop_reason=batch_cap_reached" in summary.getMessage()
# 1000 of 4000 rows left is 1 GiB live, 512 MiB above the target
assert f"target_reached=false above_target={512 * 1024**2}" in summary.getMessage()
# the next check starts from what this run freed, with the deletes already counted out of n_tup_del
assert saves == [
SizeCleanupState(
allocated_bytes=allocated,
reclaimable_bytes=round(3000 * allocated / 4000),
bytes_per_row=allocated / 4000,
inserted_total=4000,
deleted_total=3000,
)
]
@pytest.mark.asyncio
async def test_size_pass_skips_on_stale_row_statistics_instead_of_trusting_an_estimate_of_zero(caplog):
db = MagicMock()
_wire_tx(db)
db.execute_raw = AsyncMock(return_value=1000)
_wire_size_probes(db, [_storage(allocated=5 * 1024**3, live=0, dead=0)], cutoff=datetime.now(timezone.utc))
client = MagicMock()
client.db = db
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
await _size_cleaner({}).cleanup_spend_logs_by_size(client)
assert db.deletes.await_count == 0
assert any("row statistics are stale" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
async def test_size_pass_does_not_refire_after_autovacuum_forgets_the_rows_it_deleted():
"""
The run before this one deleted 15,000 of 20,000 rows and saved what it freed. Autovacuum has since
turned those dead tuples into free space, so pg_stat_user_tables reports 5,000 live and 0 dead rows while
the files still measure the full 32 MB. Trusting the live share would call all 32 MB live and empty the
table; the saved count says 8 MB are live, under the limit, so nothing is deleted.
"""
db = MagicMock()
_wire_tx(db)
db.execute_raw = AsyncMock(return_value=1000)
after_last_run = SizeCleanupState(
allocated_bytes=32_030_720,
reclaimable_bytes=24_015_000,
bytes_per_row=1601.0,
inserted_total=20_000,
deleted_total=15_000,
)
saves = _wire_size_probes(
db,
[_storage(32_030_720, live=5000, dead=0, inserted=20_000, deleted=15_000)],
cutoff=datetime.now(timezone.utc),
saved_state=after_last_run,
)
client = MagicMock()
client.db = db
cleaner = _size_cleaner({"maximum_spend_logs_size": "16MB", "maximum_spend_logs_cleanup_target_size": "10MB"})
await cleaner.cleanup_spend_logs_by_size(client)
assert db.deletes.await_count == 0
assert saves[0].reclaimable_bytes == 24_015_000
_RUN_STATE = SizeCleanupState(
allocated_bytes=32_030_720,
reclaimable_bytes=24_015_000,
@ -1765,6 +1963,58 @@ def test_advance_size_state_tracks_free_space_through_vacuum_inserts_and_other_d
assert 0 <= probe["allocated_bytes"] - advanced.reclaimable_bytes <= probe["allocated_bytes"], name
@pytest.mark.asyncio
async def test_size_only_run_leaves_the_age_rules_to_the_retention_job():
db = MagicMock()
_wire_tx(db)
db.execute_raw = AsyncMock(return_value=0)
_wire_size_probes(db, [_storage(allocated=10 * 1024**3, live=100, dead=0)], cutoff=datetime.now(timezone.utc))
client = MagicMock()
client.db = db
cleaner = _size_cleaner(
{"maximum_spend_logs_retention_period": "7d", "maximum_health_check_retention_period": "30d"}
)
await cleaner.cleanup_spend_logs_by_size(client)
tables = {call[0][0].split('"')[1] for call in db.deletes.call_args_list}
assert tables == {"LiteLLM_SpendLogs", "LiteLLM_SpendLogToolIndex"}
@pytest.mark.asyncio
@pytest.mark.parametrize(("cron", "size_pass_expected"), [(None, False), ("0 3 * * *", True)])
async def test_the_retention_run_carries_the_size_pass_only_when_one_cron_drives_both(cron, size_pass_expected):
db = MagicMock()
_wire_tx(db)
db.execute_raw = AsyncMock(return_value=0)
_wire_size_probes(db, [_storage(allocated=10 * 1024**3, live=100, dead=0)], cutoff=datetime.now(timezone.utc))
client = MagicMock()
client.db = db
cleaner = _size_cleaner({"maximum_spend_logs_retention_period": "7d", "maximum_spend_logs_cleanup_cron": cron})
await cleaner.cleanup_old_spend_logs(client)
measured = any("pg_total_relation_size" in call[0][0] for call in db.query_raw.call_args_list)
assert measured is size_pass_expected
@pytest.mark.parametrize(
("settings", "retention_job", "size_job"),
[
({}, False, False),
({"maximum_spend_logs_retention_period": "7d"}, True, False),
({"maximum_spend_logs_size": "180GB"}, False, True),
({"maximum_spend_logs_size": "180GB", "maximum_spend_logs_retention_period": "7d"}, True, True),
({"maximum_spend_logs_size": "180GB", "maximum_spend_logs_cleanup_cron": "0 3 * * *"}, True, False),
],
)
def test_which_scheduler_jobs_the_cleanup_settings_ask_for(settings, retention_job, size_job):
from litellm.proxy.proxy_server import _wants_retention_job, _wants_size_check_job
assert _wants_retention_job(settings) is retention_job
assert _wants_size_check_job(settings) is size_job
def test_after_deleting_counts_the_rows_and_any_file_growth_as_reclaimable_and_to_state_keeps_the_counters():
storage = SpendLogStorage(allocated_bytes=32_030_720, reclaimable_bytes=0, bytes_per_row=1601.0, live_rows=20_000)

View file

@ -30072,11 +30072,26 @@ export interface components {
* @description Wall-clock budget for one spend log cleanup run (e.g. '5m'), shared across every table it prunes. A run that hits the budget stops and the next run resumes from where it left off. Defaults to '5m'.
*/
maximum_spend_logs_cleanup_run_budget?: string | null;
/**
* Maximum Spend Logs Cleanup Target Size
* @description Level a size-triggered cleanup run deletes down to (e.g. '20GB'). Must be below maximum_spend_logs_size. Unset means the run stops as soon as the tables are back under maximum_spend_logs_size.
*/
maximum_spend_logs_cleanup_target_size?: string | null;
/**
* Maximum Spend Logs Retention Period
* @description Maximum retention period for spend logs (e.g., '7d' for 7 days). Logs older than this will be deleted.
*/
maximum_spend_logs_retention_period?: string | null;
/**
* Maximum Spend Logs Size
* @description Size rule for spend logs (e.g. '180GB'): when LiteLLM_SpendLogs plus LiteLLM_SpendLogToolIndex (heap, indexes and TOAST, measured with pg_total_relation_size) hold more live data than this, the cleanup job deletes the oldest spend logs first until they are under maximum_spend_logs_cleanup_target_size. Independent of maximum_spend_logs_retention_period: a row is deleted when either rule says so. Unset means no size rule.
*/
maximum_spend_logs_size?: string | null;
/**
* Maximum Spend Logs Size Check Interval
* @description How often the size rule is checked (e.g. '10m'). Defaults to '1d'. Ignored when maximum_spend_logs_cleanup_cron is set, which then drives both the age and the size cleanup.
*/
maximum_spend_logs_size_check_interval?: string | null;
/**
* Mcp Advertised Versions
* @description MCP revisions enabled by the gateway. Defaults to all completed legacy revisions. Modern protocol serving requires explicit opt-in. Apps/Tasks remain disabled.