fix: drain shutdown callbacks and persist pending tag spend

This commit is contained in:
Joshua Valluru 2026-10-28 17:09:51 -07:00
parent 3b9a59348f
commit 074eed21dd
7 changed files with 401 additions and 14 deletions

View file

@ -473,8 +473,12 @@ class LoggingWorker:
self.start()
self.enqueue(async_coroutine)
async def stop(self) -> None:
async def stop(self, *, discard_pending: bool = False) -> None:
"""Stop the logging worker and clean up resources."""
if discard_pending and self._queue is not None:
for task in self._drain_pending(self._queue):
task["coroutine"].close()
self._queue.task_done()
if self._timeout_summary_task is not None:
self._timeout_summary_task.cancel()
self._timeout_summary_task = None
@ -496,6 +500,11 @@ class LoggingWorker:
# Wait for cancellation to settle; ignore errors raised during shutdown.
await asyncio.gather(*tasks_to_cancel, return_exceptions=True)
if discard_pending and self._queue is not None:
for task in self._unstarted_dequeued_tasks():
task["coroutine"].close()
self._untrack_dequeued(task)
self._queue.task_done()
self._worker_task = None
# Drop references to completed tasks so we can restart cleanly.
self._running_tasks.clear()

View file

@ -1268,14 +1268,30 @@ async def _flush_spend_logs_queue_on_shutdown() -> None:
async def _flush_spend_on_shutdown() -> None:
from litellm.proxy.utils import update_daily_tag_spend
await flush_spend_counters_on_shutdown()
if prisma_client is not None:
await update_daily_tag_spend(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)
await _flush_spend_logs_queue_on_shutdown()
async def _flush_spend_around_logging_drain_on_shutdown(
flush_spend: Callable[[], Awaitable[None]] = _flush_spend_on_shutdown,
) -> None:
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
await flush_spend()
verbose_proxy_logger.info("Draining success callbacks before the final spend flush")
try:
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=GracefulShutdownManager.get_timeout())
except TimeoutError:
verbose_proxy_logger.warning("Timed out draining success callbacks on shutdown; accounting may be incomplete")
finally:
try:
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.stop(discard_pending=True), timeout=5.0)
except TimeoutError:
verbose_proxy_logger.warning("Timed out cancelling success callbacks on shutdown")
await drain_logging_tasks()
await flush_spend()

View file

@ -1526,14 +1526,12 @@ def _schedule_async_success_logging(
def _enqueue_async_logging() -> None:
logging_obj.mark_async_success_log_enqueued()
asyncio.create_task(
_client_async_logging_helper(
logging_obj=logging_obj,
result=result,
start_time=start_time,
end_time=end_time,
is_completion_with_fallbacks=is_completion_with_fallbacks,
)
_client_async_logging_helper(
logging_obj=logging_obj,
result=result,
start_time=start_time,
end_time=end_time,
is_completion_with_fallbacks=is_completion_with_fallbacks,
)
if not logging_obj.defer_async_logging:
@ -1544,13 +1542,13 @@ def _schedule_async_success_logging(
logging_obj.enqueue_deferred_logging = _enqueue_async_logging
async def _client_async_logging_helper(
def _client_async_logging_helper(
logging_obj: LiteLLMLoggingObject,
result,
start_time,
end_time,
result: object,
start_time: datetime.datetime,
end_time: datetime.datetime,
is_completion_with_fallbacks: bool,
):
) -> None:
if (
is_completion_with_fallbacks is False
): # don't log the parent event litellm.completion_with_fallbacks as a 'log_success_event', this will lead to double logging the same call - https://github.com/BerriAI/litellm/issues/7477

View file

@ -1,3 +1,4 @@
import hashlib
import json
import os
import threading
@ -26,6 +27,154 @@ MODEL_INSERT_ARRIVED_LOG_LINE: Final = "path=/model/new"
COMMIT_DELAY_SECONDS: Final = 15
BURST_REQUESTS: Final = 30
_GATED_LOGGER: Final = """
import os
import httpx
from litellm.integrations.custom_logger import CustomLogger
class ShutdownLogger(CustomLogger):
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
async with httpx.AsyncClient(timeout=25, trust_env=False) as client:
await client.post(os.environ["SHUTDOWN_CALLBACK_URL"])
logger = ShutdownLogger()
"""
def _tag_shutdown_config(directory: Path, upstream: str, redis_buffer: bool, *, gated: bool = False) -> Path:
path: Final = directory / "tag-shutdown.yaml"
if gated:
(directory / "shutdown_callbacks.py").write_text(_GATED_LOGGER)
path.write_text(
yaml.safe_dump(
{
"model_list": [
{
"model_name": "shutdown-model",
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_base": upstream + "/v1",
"api_key": "synthetic-key",
"input_cost_per_token": 0.01,
"output_cost_per_token": 0.02,
},
}
],
"general_settings": {
"master_key": "os.environ/LITELLM_MASTER_KEY",
"database_url": "os.environ/DATABASE_URL",
"proxy_batch_write_at": 3600,
"use_redis_transaction_buffer": redis_buffer,
"disable_model_info_refresh": True,
},
"litellm_settings": {
"cache": True,
"cache_params": {"type": "redis", "host": "os.environ/REDIS_HOST", "port": "os.environ/REDIS_PORT"},
"callbacks": ["shutdown_callbacks.logger"] if gated else [],
},
}
)
)
return path
def _tagged_chat(gateway: Gateway, key: str, tags: tuple[str, str]) -> None:
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{
"model": "shutdown-model",
"messages": [{"role": "user", "content": uuid.uuid4().hex}],
"metadata": {"tags": list(tags)},
},
key=key,
)
assert response.status_code == 200, response.text
def _assert_shutdown_accounting(key: str, tags: tuple[str, str], requests: int) -> None:
hashed: Final = hashlib.sha256(key.encode()).hexdigest()
for table, count in (("LiteLLM_SpendLogs", "count(*)"), ("LiteLLM_DailyUserSpend", "sum(api_requests)")):
rows: Final = read_rows(
f'SELECT ({count})::int AS requests, sum(spend) AS spend FROM "{table}" WHERE api_key=%s', (hashed,)
)
assert rows[0]["requests"] == requests, (table, rows)
assert rows[0]["spend"] == pytest.approx(requests * 0.05, abs=1e-9), (table, rows)
key_rows: Final = read_rows('SELECT spend FROM "LiteLLM_VerificationToken" WHERE token=%s', (hashed,))
assert key_rows[0]["spend"] == pytest.approx(requests * 0.05, abs=1e-9)
for tag in tags:
tag_rows: Final = read_rows(
'SELECT sum(api_requests)::int AS requests, sum(spend) AS spend FROM "LiteLLM_DailyTagSpend" WHERE api_key=%s AND tag=%s',
(hashed, tag),
)
assert tag_rows[0]["requests"] == requests, (tag, tag_rows)
assert tag_rows[0]["spend"] == pytest.approx(requests * 0.05, abs=1e-9), (tag, tag_rows)
@pytest.mark.parametrize("redis_buffer", [False, True])
def test_tagged_request_survives_shutdown_before_the_tag_timer(
gateway: Gateway, tmp_path: Path, redis_buffer: bool
) -> None:
tags: Final = (f"shutdown-a-{uuid.uuid4().hex}", f"shutdown-b-{uuid.uuid4().hex}")
overrides: Final = {"DATABASE_URL": os.environ["DATABASE_URL"], "LITELLM_LOG": "DEBUG"}
with wire_server(_provider) as wire:
config: Final = _tag_shutdown_config(tmp_path, wire.url, redis_buffer)
with owned_proxy_process(gateway, tmp_path, overrides, config=config) as owned:
key: Final = string_value(
owned.gateway.post("/key/generate", {"user_id": f"shutdown-{uuid.uuid4().hex}"})["key"]
)
_tagged_chat(owned.gateway, key, tags)
owned.begin_shutdown()
owned.process.wait(timeout=90)
with owned_proxy_process(gateway, tmp_path, overrides, config=config) as restarted:
_assert_shutdown_accounting(key, tags, 1)
restarted.begin_shutdown()
restarted.process.wait(timeout=90)
_assert_shutdown_accounting(key, tags, 1)
@pytest.mark.parametrize("redis_buffer", [False, True])
def test_callback_backlog_is_accounted_before_shutdown_disconnects(
gateway: Gateway, tmp_path: Path, redis_buffer: bool
) -> None:
release: Final = threading.Event()
tags: Final = (f"backlog-a-{uuid.uuid4().hex}", f"backlog-b-{uuid.uuid4().hex}")
with wire_server(_provider) as wire, wire_server(lambda _: Reply(chunks=(b"{}",), gate_before_first=release)) as logger:
config: Final = _tag_shutdown_config(tmp_path, wire.url, redis_buffer, gated=True)
with owned_proxy_process(
gateway,
tmp_path,
{
"DATABASE_URL": os.environ["DATABASE_URL"],
"LOGGING_WORKER_CONCURRENCY": "2",
"SHUTDOWN_CALLBACK_URL": logger.url,
"GRACEFUL_SHUTDOWN_TIMEOUT": "15",
"LITELLM_LOG": "DEBUG",
},
config=config,
) as owned:
key: Final = string_value(
owned.gateway.post("/key/generate", {"user_id": f"backlog-{uuid.uuid4().hex}"})["key"]
)
try:
for _ in range(3):
_tagged_chat(owned.gateway, key, tags)
eventually(logger.received.qsize, lambda count: count == 2, seconds=10)
owned.begin_shutdown()
eventually(
lambda: (
owned.process.poll() is not None
or "Draining success callbacks before the final spend flush" in owned.log.read_text()
),
lambda ready: ready,
seconds=30,
)
release.set()
owned.process.wait(timeout=90)
_assert_shutdown_accounting(key, tags, 3)
finally:
release.set()
def _api_requests(table: str, column: str, identity: str) -> int:
rows: Final = read_rows(

View file

@ -82,6 +82,56 @@ class _RecordCollector(logging.Handler):
self.records.append(record)
@pytest.mark.asyncio
@pytest.mark.parametrize("running", [False, True])
async def test_stop_discards_waiting_callbacks_without_executing_them(running: bool) -> None:
worker: Final = LoggingWorker(concurrency=1)
started: Final = asyncio.Event()
cancelled: Final = asyncio.Event()
executed: Final = asyncio.Event()
async def blocked() -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
async def waiting() -> None:
executed.set()
if running:
worker.ensure_initialized_and_enqueue(blocked())
await started.wait()
else:
worker._ensure_queue()
worker.enqueue(waiting())
await worker.stop(discard_pending=True)
assert not executed.is_set()
assert cancelled.is_set() == running
assert worker._queue is not None
assert worker._queue.empty()
await asyncio.wait_for(worker._queue.join(), timeout=1)
@pytest.mark.asyncio
async def test_stop_does_not_replay_a_callback_dequeued_before_its_processor_started() -> None:
worker: Final = LoggingWorker(concurrency=1)
executed: Final = asyncio.Event()
async def callback() -> None:
executed.set()
worker.ensure_initialized_and_enqueue(callback())
await asyncio.sleep(0)
await worker.stop(discard_pending=True)
assert not executed.is_set()
worker._flush_on_exit()
assert not executed.is_set()
assert worker._queue is not None
await asyncio.wait_for(worker._queue.join(), timeout=1)
class TestLoggingWorker:
"""Test cases for LoggingWorker functionality."""

View file

@ -260,6 +260,131 @@ async def test_shutdown_commits_spend_a_logging_task_queued_while_the_first_comm
token = MASTER_KEY
@pytest.mark.asyncio
async def test_shutdown_persists_accounting_from_worker_callbacks_before_returning() -> None:
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.responses.streaming_iterator import spawn_logging_task
started: Final = asyncio.Event()
release: Final = asyncio.Event()
first_flush: Final = asyncio.Event()
pending: Final[asyncio.Queue[str]] = asyncio.Queue()
persisted: Final[asyncio.Queue[str]] = asyncio.Queue()
async def enqueue_spend() -> None:
pending.put_nowait("accounted request")
async def callback() -> None:
started.set()
await release.wait()
spawn_logging_task(asyncio.get_running_loop(), enqueue_spend(), task_name="accounting")
async def flush_spend() -> None:
while not pending.empty():
persisted.put_nowait(pending.get_nowait())
first_flush.set()
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(callback())
await started.wait()
shutdown: Final = asyncio.create_task(
litellm.proxy.proxy_server._flush_spend_around_logging_drain_on_shutdown(flush_spend)
)
try:
await first_flush.wait()
waited_for_callback: Final = not shutdown.done()
release.set()
await shutdown
assert waited_for_callback, "Shutdown returned while accounting callbacks were still running"
assert persisted.get_nowait() == "accounted request"
assert pending.empty()
finally:
release.set()
await GLOBAL_LOGGING_WORKER.flush()
await GLOBAL_LOGGING_WORKER.stop()
@pytest.mark.asyncio
async def test_shutdown_reports_worker_timeout_and_flushes_completed_accounting(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
monkeypatch.setenv("GRACEFUL_SHUTDOWN_TIMEOUT", "0")
started: Final = asyncio.Event()
cancelled: Final = asyncio.Event()
flushed_after_cancellation: Final = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
async def flush_spend() -> None:
if cancelled.is_set():
flushed_after_cancellation.set()
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(callback())
await started.wait()
try:
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
await litellm.proxy.proxy_server._flush_spend_around_logging_drain_on_shutdown(flush_spend)
assert cancelled.is_set()
assert flushed_after_cancellation.is_set()
assert "Timed out draining success callbacks" in caplog.text
finally:
await GLOBAL_LOGGING_WORKER.stop()
@pytest.mark.asyncio
async def test_shutdown_bounds_callback_cancellation_and_flushes_accounting(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
monkeypatch.setenv("GRACEFUL_SHUTDOWN_TIMEOUT", "0")
started: Final = asyncio.Event()
cancelling: Final = asyncio.Event()
cancelled: Final = asyncio.Event()
flushed: Final = asyncio.Event()
loop: Final = asyncio.get_running_loop()
clock: Final = loop.time
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancelling.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
async def flush_spend() -> None:
if cancelled.is_set():
flushed.set()
GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue(callback())
await started.wait()
try:
with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"):
shutdown: Final = asyncio.create_task(
litellm.proxy.proxy_server._flush_spend_around_logging_drain_on_shutdown(flush_spend)
)
await cancelling.wait()
monkeypatch.setattr(loop, "time", lambda: clock() + 6)
await shutdown
assert cancelled.is_set()
assert flushed.is_set()
assert "Timed out cancelling success callbacks" in caplog.text
finally:
monkeypatch.setattr(loop, "time", clock)
await GLOBAL_LOGGING_WORKER.stop()
headers = {"Authorization": f"Bearer {token}"}
example_completion_result = {

View file

@ -10882,3 +10882,43 @@ def test_get_optional_params_forwards_a_tool_config_cache_point_to_a_downstream_
)
assert optional_params["extra_body"] == {"kept": True, "cache_control_injection_points": [point]}
@pytest.mark.asyncio
@pytest.mark.parametrize("deferred", [False, True])
async def test_success_logging_is_enqueued_before_the_worker_is_flushed(deferred: bool) -> None:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.utils import _schedule_async_success_logging
completed: Final[asyncio.Queue[object]] = asyncio.Queue()
async def callback(kwargs: object, response: object, start: object, end: object) -> None:
completed.put_nowait(response)
now: Final = datetime(2026, 1, 1, tzinfo=timezone.utc)
logging_obj: Final = Logging(
model="synthetic-model",
messages=[],
stream=False,
call_type="acompletion",
start_time=now.timestamp(),
litellm_call_id="logging-enqueue",
function_id="logging-enqueue",
dynamic_async_success_callbacks=[callback],
)
logging_obj.defer_async_logging = deferred
result: Final = litellm.ModelResponse(id="accounted-response")
try:
_schedule_async_success_logging(logging_obj, result, now, now, False)
if deferred:
assert completed.empty()
assert logging_obj.enqueue_deferred_logging is not None
logging_obj.enqueue_deferred_logging()
await GLOBAL_LOGGING_WORKER.flush()
assert completed.get_nowait() is result
assert completed.empty()
finally:
await asyncio.sleep(0)
await GLOBAL_LOGGING_WORKER.flush()
await GLOBAL_LOGGING_WORKER.stop()