mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-27 01:22:18 +00:00
* test(integration): add read-replica routing harness * refactor(integration): hoist the maintenance url imports * fix(integration): keep per-test databases and the witness sequence readable under replica roles * fix(integration): opt bespoke database and pool tests out of the injected read replica * test(integration): commit recorded replica routing expectations * fix(integration): judge routing by role containment so shrinking role sets do not fail * fix(integration): run the pool-limit shutdown choreography on the superuser database url * ci(integration): add the mcp group to the replica matrix * fix(integration): judge routing by exact role sets with a named either-role allowlist * test(integration): drop containment-era routing expectations for re-recording * chore(integration): drop docstrings from the replica harness scripts * docs(integration): describe exact routing matching and the either-role list * test(integration): record exact replica routing expectations * test(integration): allow the SELECT 1 health probe on either role * test(integration): replace committed routing expectations with an on-demand base-vs-head parity run * test(integration): fix parity env scope, readme wording, and seed-deterministic serialization test * test(integration): make the sorted-role serialization test deterministic in-process * test(integration): swap all product code in parity runs and pin role gains * ci(integration): force tracked-file removal before parity checkout --------- Co-authored-by: yuneng <yuneng@berri.ai>
313 lines
13 KiB
Python
313 lines
13 KiB
Python
import json
|
|
import os
|
|
import signal
|
|
import threading
|
|
import uuid
|
|
from collections.abc import Callable, Iterator
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Final
|
|
|
|
import httpx
|
|
import psycopg
|
|
import pytest
|
|
import yaml
|
|
from integration._support.client import Gateway, delete_key_if_present, eventually, string_value
|
|
from integration._support.database import read_rows
|
|
from integration._support.process import OwnedProxy, owned_proxy_process
|
|
from integration._support.wire import Reply, Request, wire_server
|
|
from psycopg import sql
|
|
|
|
REQUESTS_WHILE_BLOCKED: Final = 6
|
|
CANCEL_LOG_LINE: Final = "in-flight scheduled job(s) for shutdown"
|
|
BATCH_DRAINED_LOG_LINE: Final = f"flushed {REQUESTS_WHILE_BLOCKED} daily spend update items from in-memory queue"
|
|
MODEL_INSERT_ARRIVED_LOG_LINE: Final = "path=/model/new"
|
|
COMMIT_DELAY_SECONDS: Final = 15
|
|
BURST_REQUESTS: Final = 30
|
|
|
|
|
|
def _api_requests(table: str, column: str, identity: str) -> int:
|
|
rows: Final = read_rows(
|
|
f'SELECT coalesce(sum(api_requests), 0)::int AS total FROM "{table}" WHERE {column}=%s', (identity,)
|
|
)
|
|
total: Final = rows[0]["total"]
|
|
assert isinstance(total, int)
|
|
return total
|
|
|
|
|
|
def _waiting_on(table: str) -> int:
|
|
rows: Final = read_rows(
|
|
"SELECT count(*)::int AS waiting FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE %s",
|
|
(f'%"{table}"%',),
|
|
)
|
|
waiting: Final = rows[0]["waiting"]
|
|
assert isinstance(waiting, int)
|
|
return waiting
|
|
|
|
|
|
def _committing_daily_user_spend() -> int:
|
|
rows: Final = read_rows(
|
|
"SELECT count(*)::int AS committing FROM pg_stat_activity "
|
|
"WHERE query='COMMIT' AND state='active' AND wait_event='PgSleep' AND pid IN "
|
|
"(SELECT pid FROM pg_locks WHERE relation = %s::regclass AND mode='RowExclusiveLock')",
|
|
('"LiteLLM_DailyUserSpend"',),
|
|
)
|
|
committing: Final = rows[0]["committing"]
|
|
assert isinstance(committing, int)
|
|
return committing
|
|
|
|
|
|
def _install_slow_commit(user_id: str, fails_once: bool) -> str:
|
|
suffix: Final = f"slow_commit_{uuid.uuid4().hex}"
|
|
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
|
|
connection.execute(sql.SQL("CREATE SEQUENCE {}").format(sql.Identifier(suffix)))
|
|
connection.execute(
|
|
sql.SQL(
|
|
"CREATE FUNCTION {}() RETURNS trigger LANGUAGE plpgsql AS $slow$ "
|
|
"BEGIN PERFORM pg_sleep({}); "
|
|
"IF {} AND nextval({}) = 1 THEN RAISE EXCEPTION 'integration: first COMMIT fails'; END IF; "
|
|
"RETURN NULL; END $slow$"
|
|
).format(
|
|
sql.Identifier(suffix), sql.Literal(COMMIT_DELAY_SECONDS), sql.Literal(fails_once), sql.Literal(suffix)
|
|
)
|
|
)
|
|
connection.execute(
|
|
sql.SQL(
|
|
'CREATE CONSTRAINT TRIGGER {} AFTER INSERT OR UPDATE ON "LiteLLM_DailyUserSpend" '
|
|
"DEFERRABLE INITIALLY DEFERRED FOR EACH ROW WHEN (NEW.user_id = {}) EXECUTE FUNCTION {}()"
|
|
).format(sql.Identifier(suffix), sql.Literal(user_id), sql.Identifier(suffix))
|
|
)
|
|
return suffix
|
|
|
|
|
|
def _drop_slow_commit(suffix: str) -> None:
|
|
with psycopg.connect(os.environ["DATABASE_URL"], autocommit=True) as connection:
|
|
connection.execute(
|
|
sql.SQL('DROP TRIGGER IF EXISTS {} ON "LiteLLM_DailyUserSpend"').format(sql.Identifier(suffix))
|
|
)
|
|
connection.execute(sql.SQL("DROP FUNCTION IF EXISTS {}()").format(sql.Identifier(suffix)))
|
|
connection.execute(sql.SQL("DROP SEQUENCE IF EXISTS {}").format(sql.Identifier(suffix)))
|
|
|
|
|
|
def _provider(request: Request) -> Reply:
|
|
if request.method != "POST":
|
|
return Reply(status=404, body=b'{"error":"not scripted"}')
|
|
assert request.target == "/v1/chat/completions"
|
|
return Reply(
|
|
body=json.dumps(
|
|
{
|
|
"id": "chatcmpl-" + uuid.uuid4().hex,
|
|
"object": "chat.completion",
|
|
"created": 1,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
|
"usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4},
|
|
}
|
|
).encode()
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Shutdown:
|
|
owner: str
|
|
team: str
|
|
owned: OwnedProxy
|
|
key: str
|
|
model: str
|
|
|
|
def chat(self) -> None:
|
|
body: Final = {"model": self.model, "messages": [{"role": "user", "content": f"spend {uuid.uuid4().hex}"}]}
|
|
assert self.owned.gateway.request("POST", "/v1/chat/completions", body, key=self.key).status_code == 200
|
|
|
|
def daily_user_requests(self) -> int:
|
|
return _api_requests("LiteLLM_DailyUserSpend", "user_id", self.owner)
|
|
|
|
def spend_logs(self) -> int:
|
|
rows: Final = read_rows('SELECT count(*)::int AS total FROM "LiteLLM_SpendLogs" WHERE "user"=%s', (self.owner,))
|
|
total: Final = rows[0]["total"]
|
|
assert isinstance(total, int)
|
|
return total
|
|
|
|
def burst(self, requests: int) -> None:
|
|
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
for outcome in pool.map(lambda _: self.chat(), range(requests)):
|
|
assert outcome is None
|
|
|
|
def logged(self, line: str, times: int = 1) -> bool:
|
|
return self.owned.log.read_text(errors="replace").count(line) >= times
|
|
|
|
def chat_while_spend_update_is_blocked(self, blocker: psycopg.Connection, table: str) -> None:
|
|
blocker.execute(f'LOCK TABLE "{table}" IN EXCLUSIVE MODE')
|
|
for _ in range(REQUESTS_WHILE_BLOCKED):
|
|
self.chat()
|
|
eventually(lambda: _waiting_on(table), lambda waiting: waiting == 1, seconds=30)
|
|
|
|
def start_blocked_model_insert(self) -> threading.Thread:
|
|
body: Final = {
|
|
"model_name": f"integration-blocked-{uuid.uuid4().hex}",
|
|
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "integration-provider-key"},
|
|
"model_info": {},
|
|
}
|
|
|
|
def insert() -> None:
|
|
try:
|
|
self.owned.gateway.request("POST", "/model/new", body)
|
|
except httpx.TransportError:
|
|
pass
|
|
|
|
thread: Final = threading.Thread(target=insert, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
def terminate_once(self, blocked: Callable[[], bool], release: Callable[[], None]) -> None:
|
|
eventually(blocked, lambda state: state, seconds=60)
|
|
self.owned.process.send_signal(signal.SIGTERM)
|
|
eventually(lambda: self.logged(CANCEL_LOG_LINE), lambda seen: seen, seconds=60)
|
|
release()
|
|
self.owned.process.wait(timeout=120)
|
|
|
|
|
|
def _config_with_pool_limit(tmp_path: Path, pool_limit: int) -> Path:
|
|
config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
|
|
config["general_settings"]["database_connection_pool_limit"] = pool_limit
|
|
config["general_settings"]["database_connection_pool_timeout"] = 60
|
|
path: Final = tmp_path / f"pool-{pool_limit}.yaml"
|
|
path.write_text(yaml.safe_dump(config))
|
|
return path
|
|
|
|
|
|
@contextmanager
|
|
def _proxy_with_one_seeded_row(
|
|
gateway: Gateway,
|
|
tmp_path: Path,
|
|
pool_limit: int,
|
|
cancel_timeout_seconds: int = 5,
|
|
settle_seconds: int = 0,
|
|
requests: int = REQUESTS_WHILE_BLOCKED,
|
|
workers: int = 1,
|
|
) -> Iterator[_Shutdown]:
|
|
owner: Final = f"integration-owner-{uuid.uuid4().hex}"
|
|
with gateway.scenario() as scenario, wire_server(_provider) as wire:
|
|
model: Final = scenario.model(api_base=wire.url + "/v1", num_retries=0)
|
|
team: Final = scenario.team(models=[model])
|
|
with owned_proxy_process(
|
|
gateway,
|
|
tmp_path,
|
|
{
|
|
"DATABASE_URL": os.environ["DATABASE_URL"],
|
|
"LITELLM_LOG": "DEBUG",
|
|
"GRACEFUL_SHUTDOWN_TIMEOUT": "1",
|
|
"SCHEDULED_JOB_SHUTDOWN_FINISH_TIMEOUT_SECONDS": "1",
|
|
"SCHEDULED_JOB_SHUTDOWN_CANCEL_TIMEOUT_SECONDS": str(cancel_timeout_seconds),
|
|
},
|
|
config=_config_with_pool_limit(tmp_path, pool_limit),
|
|
remove_environment=("DATABASE_URL_READ_REPLICA",),
|
|
workers=workers,
|
|
) as owned:
|
|
key: Final = string_value(
|
|
owned.gateway.post("/key/generate", {"user_id": owner, "team_id": team, "models": [model]})["key"]
|
|
)
|
|
scenario.cleanups.callback(delete_key_if_present, gateway, key)
|
|
shutdown: Final = _Shutdown(owner, team, owned, key, model)
|
|
shutdown.chat()
|
|
eventually(shutdown.daily_user_requests, lambda total: total == 1, seconds=60)
|
|
yield shutdown
|
|
written: Final = 1 + requests
|
|
if settle_seconds:
|
|
eventually(
|
|
lambda: (
|
|
_api_requests("LiteLLM_DailyUserSpend", "user_id", owner),
|
|
_api_requests("LiteLLM_DailyTeamSpend", "team_id", team),
|
|
),
|
|
lambda totals: totals == (written, written),
|
|
seconds=settle_seconds,
|
|
)
|
|
assert _api_requests("LiteLLM_DailyUserSpend", "user_id", owner) == written
|
|
assert _api_requests("LiteLLM_DailyTeamSpend", "team_id", team) == written
|
|
|
|
|
|
@pytest.mark.covers("quota_management.spend_tracking.shutdown_cancel_keeps_in_flight_daily_batch")
|
|
def test_daily_spend_batch_cancelled_while_waiting_for_a_pool_connection_is_written_by_the_final_flush(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
with (
|
|
_proxy_with_one_seeded_row(gateway, tmp_path, pool_limit=2) as shutdown,
|
|
psycopg.connect(os.environ["DATABASE_URL"]) as models,
|
|
psycopg.connect(os.environ["DATABASE_URL"]) as memberships,
|
|
):
|
|
models.execute('LOCK TABLE "LiteLLM_ProxyModelTable" IN EXCLUSIVE MODE')
|
|
first: Final = shutdown.start_blocked_model_insert()
|
|
eventually(lambda: _waiting_on("LiteLLM_ProxyModelTable"), lambda waiting: waiting == 1, seconds=30)
|
|
shutdown.chat_while_spend_update_is_blocked(memberships, "LiteLLM_TeamMembership")
|
|
second: Final = shutdown.start_blocked_model_insert()
|
|
eventually(lambda: shutdown.logged(MODEL_INSERT_ARRIVED_LOG_LINE, times=2), lambda seen: seen, seconds=30)
|
|
memberships.rollback()
|
|
eventually(lambda: _waiting_on("LiteLLM_ProxyModelTable"), lambda waiting: waiting == 2, seconds=30)
|
|
shutdown.terminate_once(lambda: shutdown.logged(BATCH_DRAINED_LOG_LINE), models.rollback)
|
|
first.join(timeout=30)
|
|
second.join(timeout=30)
|
|
|
|
|
|
@pytest.mark.covers("quota_management.spend_tracking.shutdown_cancel_keeps_in_flight_daily_batch")
|
|
def test_daily_spend_batch_cancelled_while_waiting_for_a_row_lock_is_written_exactly_once(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
with (
|
|
_proxy_with_one_seeded_row(gateway, tmp_path, pool_limit=10) as shutdown,
|
|
psycopg.connect(os.environ["DATABASE_URL"]) as holder,
|
|
psycopg.connect(os.environ["DATABASE_URL"]) as memberships,
|
|
):
|
|
holder.execute('SELECT 1 FROM "LiteLLM_DailyUserSpend" WHERE user_id=%s FOR UPDATE', (shutdown.owner,))
|
|
shutdown.chat_while_spend_update_is_blocked(memberships, "LiteLLM_TeamMembership")
|
|
memberships.rollback()
|
|
shutdown.terminate_once(
|
|
lambda: shutdown.logged(BATCH_DRAINED_LOG_LINE) and _waiting_on("LiteLLM_DailyUserSpend") == 1,
|
|
holder.rollback,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("cancel_timeout_seconds", "commit_fails_once"),
|
|
[
|
|
pytest.param(60, False, id="cancel_budget_outlives_commit"),
|
|
pytest.param(5, False, id="commit_outlives_cancel_budget"),
|
|
pytest.param(60, True, id="commit_fails_within_cancel_budget"),
|
|
pytest.param(5, True, id="commit_fails_after_cancel_budget"),
|
|
],
|
|
)
|
|
def test_daily_spend_batch_cancelled_while_postgres_is_committing_it_is_written_exactly_once(
|
|
gateway: Gateway, tmp_path: Path, cancel_timeout_seconds: int, commit_fails_once: bool
|
|
) -> None:
|
|
with (
|
|
_proxy_with_one_seeded_row(
|
|
gateway, tmp_path, pool_limit=10, cancel_timeout_seconds=cancel_timeout_seconds, settle_seconds=90
|
|
) as shutdown,
|
|
psycopg.connect(os.environ["DATABASE_URL"]) as memberships,
|
|
):
|
|
suffix: Final = _install_slow_commit(shutdown.owner, fails_once=commit_fails_once)
|
|
try:
|
|
shutdown.chat_while_spend_update_is_blocked(memberships, "LiteLLM_TeamMembership")
|
|
memberships.rollback()
|
|
shutdown.terminate_once(
|
|
lambda: shutdown.logged(BATCH_DRAINED_LOG_LINE) and _committing_daily_user_spend() == 1,
|
|
lambda: None,
|
|
)
|
|
finally:
|
|
_drop_slow_commit(suffix)
|
|
|
|
|
|
def test_daily_spend_burst_across_two_workers_survives_shutdown_during_commit_exactly_once(
|
|
gateway: Gateway, tmp_path: Path
|
|
) -> None:
|
|
with _proxy_with_one_seeded_row(
|
|
gateway, tmp_path, pool_limit=10, settle_seconds=120, requests=BURST_REQUESTS, workers=2
|
|
) as shutdown:
|
|
suffix: Final = _install_slow_commit(shutdown.owner, fails_once=False)
|
|
try:
|
|
shutdown.burst(BURST_REQUESTS)
|
|
eventually(shutdown.spend_logs, lambda total: total == 1 + BURST_REQUESTS, seconds=60)
|
|
shutdown.terminate_once(lambda: _committing_daily_user_spend() >= 1, lambda: None)
|
|
finally:
|
|
_drop_slow_commit(suffix)
|