fix(proxy): evict email-only member caches and reset team members metric on delete

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Devin AI 2026-09-24 17:46:18 +00:00
parent 0a023a4e1a
commit 387cf21fff
2 changed files with 45 additions and 6 deletions

View file

@ -4548,10 +4548,12 @@ async def delete_team(
)
await _invalidate_deleted_team_member_cache(
teams=team_rows,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
for deleted_team in team_rows:
_emit_team_members_metric(deleted_team.model_copy(update={"members_with_roles": []}))
await sync_team_access_group_membership(prisma_client=prisma_client, team_id=deleted_team.team_id)
return deleted_teams
@ -4628,16 +4630,23 @@ async def _invalidate_deleted_team_cache(
async def _invalidate_deleted_team_member_cache(
teams: Sequence[LiteLLM_TeamTable],
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> None:
for team in teams:
await _evict_deleted_team_member_cache(team=team, user_api_key_cache=user_api_key_cache)
await _evict_deleted_team_member_cache(
team=team,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
)
async def _evict_deleted_team_member_cache(team: LiteLLM_TeamTable, user_api_key_cache: UserApiKeyCache) -> None:
member_user_ids: Final = tuple(
sorted({member.user_id for member in team.members_with_roles if member.user_id is not None})
)
async def _evict_deleted_team_member_cache(
team: LiteLLM_TeamTable,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
) -> None:
member_user_ids: Final = await _deleted_team_member_user_ids(team=team, prisma_client=prisma_client)
await evict_and_broadcast(cache_keys=member_user_ids, user_api_key_cache=user_api_key_cache)
await asyncio.gather(
*(
@ -4651,6 +4660,25 @@ async def _evict_deleted_team_member_cache(team: LiteLLM_TeamTable, user_api_key
)
async def _deleted_team_member_user_ids(team: LiteLLM_TeamTable, prisma_client: PrismaClient) -> tuple[str, ...]:
roster_user_ids: Final = frozenset(
member.user_id for member in team.members_with_roles if member.user_id is not None
)
email_only_member_emails: Final = sorted(
{
member.user_email
for member in team.members_with_roles
if member.user_id is None and member.user_email is not None
}
)
if not email_only_member_emails:
return tuple(sorted(roster_user_ids))
email_only_users: Final = await _user_db(prisma_client).find_many(
where={"user_email": {"in": email_only_member_emails, "mode": "insensitive"}}
)
return tuple(sorted(roster_user_ids.union(user.user_id for user in email_only_users)))
def _transform_teams_to_deleted_records(
teams: list[LiteLLM_TeamTable],
user_api_key_dict: UserAPIKeyAuth,

View file

@ -9032,7 +9032,8 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
team = LiteLLM_TeamTable(
team_id="team-doomed",
team_alias="doomed-team",
members_with_roles=[Member(user_id=user_id, role="user") for user_id in member_user_ids],
members_with_roles=[Member(user_id=user_id, role="user") for user_id in member_user_ids]
+ [Member(user_id=None, user_email="invitee@example.com", role="user")],
metadata={},
model_max_budget={},
model_spend={},
@ -9046,6 +9047,9 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
mock_prisma_client.db.execute_raw = AsyncMock()
mock_prisma_client.db.litellm_teammembership.delete_many = AsyncMock()
mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(
return_value=[SimpleNamespace(user_id="invited-user", user_email="invitee@example.com")]
)
mock_tx = AsyncMock()
mock_tx.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
@ -9058,6 +9062,7 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
fresh_cache = UserApiKeyCache()
for user_id in member_user_ids:
fresh_cache.set_cache(key=user_id, value=UserAPIKeyAuth(user_id=user_id))
fresh_cache.set_cache(key="invited-user", value=UserAPIKeyAuth(user_id="invited-user"))
fresh_cache.set_cache(key="bystander-user", value=UserAPIKeyAuth(user_id="bystander-user"))
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
@ -9085,6 +9090,12 @@ async def test_delete_team_evicts_member_caches_with_one_transaction(
assert fresh_cache.get_cache(key=user_id) is None, (
f"member {user_id}'s cached user object survived the team delete"
)
assert fresh_cache.get_cache(key="invited-user") is None, (
"the email-only roster entry resolves to invited-user, whose cached user object must be evicted too"
)
mock_prisma_client.db.litellm_usertable.find_many.assert_awaited_once_with(
where={"user_email": {"in": ["invitee@example.com"], "mode": "insensitive"}}
)
assert fresh_cache.get_cache(key="bystander-user") is not None