mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
perf(auth): negative-cache missing team membership rows
The session-token grant refresh reads get_team_membership on every request. A member with no LiteLLM_TeamMembership row (the common lite-login case) returned None without caching, so every request re-queried the DB and defeated the auth cache. Cache the miss under a plain-string sentinel with the management-object TTL, mirroring the MCP no-permission sentinel. All three readers of the key already treat a non-model value as no row, and team_member_update already evicts it, so a newly-created per-member budget is picked up on the next request.
This commit is contained in:
parent
ed90ff4a39
commit
ae382dd7e4
3 changed files with 119 additions and 4 deletions
|
|
@ -72,6 +72,7 @@ from litellm.proxy.auth.budget_throttle import (
|
|||
)
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import publish_auth_cache_invalidation
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
_safe_get_request_headers,
|
||||
_safe_get_request_query_params,
|
||||
|
|
@ -80,6 +81,7 @@ from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
|||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
END_USER_RESTRICTED_REGISTRY_OVERFLOW_SENTINEL,
|
||||
MODEL_ACCESS_GROUP_REGISTRY_OVERFLOW_SENTINEL,
|
||||
NO_TEAM_MEMBERSHIP_SENTINEL,
|
||||
TAG_REGISTRY_OVERFLOW_SENTINEL,
|
||||
UserApiKeyCache,
|
||||
end_user_cache_key,
|
||||
|
|
@ -2150,10 +2152,10 @@ async def get_team_membership(
|
|||
_key: Final = team_membership_reservation_cache_key(user_id=user_id, team_id=team_id)
|
||||
|
||||
# check if in cache
|
||||
cached_membership_obj: Final = await user_api_key_cache.async_get_cache(
|
||||
key=_key,
|
||||
model_type=LiteLLM_TeamMembership,
|
||||
)
|
||||
cached: Final[object] = await user_api_key_cache.async_get_cache(key=_key)
|
||||
if cached == NO_TEAM_MEMBERSHIP_SENTINEL:
|
||||
return None
|
||||
cached_membership_obj: Final = CacheCodec.deserialize(cached, model_type=LiteLLM_TeamMembership)
|
||||
if cached_membership_obj is not None:
|
||||
return cached_membership_obj
|
||||
|
||||
|
|
@ -2165,6 +2167,11 @@ async def get_team_membership(
|
|||
)
|
||||
|
||||
if response is None:
|
||||
await user_api_key_cache.async_set_cache(
|
||||
key=_key,
|
||||
value=NO_TEAM_MEMBERSHIP_SENTINEL,
|
||||
ttl=get_management_object_ttl(user_api_key_cache),
|
||||
)
|
||||
return None
|
||||
|
||||
_response: Final = LiteLLM_TeamMembership.model_validate(response.dict())
|
||||
|
|
|
|||
|
|
@ -246,6 +246,14 @@ def team_membership_reservation_cache_key(user_id: str, team_id: str) -> str:
|
|||
return f"team_membership:{user_id}:{team_id}"
|
||||
|
||||
|
||||
#: Cached under ``team_membership_reservation_cache_key`` when a member has no ``LiteLLM_TeamMembership``
|
||||
#: row, so a session-token member without a per-member budget costs no DB read per request. Lives beside
|
||||
#: the key builder because it is part of the same cache protocol: every reader of the key must know that
|
||||
#: a plain string here means "no row", distinct from a serialized membership. The two budget readers
|
||||
#: already treat a non-model value as "no row", so they need no change to stay correct.
|
||||
NO_TEAM_MEMBERSHIP_SENTINEL: Final = "__no_team_membership__"
|
||||
|
||||
|
||||
def get_management_object_ttl(cache: DualCache) -> float:
|
||||
"""
|
||||
In-memory TTL for management-object cache writes (keys, teams, users, budgets, ...).
|
||||
|
|
|
|||
|
|
@ -6214,6 +6214,106 @@ async def test_get_team_membership_db_fetch_returns_validated_membership():
|
|||
assert result.spend == 1.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_membership_negative_caches_a_missing_row():
|
||||
"""
|
||||
Regression (LIT-7358): a member with no LiteLLM_TeamMembership row is the common lite-login case,
|
||||
and the session-token refresh reads this loader on every request. Before the fix a missing row
|
||||
returned None without caching, so every request re-queried the DB. The miss must be cached so the
|
||||
second request serves from cache and never touches the DB.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
NO_TEAM_MEMBERSHIP_SENTINEL,
|
||||
team_membership_reservation_cache_key,
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
cache = UserApiKeyCache()
|
||||
|
||||
first = await get_team_membership(
|
||||
user_id="u-1", team_id="t-1", prisma_client=mock_prisma_client, user_api_key_cache=cache
|
||||
)
|
||||
second = await get_team_membership(
|
||||
user_id="u-1", team_id="t-1", prisma_client=mock_prisma_client, user_api_key_cache=cache
|
||||
)
|
||||
|
||||
assert first is None
|
||||
assert second is None
|
||||
mock_prisma_client.db.litellm_teammembership.find_unique.assert_awaited_once()
|
||||
cached = await cache.async_get_cache(
|
||||
key=team_membership_reservation_cache_key(user_id="u-1", team_id="t-1")
|
||||
)
|
||||
assert cached == NO_TEAM_MEMBERSHIP_SENTINEL
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_membership_reads_sentinel_as_no_membership_not_a_model():
|
||||
"""
|
||||
The negative-cache sentinel is a plain string sharing the key a serialized membership uses.
|
||||
A pre-seeded sentinel must read back as None (no DB read), never be mistaken for a membership.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
NO_TEAM_MEMBERSHIP_SENTINEL,
|
||||
team_membership_reservation_cache_key,
|
||||
)
|
||||
|
||||
cache = UserApiKeyCache()
|
||||
await cache.async_set_cache(
|
||||
key=team_membership_reservation_cache_key(user_id="u-1", team_id="t-1"),
|
||||
value=NO_TEAM_MEMBERSHIP_SENTINEL,
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(return_value=None)
|
||||
|
||||
result = await get_team_membership(
|
||||
user_id="u-1", team_id="t-1", prisma_client=mock_prisma_client, user_api_key_cache=cache
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_prisma_client.db.litellm_teammembership.find_unique.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalidate_team_member_spend_state_evicts_the_negative_cache_sentinel():
|
||||
"""
|
||||
A member who later gains a per-member budget writes a membership row and calls
|
||||
invalidate_team_member_spend_state. That must drop a cached "no membership" sentinel so the next
|
||||
request re-reads the DB and honors the new budget instead of serving the stale miss until TTL.
|
||||
"""
|
||||
from litellm.proxy.auth.auth_checks import get_team_membership, invalidate_team_member_spend_state
|
||||
from litellm.proxy.common_utils.user_api_key_cache import team_membership_reservation_cache_key
|
||||
|
||||
cache = UserApiKeyCache()
|
||||
membership_row = MagicMock()
|
||||
membership_row.dict = lambda: {"user_id": "u-1", "team_id": "t-1", "spend": 0.0}
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db.litellm_teammembership.find_unique = AsyncMock(side_effect=[None, membership_row])
|
||||
|
||||
before = await get_team_membership(
|
||||
user_id="u-1", team_id="t-1", prisma_client=mock_prisma_client, user_api_key_cache=cache
|
||||
)
|
||||
assert before is None
|
||||
|
||||
await invalidate_team_member_spend_state(user_id="u-1", team_id="t-1", user_api_key_cache=cache)
|
||||
assert (
|
||||
await cache.async_get_cache(key=team_membership_reservation_cache_key(user_id="u-1", team_id="t-1"))
|
||||
is None
|
||||
)
|
||||
|
||||
after = await get_team_membership(
|
||||
user_id="u-1", team_id="t-1", prisma_client=mock_prisma_client, user_api_key_cache=cache
|
||||
)
|
||||
assert after is not None
|
||||
assert after.user_id == "u-1"
|
||||
assert mock_prisma_client.db.litellm_teammembership.find_unique.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_access_object_db_fetch_returns_validated_access_group():
|
||||
from litellm.proxy._types import LiteLLM_AccessGroupTable
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue