Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 71 additions & 19 deletions litellm/proxy/db/spend_counter_reseed.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
deployments. This module reseeds from the authoritative DB instead.

A per-counter singleflight lock collapses concurrent reseeds on the same pod
to one DB query per cold-cache window. The lock dict is bounded LRU to cap
memory in long-lived deployments.
to one DB query per cold-cache window. The lock registry retains active locks
and bounds idle entries with LRU eviction in long-lived deployments.
"""

import asyncio
from collections import OrderedDict
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar, Optional

Expand All @@ -36,6 +39,12 @@
from litellm.proxy.utils import PrismaClient


@dataclass
class _CounterLock:
lock: asyncio.Lock
users: int = 0


class SpendCounterReseed:
"""
Reseeds spend counters from the authoritative DB and warms the cache,
Expand All @@ -53,23 +62,60 @@ class SpendCounterReseed:
and get_tag_objects_batch(); callers pass those values as fallback_spend.
"""

_locks: ClassVar["OrderedDict[str, asyncio.Lock]"] = OrderedDict()
_locks: ClassVar["OrderedDict[str, _CounterLock]"] = OrderedDict()
_registry_lock: ClassVar[Optional[asyncio.Lock]] = None

@staticmethod
async def _get_lock(counter_key: str) -> asyncio.Lock:
def _get_registry_lock() -> asyncio.Lock:
if SpendCounterReseed._registry_lock is None:
SpendCounterReseed._registry_lock = asyncio.Lock()
async with SpendCounterReseed._registry_lock:
lock = SpendCounterReseed._locks.get(counter_key)
if lock is not None:
SpendCounterReseed._locks.move_to_end(counter_key)
return lock
lock = asyncio.Lock()
SpendCounterReseed._locks[counter_key] = lock
if len(SpendCounterReseed._locks) > SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE:
SpendCounterReseed._locks.popitem(last=False)
return lock
return SpendCounterReseed._registry_lock

@staticmethod
def _get_or_create_lock(counter_key: str) -> _CounterLock:
counter_lock = SpendCounterReseed._locks.get(counter_key)
if counter_lock is None:
counter_lock = _CounterLock(lock=asyncio.Lock())
SpendCounterReseed._locks[counter_key] = counter_lock
SpendCounterReseed._locks.move_to_end(counter_key)
return counter_lock

@staticmethod
def _prune_idle_locks() -> None:
while len(SpendCounterReseed._locks) > SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE:
idle_key = next(
(
key
for key, counter_lock in SpendCounterReseed._locks.items()
if counter_lock.users == 0 and not counter_lock.lock.locked()
),
Comment on lines +86 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid scanning every active lock under registry pressure

When more distinct counters are active than SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE (especially when operators lower the configurable cap), every acquisition and release scans the ordered registry while holding the event-loop registry lock; if all entries are active, each scan traverses the entire collection. A burst of slow cold reseeds therefore causes quadratic Python work and blocks unrelated counter operations on the same event loop; track idle entries separately or maintain eviction eligibility without a full scan

Useful? React with 👍 / 👎.

None,
)
if idle_key is None:
return
SpendCounterReseed._locks.pop(idle_key)

@staticmethod
async def _get_lock(counter_key: str) -> asyncio.Lock:
async with SpendCounterReseed._get_registry_lock():
counter_lock = SpendCounterReseed._get_or_create_lock(counter_key)
SpendCounterReseed._prune_idle_locks()
return counter_lock.lock

@staticmethod
@asynccontextmanager
async def _counter_lock(counter_key: str) -> AsyncIterator[None]:
async with SpendCounterReseed._get_registry_lock():
counter_lock = SpendCounterReseed._get_or_create_lock(counter_key)
counter_lock.users += 1
Comment on lines +109 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep the lock usage metadata immutable

Each lock acquisition now mutates the shared _CounterLock.users instance in place, with a matching decrement later, despite the imported repository guideline explicitly prohibiting mutation and recommending frozen dataclasses. Represent active usage without mutating a shared dataclass so this new synchronization state follows the repository's required convention

AGENTS.md reference: AGENTS.md:L1-L1

Useful? React with 👍 / 👎.

try:
lock = await SpendCounterReseed._get_lock(counter_key)
async with lock:
yield
finally:
async with SpendCounterReseed._get_registry_lock():
counter_lock.users -= 1
SpendCounterReseed._prune_idle_locks()

@staticmethod
async def from_db(prisma_client: Optional["PrismaClient"], counter_key: str) -> Optional[float]:
Expand Down Expand Up @@ -152,8 +198,7 @@ async def coalesced(
Returns the spend value (including 0.0 from a fresh budget reset)
when the DB read succeeds, or None when the DB is unavailable.
"""
lock = await SpendCounterReseed._get_lock(counter_key)
async with lock:
async with SpendCounterReseed._counter_lock(counter_key):
# Re-check after acquiring the lock. Skip in-memory on a clean
# Redis miss - in-memory is per-pod-stale.
redis_clean_miss = False
Expand Down Expand Up @@ -197,7 +242,15 @@ async def coalesced(
value=current_value,
)
else:
await spend_counter_cache.async_increment_cache(key=counter_key, value=db_spend, refresh_ttl=True)
cached = spend_counter_cache.in_memory_cache.get_cache(key=counter_key)
Comment thread
veria-ai[bot] marked this conversation as resolved.
if cached is not None:
current_value = float(cached)
Comment on lines +245 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve active locks during LRU eviction

When a no-Redis worker has a cold reseed waiting on the database and processes more than SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE distinct counters meanwhile, _get_lock() can evict the still-held lock; a subsequent increment for the original key then acquires a new lock and writes only the request cost. Once the DB read returns, this new cache check adopts that partial value and discards the historical DB spend, allowing the affected budget to be undercounted until expiry. Keep held or awaited locks non-evictable, or otherwise verify that the registry still maps the key to the active lock before treating this cached value as a serialized writer

Useful? React with 👍 / 👎.

else:
current_value = await spend_counter_cache.async_increment_cache(
key=counter_key,
value=db_spend,
refresh_ttl=True,
)
except Exception:
verbose_proxy_logger.exception(
"SpendCounterReseed.coalesced: failed to warm counter %s",
Expand Down Expand Up @@ -262,8 +315,7 @@ async def coalesced_window(
entity_id: str,
window_start: datetime,
) -> Optional[float]:
lock = await SpendCounterReseed._get_lock(counter_key)
async with lock:
async with SpendCounterReseed._counter_lock(counter_key):
redis_clean_miss = False
if spend_counter_cache.redis_cache is not None:
try:
Expand Down
53 changes: 25 additions & 28 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2248,24 +2248,25 @@ async def _repair_stale_spend_counter(counter_key: str, db_spend: float) -> None
in-memory copy is guarded by a read-compare-write with no await in between,
so it is atomic within the worker.
"""
cached = spend_counter_cache.in_memory_cache.get_cache(key=counter_key)
needs_update = True
if cached is not None:
try:
needs_update = float(cached) < db_spend
except (TypeError, ValueError):
needs_update = True
if needs_update:
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=db_spend)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_max(key=counter_key, value=db_spend)
except Exception:
verbose_proxy_logger.debug(
"Unable to repair stale spend counter %s in Redis",
counter_key,
exc_info=True,
)
async with SpendCounterReseed._counter_lock(counter_key):
cached = spend_counter_cache.in_memory_cache.get_cache(key=counter_key)
needs_update = True
if cached is not None:
try:
needs_update = float(cached) < db_spend
except (TypeError, ValueError):
needs_update = True
if needs_update:
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=db_spend)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_max(key=counter_key, value=db_spend)
except Exception:
verbose_proxy_logger.debug(
"Unable to repair stale spend counter %s in Redis",
counter_key,
exc_info=True,
)


async def reseed_spend_counter_from_db(counter_key: str) -> None:
Expand Down Expand Up @@ -2638,11 +2639,6 @@ async def _init_and_increment_spend_counter(
2. If not found, reseed from the DB via `SpendCounterReseed.coalesced`.
Falls back to the cached object's `.spend` via user_api_key_cache
only if prisma is unavailable, since that value can lag the flusher.
3. Seed counter via async_increment_cache (not async_set_cache) to avoid a
check-then-set race: if two pods cold-start simultaneously, both may see
the counter as absent and seed it. Using increment means the worst case
is over-counting (conservative, blocks slightly early) rather than
under-counting (would allow overspend).
4. Increment atomically (both in-memory + Redis)
"""
await _ensure_spend_counter_initialized(
Expand Down Expand Up @@ -2778,11 +2774,12 @@ async def _increment_spend_counter_cache(counter_key: str, increment: float):
)
return current_value

return await spend_counter_cache.async_increment_cache(
key=counter_key,
value=increment,
refresh_ttl=True,
)
async with SpendCounterReseed._counter_lock(counter_key):
return await spend_counter_cache.async_increment_cache(
key=counter_key,
value=increment,
refresh_ttl=True,
)


async def _invalidate_spend_counter(counter_key: str):
Expand Down
22 changes: 22 additions & 0 deletions tests/test_litellm/proxy/proxy_server/test_spend_counters.py
Original file line number Diff line number Diff line change
Expand Up @@ -1179,6 +1179,28 @@ async def test_increment_spend_counter_cache_redis_error_raises_and_invalidates(
assert fake_cache.redis_cache.async_delete_cache.called is True


@pytest.mark.asyncio
async def test_increment_spend_counter_cache_no_redis_uses_in_memory_increment(
monkeypatch,
):
fake_cache = _make_spend_counter_cache(
redis_increment_value=44.0,
with_redis=False,
)
monkeypatch.setattr(ps, "spend_counter_cache", fake_cache)

result = await ps._increment_spend_counter_cache(
counter_key="spend:key:k", increment=4.0
)

assert result == 44.0
fake_cache.async_increment_cache.assert_awaited_once_with(
key="spend:key:k",
value=4.0,
refresh_ttl=True,
)


# ---------------------------------------------------------------------------
# _invalidate_spend_counter
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading