Skip to content

Commit 2a771ca

Browse files
author
hiraku-miyoshi
committed
fix(proxy): clamp reservation record TTL so stale records never outlive their counters
1 parent 5ab20c3 commit 2a771ca

2 files changed

Lines changed: 46 additions & 8 deletions

File tree

litellm/proxy/hooks/batch_enqueued_tokens.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
"""
1010

1111
import asyncio
12+
import math
13+
import time
1214
import uuid
13-
from collections.abc import Awaitable, Mapping, Sequence
14-
from dataclasses import dataclass
15+
from collections.abc import Awaitable, Callable, Mapping, Sequence
16+
from dataclasses import dataclass, field
1517
from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, TypeAlias
1618

1719
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
@@ -85,6 +87,7 @@ class BatchEnqueuedTokenReservation:
8587
scopes: tuple[BatchEnqueuedTokenScope, ...]
8688
backend: ReservationBackend = "redis"
8789
owner: str = ""
90+
reserved_at_monotonic: float = field(default_factory=time.monotonic, compare=False)
8891

8992

9093
@dataclass(frozen=True, slots=True)
@@ -180,11 +183,18 @@ class BatchEnqueuedTokenStore:
180183
granted them, and in-memory grants also remember the granting worker, so a
181184
refund never debits counters the grant did not charge. Everything expires after
182185
``BATCH_ENQUEUED_TOKEN_TTL_SECONDS`` so a crash between submission and the
183-
terminal-state refund can never leak tokens forever.
186+
terminal-state refund can never leak tokens forever, and reservation records
187+
expire no later than the counters they would refund, so a stale record can
188+
never debit an allowance re-granted after its counters expired.
184189
"""
185190

186-
def __init__(self, internal_usage_cache: "InternalUsageCache") -> None:
191+
def __init__(
192+
self,
193+
internal_usage_cache: "InternalUsageCache",
194+
monotonic: Callable[[], float] = time.monotonic,
195+
) -> None:
187196
self.internal_usage_cache = internal_usage_cache
197+
self._monotonic: Final = monotonic
188198
self._lock = asyncio.Lock()
189199
self._owner_token = uuid.uuid4().hex
190200
redis_cache = internal_usage_cache.dual_cache.redis_cache
@@ -235,6 +245,7 @@ async def _reserve_via_redis(
235245
tokens: int,
236246
scopes: tuple[BatchEnqueuedTokenScope, ...],
237247
) -> BatchEnqueuedTokenOutcome:
248+
started: Final = self._monotonic()
238249
for index, scope in enumerate(scopes):
239250
result = await self._run_reserve_script(
240251
reserve_script,
@@ -246,7 +257,9 @@ async def _reserve_via_redis(
246257
if result[0] != 1:
247258
await self._rollback_partial_reserve(refund_script, tokens=tokens, scopes=scopes[:index])
248259
return BatchEnqueuedTokenOverLimit(scope=scope, enqueued=result[1])
249-
return BatchEnqueuedTokenReservation(tokens=tokens, scopes=scopes, backend="redis")
260+
return BatchEnqueuedTokenReservation(
261+
tokens=tokens, scopes=scopes, backend="redis", reserved_at_monotonic=started
262+
)
250263

251264
async def _run_reserve_script(
252265
self,
@@ -295,14 +308,17 @@ async def _reserve_in_memory(
295308
scopes: tuple[BatchEnqueuedTokenScope, ...],
296309
span: "Span | None",
297310
) -> BatchEnqueuedTokenOutcome:
311+
started: Final = self._monotonic()
298312
async with self._lock:
299313
currents: Final = tuple([await self._get_local_counter(scope, span) for scope in scopes])
300314
for scope, current in zip(scopes, currents):
301315
if current + tokens > scope.limit:
302316
return BatchEnqueuedTokenOverLimit(scope=scope, enqueued=current)
303317
for scope, current in zip(scopes, currents):
304318
await self._set_local_counter(scope, current + tokens, span)
305-
return BatchEnqueuedTokenReservation(tokens=tokens, scopes=scopes, backend="memory", owner=self._owner_token)
319+
return BatchEnqueuedTokenReservation(
320+
tokens=tokens, scopes=scopes, backend="memory", owner=self._owner_token, reserved_at_monotonic=started
321+
)
306322

307323
async def refund(
308324
self,
@@ -349,11 +365,13 @@ async def save_reservation(
349365
litellm_parent_otel_span: "Span | None" = None,
350366
) -> None:
351367
serialized: Final = _RESERVATION_ADAPTER.dump_json(reservation).decode("utf-8")
368+
elapsed: Final = self._monotonic() - reservation.reserved_at_monotonic
369+
ttl: Final = max(1, BATCH_ENQUEUED_TOKEN_TTL_SECONDS - math.ceil(elapsed))
352370
if self._save_script is not None:
353371
try:
354372
await self._save_script(
355373
(self._record_key(batch_id),),
356-
(serialized, BATCH_ENQUEUED_TOKEN_TTL_SECONDS),
374+
(serialized, ttl),
357375
)
358376
except Exception as e: # noqa: BLE001 # any Redis failure must fall back to the in-memory record
359377
verbose_proxy_logger.warning(
@@ -364,7 +382,7 @@ async def save_reservation(
364382
await self.internal_usage_cache.async_set_cache(
365383
key=self._record_key(batch_id),
366384
value=serialized,
367-
ttl=BATCH_ENQUEUED_TOKEN_TTL_SECONDS,
385+
ttl=ttl,
368386
litellm_parent_otel_span=litellm_parent_otel_span,
369387
local_only=True,
370388
)

tests/test_litellm/proxy/hooks/test_batch_enqueued_tokens.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import pytest
1717

1818
from litellm.caching.caching import DualCache
19+
from litellm.constants import BATCH_ENQUEUED_TOKEN_TTL_SECONDS
1920
from litellm.proxy._types import UserAPIKeyAuth
2021
from litellm.proxy.hooks.batch_enqueued_tokens import (
2122
BatchEnqueuedTokenOverLimit,
@@ -136,6 +137,7 @@ def __init__(
136137
raise_after_landing_save_keys: frozenset[str] = frozenset(),
137138
) -> None:
138139
self.script_calls: tuple[tuple[str, tuple[str, ...]], ...] = ()
140+
self.save_ttls: tuple[int, ...] = ()
139141
self.counters: Mapping[str, int] = MappingProxyType({})
140142
self.records: Mapping[str, str] = MappingProxyType({})
141143
self.fail_reserve_keys = fail_reserve_keys
@@ -180,6 +182,7 @@ def _run(self, kind: str, keys: tuple[str, ...], args: tuple[str | bytes | int |
180182
if keys[0] in self.fail_save_keys:
181183
raise ConnectionError(f"simulated redis failure for {keys[0]}")
182184
self.records = MappingProxyType({**self.records, keys[0]: str(args[0])})
185+
self.save_ttls = (*self.save_ttls, int(args[1]))
183186
if keys[0] in self.raise_after_landing_save_keys:
184187
raise TimeoutError(f"simulated redis timeout after landing for {keys[0]}")
185188
return 1
@@ -303,6 +306,23 @@ async def test_local_ghost_left_by_landed_save_never_refunds_twice():
303306
assert fake.counters[f"batch_enqueued_tokens:{scope.key}:{scope.value}"] == 100
304307

305308

309+
@pytest.mark.asyncio
310+
async def test_record_ttl_shrinks_by_elapsed_time_so_stale_records_never_outlive_their_counters():
311+
scope = _scope(limit=100)
312+
fake = _SingleKeyRedisFake()
313+
ticks = iter((1_000.0, 1_030.5))
314+
store = BatchEnqueuedTokenStore(
315+
internal_usage_cache=InternalUsageCache(DualCache(redis_cache=fake, default_in_memory_ttl=60)),
316+
monotonic=lambda: next(ticks),
317+
)
318+
reservation = await store.reserve(tokens=60, scopes=(scope,))
319+
assert isinstance(reservation, BatchEnqueuedTokenReservation)
320+
assert reservation.reserved_at_monotonic == 1_000.0
321+
await store.save_reservation("batch_ttl_clamp", reservation)
322+
assert fake.save_ttls == (BATCH_ENQUEUED_TOKEN_TTL_SECONDS - 31,)
323+
assert await store.pop_reservation("batch_ttl_clamp") == reservation
324+
325+
306326
@pytest.mark.asyncio
307327
async def test_memory_refund_skips_reservations_granted_by_another_worker():
308328
store = _in_memory_store()

0 commit comments

Comments
 (0)