mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-08 08:08:37 +00:00
* fix(accounting): keep one undecodable delivery from blocking the ledger A request whose metadata carried a 401-digit tokens_saved stored that number in its delivery payload. The query engine could not decode it when the worker claimed the batch, so every later delivery on the proxy stayed pending The worker now claims the payload as text and decodes each row in Python. A row that still cannot be decoded is parked alone while the rest of the batch applies. The payload builder also clamps integers to the JSON-safe range, so caller-supplied counts can no longer reach the engine at that size Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(accounting): take spend locks in one order so the ledger cannot deadlock A ledger delivery updates many spend tables in one transaction, so it can deadlock with any writer that locks the same rows in another order. It took the team advisory lock after the user and team rows, while team roster writers take it first The delivery now takes every team advisory lock before any row, then writes tables in one canonical order with ids sorted. It also runs under the rollup lock_timeout, applies 500 deliveries per batch by default, and spreads retries with jitter The writers that could still collide with it now follow the same order. The budget reset cascade, model allowlist rename, global spend reset, bulk user update, org delete and the delete-by-list paths lock their rows in key order first, and access group endpoints update teams and keys in one sorted pass Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(accounting): prove a ledger delivery waits instead of deadlocking Real Postgres tests hold the first lock of a team roster change, an access group update and a budget reset cascade, start a delivery, and let the other writer finish. The delivery must wait and then charge once. Before the lock order fix each case ends in a Postgres deadlock Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(accounting)!: make the SpendLogs ledger the default accounting path use_spend_logs_ledger now defaults to true, so every request is charged through LiteLLM_SpendLogDelivery. Setting it to false rolls back to the in-memory and Redis spend queues, which stay in place. Tests that cover those legacy writers pin the setting off, and the rest check the queued charge the ledger stores Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(proxy): let the mocked client open a transaction for key and team deletes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(proxy): take team locks first when a model rename rewrites allowlists A rename locked team rows before user rows while a roster change holds the team advisory lock and a user row, so the two could deadlock. The rename now takes the advisory lock for every team that lists the model, sorted, before any row lock. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(accounting): drain a backlog in one worker run, one batch per transaction One run applied a single fresh batch, so at 500 per batch charges fell behind any load above that rate. The worker now keeps claiming fresh batches while they come back full, for up to five seconds, and each batch still commits on its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(accounting): queue only the charge when a request's spend log is suppressed With logging off for a request, the ledger still queued the full row, so a failed flush or a shutdown could leave its prompt and response in Redis. The queue now holds only the fields the charge needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(proxy): format the rename team listing query Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(accounting): remove expired delivery receipts as fast as the worker applies them The worker now applies several batches per run but still removed one cleanup batch of applied receipts, so the table could outgrow its retention window. Cleanup now repeats while each pass removes a full batch, for up to five seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(proxy): retry a model rename when a team starts listing the model mid-lock The team advisory locks come from a snapshot, so a team that gained the old name before its row lock was locked without its advisory lock. That attempt now commits without writing and the next one locks the fresh set of teams. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.5 KiB
Python
197 lines
8.5 KiB
Python
import asyncio
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import AsyncIterator, Callable, Iterator, Sequence
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Final
|
|
|
|
import psycopg
|
|
import pytest
|
|
import pytest_asyncio
|
|
from prisma import Prisma
|
|
|
|
from litellm.proxy._types import DBSpendUpdateTransactions
|
|
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
|
from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter
|
|
from litellm.proxy.management_helpers.model_allowlist_rename_sync import sync_model_allowlists_for_renamed_model
|
|
from litellm.repositories import unit_of_work
|
|
from tests.integration._support.database import scratch_database
|
|
|
|
_ROOT: Final = Path(__file__).resolve().parents[3]
|
|
_KEY: Final = "2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683"
|
|
_CHARGES: Final = DBSpendUpdateTransactions(
|
|
user_list_transactions={"user": 1.0},
|
|
end_user_list_transactions={},
|
|
key_list_transactions={_KEY: 1.0},
|
|
team_list_transactions={"team": 1.0},
|
|
team_member_list_transactions={"team_id::team::user_id::user": 1.0},
|
|
org_list_transactions={"org": 1.0},
|
|
org_member_list_transactions={"org_id::org::user_id::user": 1.0},
|
|
project_list_transactions={"project": 1.0},
|
|
tag_list_transactions={"tag": 1.0},
|
|
agent_list_transactions={},
|
|
model_access_group_list_transactions={"group": 1.0},
|
|
)
|
|
_SEED: Final = (
|
|
"INSERT INTO \"LiteLLM_BudgetTable\" (budget_id, created_by, updated_by) VALUES ('budget', 't', 't')",
|
|
'INSERT INTO "LiteLLM_OrganizationTable" (organization_id, organization_alias, budget_id, created_by, updated_by) '
|
|
"VALUES ('org', 'org', 'budget', 't', 't')",
|
|
"INSERT INTO \"LiteLLM_UserTable\" (user_id) VALUES ('user')",
|
|
'INSERT INTO "LiteLLM_TeamTable" (team_id, members_with_roles) '
|
|
"""VALUES ('team', '[{"user_id": "user", "role": "user"}]')""",
|
|
"INSERT INTO \"LiteLLM_TeamMembership\" (team_id, user_id, budget_id) VALUES ('team', 'user', 'budget')",
|
|
"INSERT INTO \"LiteLLM_OrganizationMembership\" (organization_id, user_id) VALUES ('org', 'user')",
|
|
f"INSERT INTO \"LiteLLM_VerificationToken\" (token, budget_id) VALUES ('{_KEY}', 'budget')",
|
|
"INSERT INTO \"LiteLLM_ProjectTable\" (project_id, budget_id, created_by, updated_by) "
|
|
"VALUES ('project', 'budget', 't', 't')",
|
|
"INSERT INTO \"LiteLLM_TagTable\" (tag_name, budget_id) VALUES ('tag', 'budget')",
|
|
"INSERT INTO \"LiteLLM_ModelAccessGroupBudgetTable\" (access_group_name, budget_id) VALUES ('group', 'budget')",
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def schema_sql() -> str:
|
|
return subprocess.run(
|
|
[sys.executable, "-I", "-m", "prisma", "migrate", "diff", "--from-empty", "--to-schema-datamodel",
|
|
str(_ROOT / "schema.prisma"), "--script"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=_ROOT,
|
|
).stdout
|
|
|
|
|
|
@pytest.fixture
|
|
def database(schema_sql: str) -> Iterator[str]:
|
|
with scratch_database("spend_lock_order") as url, psycopg.connect(url, autocommit=True) as connection:
|
|
connection.execute(schema_sql)
|
|
for statement in _SEED:
|
|
connection.execute(statement)
|
|
yield url
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="function")
|
|
async def ledger(database: str) -> AsyncIterator[Prisma]:
|
|
client: Final = Prisma(datasource={"url": database})
|
|
await client.connect()
|
|
try:
|
|
yield client
|
|
finally:
|
|
await client.disconnect()
|
|
|
|
|
|
async def _apply_charges(client: Prisma) -> None:
|
|
async with client.tx() as transaction:
|
|
await DBSpendUpdateWriter().apply_accounting_entities(client, None, transaction, _CHARGES)
|
|
|
|
|
|
async def _ledger_waits_for_a_lock(url: str) -> None:
|
|
with psycopg.connect(url, autocommit=True) as observer:
|
|
for _ in range(200):
|
|
if observer.execute("SELECT 1 FROM pg_locks WHERE NOT granted").fetchone():
|
|
return
|
|
await asyncio.sleep(0.05)
|
|
raise AssertionError("the ledger never waited on the other writer")
|
|
|
|
|
|
def _spend(url: str) -> Sequence[object]:
|
|
with psycopg.connect(url) as connection:
|
|
return connection.execute(
|
|
'SELECT (SELECT spend FROM "LiteLLM_UserTable"), (SELECT spend FROM "LiteLLM_TeamTable"), '
|
|
'(SELECT spend FROM "LiteLLM_TeamMembership"), (SELECT spend FROM "LiteLLM_VerificationToken"), '
|
|
'(SELECT spend FROM "LiteLLM_OrganizationTable"), (SELECT spend FROM "LiteLLM_ProjectTable")'
|
|
).fetchone() or ()
|
|
|
|
|
|
_Writer = Callable[[psycopg.Connection[tuple[object, ...]]], None]
|
|
|
|
|
|
def _roster_first(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
connection.execute("SELECT pg_advisory_xact_lock(hashtext('team'))")
|
|
|
|
|
|
def _roster_rest(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
connection.execute("SELECT 1 FROM \"LiteLLM_UserTable\" WHERE user_id = 'user' FOR UPDATE")
|
|
connection.execute("UPDATE \"LiteLLM_TeamTable\" SET members_with_roles = members_with_roles WHERE team_id = 'team'")
|
|
|
|
|
|
def _access_group_first(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
connection.execute("UPDATE \"LiteLLM_TeamTable\" SET access_group_ids = ARRAY['ag'] WHERE team_id = 'team'")
|
|
|
|
|
|
def _access_group_rest(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
connection.execute(f"UPDATE \"LiteLLM_VerificationToken\" SET access_group_ids = ARRAY['ag'] WHERE token = '{_KEY}'")
|
|
|
|
|
|
def _cascade_first(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
connection.execute(unit_of_work._LOCK_BUDGET_ORGS_SQL.replace("$1", "%s"), (["budget"],))
|
|
|
|
|
|
def _cascade_rest(connection: psycopg.Connection[tuple[object, ...]]) -> None:
|
|
for statement in (
|
|
unit_of_work._LOCK_BUDGET_MEMBERSHIPS_SQL,
|
|
unit_of_work._LOCK_BUDGET_KEYS_SQL,
|
|
unit_of_work._LOCK_BUDGET_PROJECTS_SQL,
|
|
unit_of_work._LOCK_BUDGET_TAGS_SQL,
|
|
unit_of_work._LOCK_BUDGET_ACCESS_GROUPS_SQL,
|
|
):
|
|
connection.execute(statement.replace("$1", "%s"), (["budget"],))
|
|
connection.execute("UPDATE \"LiteLLM_TagTable\" SET spend = 0 WHERE budget_id = 'budget'")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("first", "rest"),
|
|
[(_roster_first, _roster_rest), (_access_group_first, _access_group_rest), (_cascade_first, _cascade_rest)],
|
|
ids=["team_roster", "access_group", "budget_cascade"],
|
|
)
|
|
@pytest.mark.asyncio(loop_scope="function")
|
|
async def test_charges_wait_for_a_concurrent_writer_instead_of_deadlocking(
|
|
database: str, ledger: Prisma, first: _Writer, rest: _Writer
|
|
) -> None:
|
|
"""The other writer takes its first lock, the ledger starts and must block holding nothing the other writer
|
|
needs next, so the other writer finishes and the charge lands once after it."""
|
|
with psycopg.connect(database) as writer:
|
|
writer.execute("SET lock_timeout = '10s'")
|
|
first(writer)
|
|
charges: Final = asyncio.create_task(_apply_charges(ledger))
|
|
await _ledger_waits_for_a_lock(database)
|
|
await asyncio.to_thread(rest, writer)
|
|
writer.commit()
|
|
await asyncio.wait_for(charges, timeout=20)
|
|
|
|
assert _spend(database) == (1.0, 1.0, 1.0, 1.0, 1.0, 1.0)
|
|
|
|
|
|
async def _rename(client: Prisma) -> None:
|
|
await sync_model_allowlists_for_renamed_model(
|
|
SimpleNamespace(db=client),
|
|
model_id="deployment",
|
|
old_name="old-model",
|
|
new_name="new-model",
|
|
llm_router=None,
|
|
user_api_key_cache=UserApiKeyCache(),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio(loop_scope="function")
|
|
async def test_a_model_rename_waits_for_a_roster_change_instead_of_deadlocking(database: str, ledger: Prisma) -> None:
|
|
"""The roster change holds the team lock and the user row, so the rename must wait before it locks the team
|
|
row the roster change updates next."""
|
|
with psycopg.connect(database, autocommit=True) as seed:
|
|
seed.execute("UPDATE \"LiteLLM_TeamTable\" SET models = ARRAY['old-model']")
|
|
seed.execute("UPDATE \"LiteLLM_UserTable\" SET models = ARRAY['old-model']")
|
|
with psycopg.connect(database) as writer:
|
|
writer.execute("SET lock_timeout = '10s'")
|
|
_roster_first(writer)
|
|
writer.execute("SELECT 1 FROM \"LiteLLM_UserTable\" WHERE user_id = 'user' FOR UPDATE")
|
|
rename: Final = asyncio.create_task(_rename(ledger))
|
|
await _ledger_waits_for_a_lock(database)
|
|
await asyncio.to_thread(_roster_rest, writer)
|
|
writer.commit()
|
|
await asyncio.wait_for(rename, timeout=20)
|
|
|
|
with psycopg.connect(database) as connection:
|
|
assert connection.execute(
|
|
'SELECT (SELECT models FROM "LiteLLM_TeamTable"), (SELECT models FROM "LiteLLM_UserTable")'
|
|
).fetchone() == (["new-model"], ["new-model"])
|