diff --git a/tests/integration/spend/test_shutdown_flush.py b/tests/integration/spend/test_shutdown_flush.py index e96511511d9..b4a7f2c72fd 100644 --- a/tests/integration/spend/test_shutdown_flush.py +++ b/tests/integration/spend/test_shutdown_flush.py @@ -13,6 +13,7 @@ import httpx import psycopg import pytest import yaml +from pydantic import JsonValue 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 @@ -25,6 +26,7 @@ BATCH_DRAINED_LOG_LINE: Final = f"flushed {REQUESTS_WHILE_BLOCKED} daily spend u MODEL_INSERT_ARRIVED_LOG_LINE: Final = "path=/model/new" COMMIT_DELAY_SECONDS: Final = 15 BURST_REQUESTS: Final = 30 +TAGGED_REQUESTS: Final = 5 def _api_requests(table: str, column: str, identity: str) -> int: @@ -168,6 +170,14 @@ class _Shutdown: self.owned.process.wait(timeout=120) +def _config_with_batch_write_interval(tmp_path: Path, seconds: int) -> Path: + config: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + config["general_settings"]["proxy_batch_write_at"] = seconds + path: Final = tmp_path / f"batch-write-{seconds}.yaml" + path.write_text(yaml.safe_dump(config)) + return path + + 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 @@ -310,3 +320,30 @@ def test_daily_spend_burst_across_two_workers_survives_shutdown_during_commit_ex shutdown.terminate_once(lambda: _committing_daily_user_spend() >= 1, lambda: None) finally: _drop_slow_commit(suffix) + + +def test_daily_tag_spend_buffered_since_the_last_tag_job_run_is_written_by_the_shutdown_flush( + gateway: Gateway, tmp_path: Path +) -> None: + tag: Final = f"integration-shutdown-tag-{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) + with owned_proxy_process( + gateway, + tmp_path, + {"DATABASE_URL": os.environ["DATABASE_URL"]}, + config=_config_with_batch_write_interval(tmp_path, 600), + remove_environment=("DATABASE_URL_READ_REPLICA",), + ) as owned: + for _ in range(TAGGED_REQUESTS): + body: dict[str, JsonValue] = { + "model": model, + "messages": [{"role": "user", "content": f"tag {uuid.uuid4().hex}"}], + } + response = owned.gateway.request("POST", "/v1/chat/completions", body, headers={"x-litellm-tags": tag}) + assert response.status_code == 200, response.text + owned.begin_shutdown() + owned.process.wait(timeout=120) + assert _api_requests("LiteLLM_DailyTagSpend", "tag", tag) == TAGGED_REQUESTS, ( + "the tag job runs every batch interval x 2.3, so only the shutdown flush can have written these rows" + ) diff --git a/tests/unit/proxy/proxy_server/test_lifecycle.py b/tests/unit/proxy/proxy_server/test_lifecycle.py index bb6fc272e94..5a20b0c3171 100644 --- a/tests/unit/proxy/proxy_server/test_lifecycle.py +++ b/tests/unit/proxy/proxy_server/test_lifecycle.py @@ -29,13 +29,18 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fakeredis import FakeRedis, FakeServer +from fakeredis.aioredis import FakeConnection from pydantic import BaseModel, TypeAdapter +from redis.asyncio import BlockingConnectionPool from typing_extensions import TypedDict import litellm.proxy.proxy_server as ps -from litellm.constants import REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY +from litellm.caching.redis_cache import RedisCache +from litellm.constants import DB_DAILY_TAG_SPEND_UPDATE_JOB_NAME, REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY from litellm.proxy._types import BaseDailySpendTransaction, DailyTagSpendTransaction from litellm.proxy.db.db_spend_update_writer import DBSpendUpdateWriter +from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManager from litellm.proxy.proxy_server import ( ProxyStartupEvent, initialize_shared_aiohttp_session, @@ -58,6 +63,7 @@ from litellm.proxy.management_helpers.audit_logs import track_audit_task from .conftest import normalize _TAG_BATCH_ADAPTER: Final = TypeAdapter(dict[str, DailyTagSpendTransaction]) +_REDIS_LIST_ADAPTER: Final = TypeAdapter(list[bytes]) # --------------------------------------------------------------------------- # cleanup_router_config_variables @@ -445,66 +451,42 @@ def _daily_tag_spend(tag: str) -> dict[str, BaseDailySpendTransaction]: } -@pytest.mark.asyncio -async def test_flush_spend_counters_on_shutdown_commits_the_daily_tag_spend_buffered_since_the_last_tag_job_run( - monkeypatch: pytest.MonkeyPatch, -): - """The tag job runs every batch interval x DAILY_TAG_SPEND_BATCH_MULTIPLIER, so a worker that exits - between runs must still write the tag rows it buffered or every tag rollup undercounts LiteLLM_SpendLogs.""" - writer: Final = DBSpendUpdateWriter() - await writer.daily_tag_spend_update_queue.add_update(_daily_tag_spend("prod-tag")) - fake_prisma: Final = MagicMock() - execute_raw: Final = AsyncMock(return_value=1) - fake_prisma.db.execute_raw = execute_raw - fake_prisma.db.tx.return_value.__aenter__.return_value.execute_raw = execute_raw - monkeypatch.setattr(ps, "prisma_client", fake_prisma, raising=False) - monkeypatch.setattr(ps, "general_settings", {}, raising=False) - monkeypatch.setattr(ps.proxy_logging_obj, "db_spend_update_writer", writer) - monkeypatch.setattr(writer, "db_update_spend_transaction_handler", AsyncMock()) - - await ps.flush_spend_counters_on_shutdown() - - tag_upserts: Final = [ - call.args for call in execute_raw.await_args_list if '"LiteLLM_DailyTagSpend"' in str(call.args[0]) - ] - observed: Final = { - "tag_upserts": len(tag_upserts), - "tag_written": "prod-tag" in tag_upserts[0][1:] if tag_upserts else False, - "left_in_memory": writer.daily_tag_spend_update_queue.update_queue.qsize(), - } - assert observed == {"tag_upserts": 1, "tag_written": True, "left_in_memory": 0} - - @pytest.mark.asyncio async def test_flush_spend_counters_on_shutdown_hands_daily_tag_spend_to_redis_when_another_pod_holds_the_lock( monkeypatch: pytest.MonkeyPatch, ): - """With the Redis transaction buffer, a pod that is not the tag-commit leader keeps its tag spend by pushing - it to the shared buffer on shutdown, where the leader's next tag job commits it.""" - async_rpush: Final = AsyncMock(return_value=1) - redis_cache: Final = MagicMock() - redis_cache.async_rpush = async_rpush - writer: Final = DBSpendUpdateWriter(redis_cache=redis_cache) - await writer.daily_tag_spend_update_queue.add_update(_daily_tag_spend("prod-tag")) - monkeypatch.setattr(ps, "prisma_client", MagicMock(), raising=False) - monkeypatch.setattr(ps, "general_settings", {"use_redis_transaction_buffer": True}, raising=False) - monkeypatch.setattr(ps.proxy_logging_obj, "db_spend_update_writer", writer) - monkeypatch.setattr(writer, "db_update_spend_transaction_handler", AsyncMock()) - monkeypatch.setattr(writer.pod_lock_manager, "acquire_lock", AsyncMock(return_value=False)) + server: Final = FakeServer() + sync_client: Final = FakeRedis(server=server) + pool: Final = BlockingConnectionPool(connection_class=FakeConnection, server=server) + try: + with ( + patch("redis.Redis.from_url", return_value=sync_client), + patch("redis.asyncio.BlockingConnectionPool.from_url", return_value=pool), + ): + redis: Final = RedisCache(url="redis://spend-buffer.example:6379/0") + writer: Final = DBSpendUpdateWriter(redis_cache=redis) + writer.pod_lock_manager.redis_cache = redis + sync_client.set(PodLockManager.get_redis_lock_key(DB_DAILY_TAG_SPEND_UPDATE_JOB_NAME), "other-pod") + await writer.daily_tag_spend_update_queue.add_update(_daily_tag_spend("prod-tag")) + monkeypatch.setattr(ps, "prisma_client", MagicMock(), raising=False) + monkeypatch.setattr(ps, "general_settings", {"use_redis_transaction_buffer": True}, raising=False) + monkeypatch.setattr(ps.proxy_logging_obj, "db_spend_update_writer", writer) - await ps.flush_spend_counters_on_shutdown() + await ps.flush_spend_counters_on_shutdown() - pushed_batches: Final = [ - _TAG_BATCH_ADAPTER.validate_json(value) - for call in async_rpush.await_args_list - if call.kwargs["key"] == REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY - for value in call.kwargs["values"] - ] - observed: Final = { - "pushed_tags": [txn["tag"] for batch in pushed_batches for txn in batch.values()], - "left_in_memory": writer.daily_tag_spend_update_queue.update_queue.qsize(), - } - assert observed == {"pushed_tags": ["prod-tag"], "left_in_memory": 0} + buffered_tags: Final = [ + txn["tag"] + for value in _REDIS_LIST_ADAPTER.validate_python( + sync_client.lrange(REDIS_DAILY_TAG_SPEND_UPDATE_BUFFER_KEY, 0, -1) + ) + for txn in _TAG_BATCH_ADAPTER.validate_json(value).values() + ] + assert buffered_tags == ["prod-tag"], ( + "a pod that is not the tag-commit leader must leave its buffered tag spend in Redis for the leader" + ) + finally: + await pool.disconnect() + sync_client.close() # ---------------------------------------------------------------------------