fix(proxy): reset end-user spend in committed pages so the budget cascade survives a large LiteLLM_EndUserTable (#46502)

* fix(proxy): reset end-user spend in committed pages so the budget cascade survives large LiteLLM_EndUserTable

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style(proxy): collapse _enduser_ids_by_tier return to one line for ruff format

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): mark the end-user page IN list as bounded for the unbounded-IN check

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

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-15 19:47:12 -07:00 • committed by GitHub
parent dba86bec28
commit 081cfa2a62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 315 additions and 112 deletions

View file

@ -6,6 +6,7 @@ from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from itertools import chain
from types import MappingProxyType
from typing import Final, Generic, Literal, Protocol, TypeVar
@ -65,6 +66,7 @@ from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.unit_of_work import (
LinkedSpendResetWrites,
budget_cascade_unit_of_work,
enduser_spend_reset_unit_of_work,
spend_reset_unit_of_work,
)
from litellm.repositories.verification_token_repository import (
@ -222,8 +224,8 @@ def _budget_link_where(
def _enduser_invalidation_where(budget_ids: Sequence[str]) -> dict[str, object]:
"""Customers whose cached spend a committed reset of these tiers invalidated.
Mirrors ``_queue_enduser_resets`` without its ``spend > 0`` filter, which
post-commit would match nobody.
``_enduser_reset_where`` adds the ``spend > 0`` filter, which post-commit
would match nobody.
"""
linked: Final = _budget_link_where(budget_ids)
default_budget_id: Final = litellm.max_end_user_budget_id
@ -251,26 +253,44 @@ def _queue_budget_linked_resets(
writes.queue_spend_zero(where=_budget_link_where(plain_ids, extra))
def _queue_enduser_resets(writes: LinkedSpendResetWrites, cascade: "_BudgetCascade") -> None:
"""End users reset on the budget link like every other gated table, plus a
NULL-budget_id branch: rows created implicitly persist no link and ride the
default tier (litellm.max_end_user_budget_id).
def _enduser_reset_where(cascade: "_BudgetCascade") -> Mapping[str, object]:
"""End users with spend to reset: linked to an expiring tier, or carrying
no link and riding the default tier (litellm.max_end_user_budget_id)."""
return {**_enduser_invalidation_where(cascade.budget_ids), **_SPENT_ROWS_WHERE}
Matching on the link rather than enumerating user ids keeps a statement's
bind count proportional to the expiring tiers instead of the customer
population, which past ~32,700 dependents exceeds PostgreSQL's per-statement
bind ceiling and wedges the cascade permanently (#40564).
def _enduser_tier(row: _EndUserRow) -> str | None:
return row.budget_id if row.budget_id is not None else litellm.max_end_user_budget_id
def _enduser_ids_by_tier(rows: Sequence[_EndUserRow]) -> Mapping[str | None, tuple[str, ...]]:
tiers: Final = tuple(dict.fromkeys(_enduser_tier(row) for row in rows))
return MappingProxyType({tier: tuple(row.user_id for row in rows if _enduser_tier(row) == tier) for tier in tiers})
def _queue_enduser_page_resets(
writes: LinkedSpendResetWrites, rows: Sequence[_EndUserRow], cascade: "_BudgetCascade"
) -> None:
"""The statements of ``_queue_budget_linked_resets`` for one page of end
users, keyed by their user ids so each statement touches at most one page.
A statement matching the budget link reads the whole end-user table (no
index on budget_id) and updates every linked row at once, which past a few
million end users outlives the prisma client's 30s request timeout (#46404).
A page of ids stays under PostgreSQL's per-statement bind ceiling (#40564)
and the spend predicates still classify each row at write time.
"""
_queue_budget_linked_resets(writes, cascade, extra=_SPENT_ROWS_WHERE)
default_budget_id: Final = litellm.max_end_user_budget_id
if default_budget_id is None or default_budget_id not in cascade.budget_ids:
return
cap: Final = cascade.rollover_caps.get(default_budget_id)
for tier, user_ids in _enduser_ids_by_tier(rows).items():
_queue_enduser_tier_resets(writes, user_ids, cap=cascade.rollover_caps.get(tier) if tier is not None else None)
def _queue_enduser_tier_resets(writes: LinkedSpendResetWrites, user_ids: Sequence[str], cap: float | None) -> None:
page: Final = {"user_id": {"in": list(user_ids)}} # bounded-ok: one page of RESET_BUDGET_JOB_BATCH_SIZE ids
if cap is None:
writes.queue_spend_zero(where={"budget_id": None, **_SPENT_ROWS_WHERE})
writes.queue_spend_zero(where={**page, **_SPENT_ROWS_WHERE})
return
writes.queue_spend_zero(where={"budget_id": None, "spend": {"gt": 0, "lte": cap}})
writes.queue_spend_decrement(where={"budget_id": None, "spend": {"gt": cap}}, amount=cap)
writes.queue_spend_zero(where={**page, "spend": {"gt": 0, "lte": cap}})
writes.queue_spend_decrement(where={**page, "spend": {"gt": cap}}, amount=cap)
@dataclass(frozen=True, slots=True)
@ -824,6 +844,49 @@ class ResetBudgetJob:
),
)
async def _reset_enduser_spend(self, cascade: _BudgetCascade) -> int:
"""Reset the end users the expiring tiers gate, one committed page at a time.
Runs before ``_commit_budget_cascade`` so a failure here leaves the
tiers due: the next tick resumes with the pages still carrying spend.
Each page invalidates its own caches right after its commit, so no
zeroed row sits behind a cached spend that still rejects requests.
Capless like ``_invalidate_enduser_caches``, for its reason.
"""
if not cascade.budget_ids:
return 0
where: Final = _enduser_reset_where(cascade)
walk = _EndUserWalk() # rebind-ok: keyset cursor advances one committed page at a time
while walk.cursor is not None:
walk = await self._reset_enduser_page(cascade, where=where, cursor=walk.cursor, reached=walk.invalidated)
return walk.invalidated
async def _reset_enduser_page(
self, cascade: _BudgetCascade, where: Mapping[str, object], cursor: str, reached: int
) -> _EndUserWalk:
rows: Final = await self._fetch_enduser_page(where=where, cursor=cursor)
if not rows:
return _EndUserWalk(cursor=None, invalidated=reached)
await self._with_db_write_retry(
lambda: self._commit_enduser_page(rows, cascade),
reason="reset_budget_write_endusers_failure",
)
await self._invalidate_caches(
counter_keys=tuple(_enduser_counter_key(row) for row in rows),
cache_keys=tuple(chain.from_iterable(_enduser_cache_keys(row) for row in rows)),
)
walked: Final = reached + len(rows)
if len(rows) < RESET_BUDGET_JOB_BATCH_SIZE:
return _EndUserWalk(cursor=None, invalidated=walked)
return _EndUserWalk(cursor=rows[-1].user_id, invalidated=walked)
async def _commit_enduser_page(self, rows: Sequence[_EndUserRow], cascade: _BudgetCascade) -> None:
async with (
db_span("reset_budget_endusers", "LiteLLM_EndUserTable"),
enduser_spend_reset_unit_of_work(self._new_batch) as writes,
):
_queue_enduser_page_resets(writes, rows, cascade)
async def _commit_budget_cascade(self, cascade: _BudgetCascade) -> None:
"""Zero the gated spend and advance ``budget_reset_at`` in one transaction.
@ -851,7 +914,6 @@ class ResetBudgetJob:
_queue_budget_linked_resets(uow.tags, cascade, extra=_SPENT_ROWS_WHERE)
_queue_budget_linked_resets(uow.model_access_groups, cascade, extra=_SPENT_ROWS_WHERE)
_queue_budget_linked_resets(uow.projects, cascade, extra=_SPENT_ROWS_WHERE)
_queue_enduser_resets(uow.endusers, cascade)
for budget_id, budget_reset_at in cascade.budget_resets:
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
@ -878,6 +940,7 @@ class ResetBudgetJob:
return _BudgetCascadeFailed(cascade=_EMPTY_CASCADE, error=e)
try:
await self._reset_enduser_spend(cascade)
await self._commit_budget_cascade(cascade)
except Exception as e:
return _BudgetCascadeFailed(cascade=cascade, error=e)
@ -896,9 +959,10 @@ class ResetBudgetJob:
"""
Resets the spend a budget tier gates (end users, team members, keys,
orgs, tags, model access groups) and advances the tier's
budget_reset_at, atomically.
budget_reset_at. End users commit in pages first, everything else
lands with the window advance in one transaction.
Caches are invalidated only after the transaction commits, so a failed
Caches are invalidated only after the commit they follow, so a failed
run cannot leave a zeroed counter in front of an un-reset DB row.
"""
await _run_phase_in_chunks(self._reset_budget_for_litellm_budget_table_chunk)
@ -929,8 +993,8 @@ class ResetBudgetJob:
case _BudgetCascadeFailed(cascade=cascade, error=error):
verbose_proxy_logger.exception(
"Failed to reset the budget table cascade (team member, enduser, org, tag and model access "
"group spend, plus budget_reset_at); nothing was committed and the budgets stay due for the "
"next run: %s",
"group spend, plus budget_reset_at); the budgets stay due for the next run, which resumes "
"with the end-user pages not committed yet: %s",
error,
exc_info=error,
)

View file

@ -97,11 +97,13 @@ class SpendResetUnitOfWork:
@dataclass(frozen=True, slots=True)
class BudgetCascadeUnitOfWork:
"""Every write a budget-tier reset performs, bound to one batch.
"""Every write a budget-tier reset performs in its final batch.
The dependent spend rows and the budget rows' ``budget_reset_at`` advance
must land together: advancing the window without zeroing the spend it
gates leaves the dependents pinned at their cap until the next window.
End users are the exception, paged and committed ahead of this batch by
``enduser_spend_reset_unit_of_work`` because one tier can gate millions.
"""
team_memberships: LinkedSpendResetWrites
@ -110,7 +112,6 @@ class BudgetCascadeUnitOfWork:
tags: LinkedSpendResetWrites
model_access_groups: LinkedSpendResetWrites
projects: LinkedSpendResetWrites
endusers: LinkedSpendResetWrites
budgets: BudgetWindowWrites
@ -137,7 +138,17 @@ async def budget_cascade_unit_of_work(
tags=LinkedSpendResetWrites(table=batch.litellm_tagtable),
model_access_groups=LinkedSpendResetWrites(table=batch.litellm_modelaccessgroupbudgettable),
projects=LinkedSpendResetWrites(table=batch.litellm_projecttable),
endusers=LinkedSpendResetWrites(table=batch.litellm_endusertable),
budgets=BudgetWindowWrites(table=batch.litellm_budgettable),
)
await batch.commit()
@asynccontextmanager
async def enduser_spend_reset_unit_of_work(
new_batch: Callable[[], PrismaBatch],
) -> AsyncGenerator[LinkedSpendResetWrites, None]:
"""One page of end-user spend resets, committed on its own so a statement
never grows with the end-user population."""
batch: Final = new_batch()
yield LinkedSpendResetWrites(table=batch.litellm_endusertable)
await batch.commit()

View file

@ -435,7 +435,7 @@ def test_reset_budget_for_team(reset_budget_job, mock_prisma_client):
def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
"""End-user spend is zeroed and the tier's window advances, in one batch."""
"""End-user spend is zeroed in its own committed page, then the tier's window advances."""
now = datetime.now(timezone.utc)
test_budget = _budget_row(budget_id="test-budget-1", budget_duration="1d", budget_reset_at=now)
@ -446,11 +446,12 @@ def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
"spend": 20.0,
"litellm_budget_table": test_budget,
"user_id": "test-enduser-1",
"budget_id": "test-budget-1",
},
)
mock_prisma_client.data["budget"] = [test_budget]
mock_prisma_client.data["enduser"] = [test_enduser]
mock_prisma_client.db.litellm_endusertable.set_find_many_results([test_enduser])
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
@ -458,10 +459,13 @@ def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
{
"table": "enduser",
"op": "update_many",
"where": {"budget_id": {"in": ["test-budget-1"]}, "spend": {"gt": 0}},
"where": {"user_id": {"in": ["test-enduser-1"]}, "spend": {"gt": 0}},
"data": {"spend": 0},
}
]
assert [batcher.committed for batcher in mock_prisma_client.db.batchers] == [True, True]
assert [call["table"] for call in mock_prisma_client.db.batchers[0].calls] == ["enduser"]
assert "enduser" not in {call["table"] for call in mock_prisma_client.db.batchers[1].calls}
budget_writes = _batch_writes(mock_prisma_client, "budget")
assert len(budget_writes) == 1
@ -470,6 +474,112 @@ def test_reset_budget_for_enduser(reset_budget_job, mock_prisma_client):
assert set(budget_writes[0]["data"].keys()) == {"budget_reset_at"}
def test_enduser_reset_commits_in_pages_before_the_window_advances(reset_budget_job, mock_prisma_client):
"""Regression for #46404: one statement over every end user a tier gates
reads the whole table and outlives the prisma client's 30s request timeout
once the table holds millions of rows, so the batch rolls back and
budget_reset_at never moves. The reset has to land one committed page at a
time, keyed by user id, with the window advance after the last page."""
mock_prisma_client.data["budget"] = [_budget_row(budget_id="shared-tier", budget_duration="1d")]
population: Final = RESET_BUDGET_JOB_BATCH_SIZE * 2 + 3
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[
types.SimpleNamespace(spend=4.0, user_id=f"cust-{index:06d}", budget_id="shared-tier")
for index in range(population)
]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
batchers: Final = mock_prisma_client.db.batchers
assert [batcher.committed for batcher in batchers] == [True, True, True, True]
pages: Final = batchers[:3]
assert [[call["table"] for call in page.calls] for page in pages] == [["enduser"]] * 3
assert [len(page.calls[0]["where"]["user_id"]["in"]) for page in pages] == [
RESET_BUDGET_JOB_BATCH_SIZE,
RESET_BUDGET_JOB_BATCH_SIZE,
3,
]
assert [page.calls[0]["where"]["spend"] for page in pages] == [{"gt": 0}] * 3
assert [page.calls[0]["data"] for page in pages] == [{"spend": 0}] * 3
assert pages[0].calls[0]["where"]["user_id"]["in"][0] == "cust-000000"
assert pages[2].calls[0]["where"]["user_id"]["in"] == ["cust-001000", "cust-001001", "cust-001002"]
cascade: Final = batchers[3]
assert "enduser" not in {call["table"] for call in cascade.calls}
assert [call["where"] for call in cascade.calls if call["table"] == "budget"] == [{"budget_id": "shared-tier"}]
def test_enduser_page_failure_leaves_the_tier_due(reset_budget_job, mock_prisma_client):
"""A page that fails after earlier pages committed must not advance the
window: the committed pages are at spend 0 and drop out of the next tick's
``spend > 0`` read, so the next tick resumes where this one stopped."""
mock_prisma_client.data["budget"] = [_budget_row(budget_id="shared-tier", budget_duration="1d")]
endusers: Final = mock_prisma_client.db.litellm_endusertable
endusers.set_find_many_results(
[
types.SimpleNamespace(spend=4.0, user_id=f"cust-{index:06d}", budget_id="shared-tier")
for index in range(RESET_BUDGET_JOB_BATCH_SIZE + 3)
]
)
endusers.set_find_many_error(1, RuntimeError("connection reset while paging end users"))
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
assert _batch_writes(mock_prisma_client, "budget") == []
enduser_writes: Final = _batch_writes(mock_prisma_client, "enduser")
assert len(enduser_writes) == 1
assert len(enduser_writes[0]["where"]["user_id"]["in"]) == RESET_BUDGET_JOB_BATCH_SIZE
def test_enduser_page_keeps_each_tier_s_rollover_statements_to_its_own_users(
rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch
):
"""One page can hold end users of several expiring tiers. Each tier's
zero-then-decrement pair must name only that tier's user ids and its own
cap, or a 15.0 spend on a 20.0 tier would be carried as 5.0."""
_make_counter_invalidation_job(monkeypatch)
mock_prisma_client.data["budget"] = [
_budget_row(budget_id="tier-10", budget_duration="1d", max_budget=10.0),
_budget_row(budget_id="tier-20", budget_duration="1d", max_budget=20.0),
]
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[
types.SimpleNamespace(spend=15.0, user_id="cust-a", budget_id="tier-10"),
types.SimpleNamespace(spend=15.0, user_id="cust-b", budget_id="tier-20"),
types.SimpleNamespace(spend=25.0, user_id="cust-c", budget_id="tier-10"),
]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
assert _batch_writes(mock_prisma_client, "enduser") == [
{
"table": "enduser",
"op": "update_many",
"where": {"user_id": {"in": ["cust-a", "cust-c"]}, "spend": {"gt": 0, "lte": 10.0}},
"data": {"spend": 0},
},
{
"table": "enduser",
"op": "update_many",
"where": {"user_id": {"in": ["cust-a", "cust-c"]}, "spend": {"gt": 10.0}},
"data": {"spend": {"decrement": 10.0}},
},
{
"table": "enduser",
"op": "update_many",
"where": {"user_id": {"in": ["cust-b"]}, "spend": {"gt": 0, "lte": 20.0}},
"data": {"spend": 0},
},
{
"table": "enduser",
"op": "update_many",
"where": {"user_id": {"in": ["cust-b"]}, "spend": {"gt": 20.0}},
"data": {"spend": {"decrement": 20.0}},
},
]
def test_reset_budget_all(reset_budget_job, mock_prisma_client):
# Setup test data with timezone-aware datetime
now = datetime.now(timezone.utc)
@ -529,6 +639,7 @@ def test_reset_budget_all(reset_budget_job, mock_prisma_client):
"spend": 20.0,
"litellm_budget_table": test_budget,
"user_id": "test-enduser-1",
"budget_id": "test-budget-1",
},
)
@ -536,7 +647,7 @@ def test_reset_budget_all(reset_budget_job, mock_prisma_client):
mock_prisma_client.data["user"] = [test_user]
mock_prisma_client.data["team"] = [test_team]
mock_prisma_client.data["budget"] = [test_budget]
mock_prisma_client.data["enduser"] = [test_enduser]
mock_prisma_client.db.litellm_endusertable.set_find_many_results([test_enduser])
# Run the test
asyncio.run(reset_budget_job.reset_budget())
@ -559,7 +670,7 @@ def test_reset_budget_all(reset_budget_job, mock_prisma_client):
{
"table": "enduser",
"op": "update_many",
"where": {"budget_id": {"in": ["test-budget-1"]}, "spend": {"gt": 0}},
"where": {"user_id": {"in": ["test-enduser-1"]}, "spend": {"gt": 0}},
"data": {"spend": 0},
}
]
@ -579,7 +690,6 @@ _LINKED_TABLE_CASES = [
("org", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
("tag", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
("model_access_group", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
("enduser", {"budget_id": {"in": ["7d-budget-tier"]}, "spend": {"gt": 0}}),
]
@ -620,34 +730,33 @@ def _bind_count(where: Dict[str, Any]) -> int:
@pytest.mark.parametrize("population", [3, 40_000], ids=["small", "over-pg-bind-ceiling"])
def test_enduser_reset_bind_count_does_not_scale_with_population(reset_budget_job, mock_prisma_client, population):
"""Regression for #40564.
"""Regression for #40564 and #46404.
Enumerating every dependent user id put one bind variable per customer into
a single prepared statement. Past PostgreSQL's ceiling the statement could
not be parsed at all, so the whole atomic cascade rolled back,
budget_reset_at never advanced, and the tier stayed due on every later tick
forever. Matching on the budget link keeps the statement the same size no
matter how many customers share a tier.
Enumerating every dependent user id in one statement put one bind variable
per end user into a single prepared statement, which past PostgreSQL's
ceiling could not be parsed at all. Matching on the budget link instead made
one statement read and update the whole end-user table, which past a few
million rows outlives the prisma client's 30s request timeout. Paging by
user id keeps every statement at one page of binds however many end users
share a tier, and the statements have to cover the whole population.
"""
budget = _budget_row(budget_id="shared-tier", budget_duration="1d")
mock_prisma_client.data["budget"] = [budget]
mock_prisma_client.data["enduser"] = [
types.SimpleNamespace(
spend=1.0,
litellm_budget_table=budget,
user_id=f"cust-{index:08d}",
budget_id="shared-tier",
)
for index in range(population)
]
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[
types.SimpleNamespace(spend=1.0, user_id=f"cust-{index:08d}", budget_id="shared-tier")
for index in range(population)
]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
writes = _batch_writes(mock_prisma_client, "enduser")
assert [_bind_count(write["where"]) for write in writes] == [2], (
f"the cascade must not enumerate {population} user ids: past "
f"{_POSTGRES_MAX_BIND_VARIABLES} binds PostgreSQL refuses the statement, got {writes[:1]}"
)
assert max(_bind_count(write["where"]) for write in writes) <= RESET_BUDGET_JOB_BATCH_SIZE + 1
assert RESET_BUDGET_JOB_BATCH_SIZE + 1 < _POSTGRES_MAX_BIND_VARIABLES
assert sorted(user_id for write in writes for user_id in write["where"]["user_id"]["in"]) == [
f"cust-{index:08d}" for index in range(population)
]
assert _batch_writes(mock_prisma_client, "budget")[0]["data"]["budget_reset_at"] is not None
@ -778,6 +887,7 @@ def test_reset_budget_resets_endusers_with_null_budget_id(reset_budget_job, mock
"spend": 30.0,
"litellm_budget_table": test_budget,
"user_id": "enduser-explicit",
"budget_id": default_budget_id,
},
)
@ -811,40 +921,34 @@ def test_reset_budget_resets_endusers_with_null_budget_id(reset_budget_job, mock
)
mock_prisma_client.data["budget"] = [test_budget]
mock_prisma_client.data["enduser"] = [enduser_with_budget]
# Set up the DB mock for NULL-budget-id end users
mock_prisma_client.db.litellm_endusertable.set_find_many_results([enduser_no_budget_row])
mock_prisma_client.db.litellm_endusertable.set_find_many_results([enduser_with_budget, enduser_no_budget_row])
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
# Both end users are zeroed: the linked rows on the tier's budget_id, the
# implicit ones on the NULL branch that stands in for the default tier.
# Both end users are zeroed in one page: the linked row on the tier's
# budget_id and the implicit one whose NULL budget_id stands in for the
# default tier.
assert _batch_writes(mock_prisma_client, "enduser") == [
{
"table": "enduser",
"op": "update_many",
"where": {"budget_id": {"in": [default_budget_id]}, "spend": {"gt": 0}},
"data": {"spend": 0},
},
{
"table": "enduser",
"op": "update_many",
"where": {"budget_id": None, "spend": {"gt": 0}},
"where": {"user_id": {"in": ["enduser-explicit", "enduser-implicit"]}, "spend": {"gt": 0}},
"data": {"spend": 0},
},
]
# The post-commit invalidation walk covers both branches, so implicitly
# created customers on the default tier get their cached spend dropped too,
# and it is paged rather than reading the whole customer population.
# The paged reset and the post-commit invalidation walk both cover both
# branches, so implicitly created customers on the default tier are reset
# and get their cached spend dropped too.
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
assert len(find_many_calls) == 1
assert find_many_calls[0]["where"]["OR"] == [
{"budget_id": {"in": [default_budget_id]}},
{"budget_id": None},
]
assert find_many_calls[0]["take"] == RESET_BUDGET_JOB_BATCH_SIZE
assert len(find_many_calls) == 2
assert find_many_calls[0]["where"]["spend"] == {"gt": 0}
for call in find_many_calls:
assert call["where"]["OR"] == [
{"budget_id": {"in": [default_budget_id]}},
{"budget_id": None},
]
assert call["take"] == RESET_BUDGET_JOB_BATCH_SIZE
litellm.max_end_user_budget_id = None
@ -879,7 +983,8 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_configured(
# ride a default tier that is not expiring, so their spend stays put.
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
assert [call["where"] for call in find_many_calls] == [
{"budget_id": {"in": ["some-budget"]}, "user_id": {"gt": ""}}
{"budget_id": {"in": ["some-budget"]}, "spend": {"gt": 0}, "user_id": {"gt": ""}},
{"budget_id": {"in": ["some-budget"]}, "user_id": {"gt": ""}},
]
litellm.max_end_user_budget_id = None
@ -919,7 +1024,8 @@ def test_reset_budget_skips_null_budget_id_endusers_when_default_not_in_reset_li
# ride a default tier that is not expiring, so their spend stays put.
find_many_calls = mock_prisma_client.db.litellm_endusertable.find_many_calls
assert [call["where"] for call in find_many_calls] == [
{"budget_id": {"in": ["other-budget"]}, "user_id": {"gt": ""}}
{"budget_id": {"in": ["other-budget"]}, "spend": {"gt": 0}, "user_id": {"gt": ""}},
{"budget_id": {"in": ["other-budget"]}, "user_id": {"gt": ""}},
]
litellm.max_end_user_budget_id = None
@ -1671,7 +1777,8 @@ def test_enduser_invalidation_is_paged_and_batched(reset_budget_job, mock_prisma
on a large enough deployment rather than a slow tick. Awaiting one cache call
per customer makes the last customer wait out every customer ahead of it.
Both regress silently, so pin the page size, the strictly advancing cursor,
and one batched call per page.
and one batched call per page. The paged reset ahead of the commit
invalidates its pages too, so the cache calls count both walks.
"""
counter_cache: Final = _make_counter_invalidation_job(monkeypatch)
mock_prisma_client.data["budget"] = [_budget_row(budget_id="budget-1")]
@ -1685,7 +1792,9 @@ def test_enduser_invalidation_is_paged_and_batched(reset_budget_job, mock_prisma
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
reads: Final = mock_prisma_client.db.litellm_endusertable.find_many_calls
all_reads: Final = mock_prisma_client.db.litellm_endusertable.find_many_calls
reads: Final = [read for read in all_reads if "spend" not in read["where"]]
assert len(all_reads) == 6
assert [read["take"] for read in reads] == [RESET_BUDGET_JOB_BATCH_SIZE] * 3
assert [read["where"]["user_id"]["gt"] for read in reads] == [
"",
@ -1693,8 +1802,8 @@ def test_enduser_invalidation_is_paged_and_batched(reset_budget_job, mock_prisma
f"cust-{RESET_BUDGET_JOB_BATCH_SIZE * 2 - 1:06d}",
]
assert counter_cache.async_delete_cache_keys.await_count == 3
assert counter_cache.user_api_key_cache.async_delete_cache_keys.await_count == 3
assert counter_cache.async_delete_cache_keys.await_count == 6
assert counter_cache.user_api_key_cache.async_delete_cache_keys.await_count == 6
counter_cache.async_delete_cache.assert_not_called()
invalidated: Final = {
@ -1727,7 +1836,7 @@ def test_enduser_invalidation_reports_a_page_read_failure_instead_of_a_clean_fin
for i in range(RESET_BUDGET_JOB_BATCH_SIZE + 3)
]
)
endusers.set_find_many_error(1, RuntimeError("connection reset while paging customers"))
endusers.set_find_many_error(3, RuntimeError("connection reset while paging customers"))
logging_obj: Final = RecordingProxyLogging()
job: Final = ResetBudgetJob(proxy_logging_obj=logging_obj, prisma_client=mock_prisma_client)
@ -1985,19 +2094,22 @@ def test_budget_reset_at_is_not_advanced_when_the_cascade_fails(db_factory, monk
def test_budget_cascade_writes_land_in_a_single_transaction(reset_budget_job, mock_prisma_client, monkeypatch):
"""Dependent spend and the budget_reset_at advance ride one batch."""
"""Dependent spend and the budget_reset_at advance ride one batch, after
the end-user pages have committed on their own."""
_make_counter_invalidation_job(monkeypatch)
now = datetime.now(timezone.utc)
budget = _budget_row(budget_id="budget-1", budget_duration="7d")
mock_prisma_client.data["budget"] = [budget]
mock_prisma_client.data["enduser"] = [
type("EndUser", (), {"spend": 5.0, "litellm_budget_table": budget, "user_id": "enduser-1", "budget_id": "budget-1"})
]
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[type("EndUser", (), {"spend": 5.0, "user_id": "enduser-1", "budget_id": "budget-1"})]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
assert len(mock_prisma_client.db.batchers) == 1, "the cascade must not be split across transactions"
batcher = mock_prisma_client.db.batchers[0]
assert len(mock_prisma_client.db.batchers) == 2, "one end-user page, then the cascade"
enduser_page, batcher = mock_prisma_client.db.batchers
assert enduser_page.committed is True
assert {(call["table"], call["op"]) for call in enduser_page.calls} == {("enduser", "update_many")}
assert batcher.committed is True
assert {(call["table"], call["op"]) for call in batcher.calls} == {
("team_membership", "update_many"),
@ -2006,7 +2118,6 @@ def test_budget_cascade_writes_land_in_a_single_transaction(reset_budget_job, mo
("tag", "update_many"),
("model_access_group", "update_many"),
("project", "update_many"),
("enduser", "update_many"),
("budget", "update_many"),
}
budget_write = next(call for call in batcher.calls if call["table"] == "budget")
@ -3288,13 +3399,9 @@ def test_budget_cascade_carries_enduser_overage_when_rollover_enabled(
_make_counter_invalidation_job(monkeypatch)
budget = _budget_row(budget_id="budget-roll", budget_duration="1d", max_budget=10.0)
mock_prisma_client.data["budget"] = [budget]
mock_prisma_client.data["enduser"] = [
type(
"EndUser",
(),
{"spend": 15.0, "litellm_budget_table": budget, "user_id": "enduser-roll", "budget_id": "budget-roll"},
)
]
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[type("EndUser", (), {"spend": 15.0, "user_id": "enduser-roll", "budget_id": "budget-roll"})]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
@ -3302,13 +3409,13 @@ def test_budget_cascade_carries_enduser_overage_when_rollover_enabled(
assert {
"table": "enduser",
"op": "update_many",
"where": {"budget_id": "budget-roll", "spend": {"gt": 10.0}},
"where": {"user_id": {"in": ["enduser-roll"]}, "spend": {"gt": 10.0}},
"data": {"spend": {"decrement": 10.0}},
} in enduser_writes
assert {
"table": "enduser",
"op": "update_many",
"where": {"budget_id": "budget-roll", "spend": {"gt": 0, "lte": 10.0}},
"where": {"user_id": {"in": ["enduser-roll"]}, "spend": {"gt": 0, "lte": 10.0}},
"data": {"spend": 0},
} in enduser_writes
@ -3377,13 +3484,9 @@ def test_cascade_rollover_writes_survive_sequential_execution(
{"user_id": "member-1", "team_id": "team-1", "spend": 15.0, "budget_id": "budget-roll"},
)
mock_prisma_client.db.litellm_teammembership.set_find_many_results([membership])
mock_prisma_client.data["enduser"] = [
type(
"EndUser",
(),
{"spend": 15.0, "litellm_budget_table": budget, "user_id": "enduser-roll", "budget_id": "budget-roll"},
)
]
mock_prisma_client.db.litellm_endusertable.set_find_many_results(
[type("EndUser", (), {"spend": 15.0, "user_id": "enduser-roll", "budget_id": "budget-roll"})]
)
asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table())
@ -3702,8 +3805,9 @@ def _wire_cascade_reads_for_test(prisma_client, endusers=()):
tables an awaitable find_many so the reads resolve instead of falling into
the job's warn-and-continue path.
End users are read by the post-commit invalidation walk rather than by
``get_data``, so callers that care about customers pass them here.
End users are read by the paged reset and the post-commit invalidation
walk rather than by ``get_data``, so callers that care about them pass
them here.
"""
for table in (
"litellm_teammembership",
@ -4010,6 +4114,7 @@ async def test_reset_budget_endusers_are_zeroed_with_the_budget_window_advance(f
prisma_client.get_data.side_effect = get_data_mock
prisma_client.update_data = AsyncMock()
batch_calls: Final = _wire_batcher_for_test(prisma_client)
_wire_cascade_reads_for_test(prisma_client, endusers=endusers)
proxy_logging_obj: Final = MagicMock()
proxy_logging_obj.service_logging_obj = MagicMock()
@ -4020,11 +4125,14 @@ async def test_reset_budget_endusers_are_zeroed_with_the_budget_window_advance(f
await _await_tasks_spawned_by(job.reset_budget_for_litellm_budget_table())
assert prisma_client.db.batch_.call_count == 1, "the cascade must be one transaction"
assert prisma_client.db.batch_.call_count == 2, "one end-user page, then the cascade in one transaction"
enduser_writes: Final = [c for c in batch_calls if c["table"] == "enduser"]
assert len(enduser_writes) == 1
assert enduser_writes[0]["where"] == {"budget_id": {"in": ["budget1"]}, "spend": {"gt": 0}}
assert enduser_writes[0]["where"] == {
"user_id": {"in": ["user1", "user2", "user3", "user4", "user5", "user6"]},
"spend": {"gt": 0},
}
assert enduser_writes[0]["data"] == {"spend": 0}
budget_writes: Final = [c for c in batch_calls if c["table"] == "budget"]
@ -4098,7 +4206,7 @@ async def test_reset_budget_continues_other_categories_on_failure(frozen_reset_c
assert len([c for c in batch_calls if c["table"] == "team_membership"]) == 1
enduser_writes: Final = [c for c in batch_calls if c["table"] == "enduser"]
assert len(enduser_writes) == 1
assert enduser_writes[0]["where"] == {"budget_id": {"in": ["budget1"]}, "spend": {"gt": 0}}
assert enduser_writes[0]["where"] == {"user_id": {"in": ["user1"]}, "spend": {"gt": 0}}
assert enduser_writes[0]["data"] == {"spend": 0}
key_writes: Final = [c for c in batch_calls if c["table"] == "key" and c["op"] == "update"]
@ -4576,7 +4684,7 @@ async def test_service_logger_endusers_success():
enduser_writes = [c for c in batch_calls if c["table"] == "enduser"]
assert len(enduser_writes) == 1
assert enduser_writes[0]["where"] == {"budget_id": {"in": ["budget1"]}, "spend": {"gt": 0}}
assert enduser_writes[0]["where"] == {"user_id": {"in": ["user1", "user2"]}, "spend": {"gt": 0}}
proxy_logging_obj.service_logging_obj.async_service_success_hook.assert_called_once()
(

View file

@ -7,6 +7,7 @@ import pytest
from litellm.repositories.unit_of_work import (
LinkedSpendResetWrites,
budget_cascade_unit_of_work,
enduser_spend_reset_unit_of_work,
spend_reset_unit_of_work,
)
@ -96,7 +97,6 @@ async def test_budget_cascade_dependents_and_window_advance_share_one_batch():
uow.tags.queue_spend_zero(where=linked)
uow.model_access_groups.queue_spend_zero(where=linked)
uow.projects.queue_spend_zero(where=linked)
uow.endusers.queue_spend_zero(where={"user_id": {"in": ["enduser-1"]}})
uow.budgets.queue_window_advance(budget_id="budget-1", budget_reset_at=reset_at)
assert batch.commit_count == 0
@ -108,11 +108,31 @@ async def test_budget_cascade_dependents_and_window_advance_share_one_batch():
("litellm_tagtable.update_many", linked, {"spend": 0}),
("litellm_modelaccessgroupbudgettable.update_many", linked, {"spend": 0}),
("litellm_projecttable.update_many", linked, {"spend": 0}),
("litellm_endusertable.update_many", {"user_id": {"in": ["enduser-1"]}}, {"spend": 0}),
("litellm_budgettable.update_many", {"budget_id": "budget-1"}, {"budget_reset_at": reset_at}),
]
async def test_enduser_page_commits_on_its_own_batch():
"""One tier can gate millions of end users, so a page of them lands in its
own transaction instead of riding the cascade's."""
batch = FakeBatch()
async with enduser_spend_reset_unit_of_work(lambda: batch) as writes:
writes.queue_spend_zero(where={"user_id": {"in": ["enduser-1", "enduser-2"]}, "spend": {"gt": 0}})
writes.queue_spend_decrement(where={"user_id": {"in": ["enduser-3"]}, "spend": {"gt": 5.0}}, amount=5.0)
assert batch.commit_count == 0
assert batch.commit_count == 1
assert batch.calls == [
(
"litellm_endusertable.update_many",
{"user_id": {"in": ["enduser-1", "enduser-2"]}, "spend": {"gt": 0}},
{"spend": 0},
),
("litellm_endusertable.update_many", {"user_id": {"in": ["enduser-3"]}, "spend": {"gt": 5.0}}, {"spend": {"decrement": 5.0}}),
]
async def test_budget_window_advance_tolerates_a_tier_deleted_mid_chunk():
"""A tier deleted between the read and the commit must not abort the batch:
``update`` raises P2025 on a missing row and takes every other write in the