mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-26 06:03:19 +00:00
* test(e2e): event_loop_stalls perf case reads the gateway's asyncio debug log and fails on a loop hold over 100 ms that is not on its allowlist The gateway runs the stdlib loop in debug mode (new LITELLM_EVENT_LOOP=asyncio knob next to PYTHONASYNCIODEBUG=1), the case replays the mixed body mix at 1 rps and groups asyncio's 'Executing <Task ...> took X seconds' lines by coroutine and location. Known holds are listed with the count they show today; anything else, or a known one past its count, fails the case. Opt-in through E2E_PERF_EVENT_LOOP=1 so the nightly build skips it * test(e2e): event_loop_stalls reads the pod list and pod logs through e2e_http instead of a raw httpx client * test(e2e): event_loop_stalls charges a hold to garbage collection when the gateway's debug-mode gc log accounts for it The proxy registers a gc.callbacks hook only when the event loop runs in debug mode and logs every collection over the loop's slow-callback threshold (generation, collected count, seconds). The case parser pairs such a pass with the hold line that follows it and reports the hold under a bounded garbage collection bucket instead of the task asyncio happened to blame, which made 7 of 10 local runs fail on a different task name each time. Holds the collector does not account for keep the strict allowlist, which gains the prisma first-query warm-up and the second pub/sub subscriber * test(e2e): event_loop_stalls allowlist caps set at twice the ten-run maximum --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
153 lines
6.5 KiB
Python
153 lines
6.5 KiB
Python
"""One perf run per `PerformanceTestCase` in `cases/`: check the gateway was deployed with the case's
|
|
model groups and guardrails, build its teams, users and keys, replay its deterministic plan from a separate Locust
|
|
process against the out-of-process mock providers, then check every `Limit` against what the
|
|
clients saw, what the mocks received and how the workers' memory moved."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from datetime import UTC, datetime
|
|
from typing import Final
|
|
|
|
import pytest
|
|
from cases import CASES
|
|
from idp import Keycloak
|
|
from lifecycle import ResourceManager
|
|
from memory_readings import read_rss_everywhere
|
|
from models import SpendLogsParams
|
|
from proxy_client import ProxyClient
|
|
from setup.datastore import read_db_counters, read_redis_counters, redis_monitor
|
|
from setup.event_loop import (
|
|
LOG_FILE_ENV,
|
|
OPT_IN_ENV,
|
|
StallRow,
|
|
offenders,
|
|
parse_stalls,
|
|
read_gateway_log,
|
|
render_stalls,
|
|
summarize,
|
|
)
|
|
from setup.observe import (
|
|
Observation,
|
|
evaluate,
|
|
junit_properties,
|
|
read_guardrail_calls,
|
|
read_mock_requests,
|
|
render,
|
|
reset_mock,
|
|
set_mock_latency,
|
|
)
|
|
from setup.performance_test_case import PerformanceTestCase
|
|
from setup.plan import build_plan
|
|
from setup.podcpu import gateway_cpu_sampler
|
|
from setup.provision import MOCK_PROVIDERS, build_setup, guardrail_url, mock_url
|
|
from setup.run import run_plan
|
|
|
|
pytestmark = [pytest.mark.perf, pytest.mark.quiet_stack]
|
|
_SPEND_SAMPLE: Final = 40
|
|
|
|
|
|
def _spend_logged_share(proxy: ProxyClient, call_ids: tuple[str, ...]) -> float | None:
|
|
"""Share of a sample of served requests whose spend row the gateway wrote, read back by the
|
|
request id the gateway returned in `x-litellm-call-id`."""
|
|
if not call_ids:
|
|
return None
|
|
found = 0
|
|
for call_id in call_ids:
|
|
rows = proxy.spend_logs(SpendLogsParams(request_id=call_id))
|
|
found += 1 if rows else 0
|
|
return found / len(call_ids)
|
|
|
|
|
|
def _event_loop_rows(case: PerformanceTestCase, since: datetime) -> tuple[StallRow, ...]:
|
|
"""The gateway's asyncio debug holds for the traffic window; a case that asks for them cannot pass without a log."""
|
|
assert case.event_loop is not None
|
|
lines: Final = read_gateway_log(since)
|
|
assert lines is not None, (
|
|
f"{case.name} needs the gateway log: run in the perf lane (pods/log with the runner service account) or set "
|
|
f"{LOG_FILE_ENV} to the gateway's log file"
|
|
)
|
|
return summarize(case.event_loop, parse_stalls(lines))
|
|
|
|
|
|
@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES])
|
|
def test_perf_case(
|
|
case: PerformanceTestCase,
|
|
proxy: ProxyClient,
|
|
idp: Keycloak,
|
|
resources: ResourceManager,
|
|
request: pytest.FixtureRequest,
|
|
) -> None:
|
|
if case.event_loop is not None and not os.environ.get(OPT_IN_ENV):
|
|
pytest.skip(
|
|
f"{case.name} reads asyncio debug output: deploy the gateway with PYTHONASYNCIODEBUG=1 and "
|
|
f"LITELLM_EVENT_LOOP=asyncio, then set {OPT_IN_ENV}=1"
|
|
)
|
|
plan: Final = build_plan(case)
|
|
mock_urls: Final = {provider: mock_url(provider) for provider in MOCK_PROVIDERS}
|
|
for url in (*mock_urls.values(), guardrail_url()):
|
|
_ = reset_mock(url)
|
|
for url in mock_urls.values():
|
|
_ = set_mock_latency(url, case.mock_latency)
|
|
setup_started: Final = time.monotonic()
|
|
setup: Final = build_setup(case, proxy=proxy, idp=idp, resources=resources)
|
|
rss_before: Final = read_rss_everywhere(proxy)
|
|
db_before: Final = read_db_counters()
|
|
redis_before: Final = read_redis_counters()
|
|
run_started: Final = time.monotonic()
|
|
traffic_started_at: Final = datetime.now(tz=UTC)
|
|
with gateway_cpu_sampler() as cpu, redis_monitor() as monitor:
|
|
outcome: Final = run_plan(case, plan, setup)
|
|
run_finished: Final = time.monotonic()
|
|
db_after: Final = read_db_counters()
|
|
redis_after: Final = read_redis_counters()
|
|
rss_after: Final = read_rss_everywhere(proxy)
|
|
rss_read: Final = time.monotonic()
|
|
assert outcome.records, f"locust wrote no records (exit {outcome.locust_exit}):\n{outcome.locust_output}"
|
|
served_call_ids: Final = tuple(
|
|
record.call_id for record in outcome.records if record.ok and not record.blocked and record.call_id
|
|
)
|
|
spend_share: Final = _spend_logged_share(
|
|
proxy, served_call_ids[:: max(1, len(served_call_ids) // _SPEND_SAMPLE)][:_SPEND_SAMPLE]
|
|
)
|
|
spend_read: Final = time.monotonic()
|
|
mock_requests: Final = tuple(seen for url in mock_urls.values() for seen in read_mock_requests(url))
|
|
guardrail_calls: Final = tuple(read_guardrail_calls(guardrail_url())) if case.guardrails else ()
|
|
mocks_read: Final = time.monotonic()
|
|
observation: Final = Observation(
|
|
records=outcome.records,
|
|
mock_requests=mock_requests,
|
|
guardrail_calls=guardrail_calls,
|
|
rss_before=rss_before,
|
|
rss_after=rss_after,
|
|
wall_seconds=outcome.wall_seconds,
|
|
spend_logged_share=spend_share,
|
|
db_before=db_before,
|
|
db_after=db_after,
|
|
redis_before=redis_before,
|
|
redis_after=redis_after,
|
|
redis_commands=monitor.commands,
|
|
gateway_cpu_seconds=cpu.seconds,
|
|
)
|
|
rows, exceeded_limits = evaluate(case, observation)
|
|
stall_rows: Final = _event_loop_rows(case, traffic_started_at) if case.event_loop is not None else ()
|
|
item: Final = request.node # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] # pytest: untyped
|
|
assert isinstance(item, pytest.Item)
|
|
item.user_properties[:] = [
|
|
entry for entry in item.user_properties if not entry[0].startswith("perf_")
|
|
] # rebind-ok: a --reruns retry replaces the first attempt's numbers instead of appending a second set
|
|
item.user_properties.extend(junit_properties(rows))
|
|
print(render(case, observation, rows))
|
|
if case.event_loop is not None:
|
|
print(render_stalls(case.event_loop, stall_rows))
|
|
print(
|
|
f" phases: setup {run_started - setup_started:.0f}s, locust {run_finished - run_started:.0f}s, "
|
|
f"observe {time.monotonic() - run_finished:.0f}s (rss {rss_read - run_finished:.0f}s, spend sample "
|
|
f"{spend_read - rss_read:.0f}s, mock logs {mocks_read - spend_read:.0f}s); locust exit {outcome.locust_exit}"
|
|
)
|
|
assert len(outcome.records) == len(plan), (
|
|
f"{len(plan)} planned, {len(outcome.records)} recorded; locust said:\n{outcome.locust_output}"
|
|
)
|
|
assert not exceeded_limits, "limits exceeded (observed > max):\n" + "\n".join(exceeded_limits)
|
|
assert not offenders(stall_rows), "event-loop holds over the threshold:\n" + "\n".join(offenders(stall_rows))
|