litellm/tests/e2e/perf/test_reliability_memory_e2e.py
yuneng-jiang b18188dee2
test(e2e): retire the quiet_stack lock and batch the claude_code model registration (#49984)
* test(e2e): retire the quiet_stack lock

quiet_stack held an exclusive host-wide lock for six tests, draining every
other xdist worker while they ran. On the full e2e lane that left 35-38% of
worker time idle. Each of those tests now lives where it can run without
pausing the suite

- the two memory tests move to perf/ behind the perf marker, so the perf lane
  runs them on its own fresh stack. Their refusing deployments use a literal
  key because the perf stack carries no provider keys
- the stored-request size check from the memory test becomes an integration
  test on an owned proxy that stores prompts
- the discount and margin spend-row tests are dropped from e2e. Their
  breakdown assertions join the existing integration pricing test, which
  already owns the discount and margin repricing flow
- the Bedrock guardrail cost test stays in e2e without the pricing reset
  fixture, since nothing else on the stack writes pricing config now
- the MCP semantic tool filter test turns on a proxy-wide setting, so it gets
  a new proxy_wide_config opt-in marker and runs after the main run

* test(e2e): register the claude_code deployments as one batch

The claude_code session fixture registered its 24 deployments one at a time on every xdist worker, and each one waited out the 15 s propagation window on its own, about 6 minutes per worker. ProxyClient.register_models posts the whole batch, polls each model until every replica serves it, then waits out propagation once from the last write. A model that fails to post or never becomes servable comes back as ModelNotRegistered, and an unservable one is deleted, so the fixture keeps reporting per-deployment failures the way it did
2026-11-04 09:55:53 -08:00

354 lines
16 KiB
Python

"""Live e2e: a few hundred requests that fail before any provider answers must not
grow the proxy's resident memory past a loose ceiling once the proxy is warm. It reads
the proxy's own RSS, so it runs in the perf lane, where nothing else is hitting the stack.
The regression this guards shipped in v1.100.0: every retry breadcrumb copied the
whole request and the copies nested into one router-global list, so a proxy under
retry-heavy failing traffic grew until it was OOM-killed. The traffic here has that
shape: a model group whose deployments refuse at the socket (an unreachable base
URL, with a literal key since the perf stack carries no provider keys) with
cooldown_time 0 so the router keeps retrying them, per-request retries, and a
fallback group that refuses the same way, each request carrying a long chat
transcript so every whole-request copy costs hundreds of containers instead of a
handful. Under the stack's cooldown policy a deployment that fails a handful of
times in a row is benched (a bad-credential 401 included), the router answers "No
deployments available" without retrying, and the retry loop that leaks stops running;
cooldown_time 0 keeps it running.
Four identical batches run back to back. The first two are the warmup that grows
the proxy's caches and allocator arenas, the last two are the measured window, and
each worker's RSS may grow at most a loose ceiling over that window. RSS cannot be
held to a tight per-batch budget: on the x86 release gate (rc/1.107.0 and v1.105.1
on isolated stacks, 28 batches each) the allocator kept setting new high-water marks
for the first several batches, about 60 MB on the third batch of a fresh stack and
more after the rest of the suite, with the gc-tracked object count flat to 0.1%, and
only settled to a couple of MB per batch after twenty. So the ceiling catches a gross
leak and nothing slower. RSS is read through /debug/memory/summary on every
configured replica; a burst of failing calls leaves a transient bulge of garbage that
gc reclaims within seconds, so each checkpoint samples until no new worker has
answered for a settle window and keeps the lowest reading per worker. A checkpoint
runs before the warmup and after every batch, and each batch's settled growth per
worker rides out as a repeated `rss_batch_growth` JUnit property as soon as that
batch settles, so a run that fails keeps the curve up to the batch it failed in. The
ceiling is judged per worker (by replica address, hostname and pid, since pods in
their own pid namespaces report the same pids) so each worker is compared with
itself, and the checkpoints around the measured window must see the same workers: a
single load-balanced address reaches the workers behind it one answer at a time, and
a worker that answered only one checkpoint would otherwise drop out of the
comparison, which is where a leaking worker could hide.
The stored-request half of that regression (every failing request's spend log carrying a
copy of the request per failed attempt) is a deterministic size check, so it lives in
tests/integration/spend/test_failing_request_stored_snapshot.py.
A cheaper check holds the idle footprint: every worker's RSS as the harness
read it at collection time, before this pytest process sent any traffic (see
conftest.pytest_collection_finish), must sit under a fixed budget. On the
release gate that is a fresh stack right after its readiness gate, one worker per
gateway replica, so the reading is what a DB-backed boot costs on its own. A
v1.100.x worker with a database idled at 886 MB RSS where v1.101.0rc1 idled at
544 MB on the same database (1.3 GB against 560 MB at the pod level, under a
2 GiB limit): the generated Prisma client at prisma-client-py's default recursive
type depth, 91k TypedDict classes that v1.101.0 cut to 19k with
recursive_type_depth = -1. The budget starts at the rc1 reading plus headroom and
E2E_MEMORY_IDLE_RSS_BUDGET_MB overrides it; a later session on the same stack (the
changed-files workflow's repeat passes, a developer's local loop) measures a proxy
already warmed by traffic, which that headroom also has to cover.
"""
from __future__ import annotations
import json
import time
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final
import pytest
from e2e_config import (
MEMORY_CONCURRENCY,
MEMORY_IDLE_RSS_BUDGET_MB,
MEMORY_REQUESTS_PER_PHASE,
MEMORY_RETRIES_PER_REQUEST,
MEMORY_RSS_CEILING_MB,
MEMORY_RSS_SAMPLE_INTERVAL_SECONDS,
MEMORY_RSS_SETTLE_SAMPLES,
MEMORY_TRANSCRIPT_TURNS,
PROXY_REPLICA_URLS,
unique_marker,
)
from e2e_metadata import Domain, Mode, Provider, Subject, meta
from lifecycle import ResourceManager
from memory_readings import RssCapture, RssReading, WorkerKey, read_rss_everywhere
from models import ChatMessage, LiteLLMParamsBody, ReliabilityChatBody, RouterSettingsOverride
from proxy_client import ProxyClient
pytestmark = [pytest.mark.e2e, pytest.mark.perf]
REFUSING_MODEL: Final = "openai/gpt-5.5"
DEPLOYMENTS_PER_GROUP: Final = 2
RSS_SAMPLE_CAP: Final = 4 * MEMORY_RSS_SETTLE_SAMPLES
WARMUP_BATCHES: Final = 2
MEASURED_BATCHES: Final = 2
FIRST_MEASURED_BATCH: Final = WARMUP_BATCHES + 1
LAST_BATCH: Final = WARMUP_BATCHES + MEASURED_BATCHES
@dataclass(frozen=True, slots=True)
class FailedCall:
status_code: int
seconds: float
body_head: str
@dataclass(frozen=True, slots=True)
class WorkerGrowth:
warm: RssReading
after: RssReading
@property
def growth_mb(self) -> float:
return self.after.ram_usage_mb - self.warm.ram_usage_mb
def _create_never_benched_refusing_deployment(proxy: ProxyClient, name: str) -> str:
return proxy.create_model(
name,
LiteLLMParamsBody(
model=REFUSING_MODEL, api_key="sk-unreachable", api_base="http://127.0.0.1:9/v1", cooldown_time=0
),
)
def _register_refusing_group(proxy: ProxyClient, resources: ResourceManager, name: str) -> None:
for model_id in tuple(_create_never_benched_refusing_deployment(proxy, name) for _ in range(DEPLOYMENTS_PER_GROUP)):
resources.defer(lambda model_id=model_id: proxy.delete_model(model_id))
def _transcript(turns: int) -> tuple[ChatMessage, ...]:
return tuple(
ChatMessage(role=role, content=f"turn {turn} {role}")
for turn in range(turns)
for role in ("user", "assistant")
)
TRANSCRIPT: Final = _transcript(MEMORY_TRANSCRIPT_TURNS)
def _fail_once(proxy: ProxyClient, key: str, model: str, override: RouterSettingsOverride) -> FailedCall:
started: Final = time.perf_counter()
resp: Final = proxy.transport.send(
"/chat/completions",
headers=proxy.transport.bearer(key),
json=ReliabilityChatBody(
model=model,
messages=[*TRANSCRIPT, ChatMessage(role="user", content=f"memory regression {unique_marker()}")],
max_tokens=512,
router_settings_override=override,
cache={"no-cache": True},
),
)
return FailedCall(resp.status_code, time.perf_counter() - started, resp.body[:300])
def _fail_many(proxy: ProxyClient, key: str, model: str, override: RouterSettingsOverride) -> tuple[FailedCall, ...]:
with ThreadPoolExecutor(max_workers=MEMORY_CONCURRENCY) as pool:
futures: Final = tuple(
pool.submit(_fail_once, proxy, key, model, override) for _ in range(MEMORY_REQUESTS_PER_PHASE)
)
return tuple(future.result() for future in futures)
def _read_rss_everywhere_after_pause(proxy: ProxyClient) -> tuple[RssReading, ...]:
time.sleep(MEMORY_RSS_SAMPLE_INTERVAL_SECONDS)
capture: Final = read_rss_everywhere(proxy)
assert not capture.failures, (
f"{len(capture.failures)} replica(s) gave no RSS reading mid-checkpoint, so their workers cannot be "
f"compared with themselves: {'; '.join(capture.failures)}"
)
return capture.readings
def _readings_until_no_new_worker(
proxy: ProxyClient, readings: tuple[RssReading, ...], samples: int, samples_since_new_worker: int
) -> tuple[RssReading, ...]:
if samples >= RSS_SAMPLE_CAP or samples_since_new_worker >= MEMORY_RSS_SETTLE_SAMPLES:
return readings
sample: Final = _read_rss_everywhere_after_pause(proxy)
known: Final = frozenset(reading.worker for reading in readings)
new_worker_answered: Final = any(reading.worker not in known for reading in sample)
return _readings_until_no_new_worker(
proxy, readings + sample, samples + 1, 0 if new_worker_answered else samples_since_new_worker + 1
)
def _settled_rss_per_worker(proxy: ProxyClient) -> Mapping[WorkerKey, RssReading]:
readings: Final = _readings_until_no_new_worker(proxy, (), 0, 0)
assert readings, "no /debug/memory/summary read carried ram_usage_mb, so the proxy cannot report its RSS"
return MappingProxyType(
{
worker: min((reading for reading in readings if reading.worker == worker), key=lambda r: r.ram_usage_mb)
for worker in {reading.worker for reading in readings}
}
)
def _record_batch_growth(
record_property: Callable[[str, object], None],
batch: int,
before: Mapping[WorkerKey, RssReading],
after: Mapping[WorkerKey, RssReading],
) -> None:
for growth in (WorkerGrowth(before[worker], after[worker]) for worker in before.keys() & after.keys()):
record_property(
"rss_batch_growth",
json.dumps(
{
"batch": batch,
"worker": growth.warm.where,
"before_mb": growth.warm.ram_usage_mb,
"after_mb": growth.after.ram_usage_mb,
"growth_mb": round(growth.growth_mb, 2),
}
),
)
def _settled_rss_after_batches(
proxy: ProxyClient,
key: str,
model: str,
override: RouterSettingsOverride,
fallback: str,
record_property: Callable[[str, object], None],
before: Mapping[WorkerKey, RssReading],
batch: int,
last_batch: int,
) -> Mapping[WorkerKey, RssReading]:
calls: Final = _fail_many(proxy, key, model, override)
_assert_every_call_failed_through_fallback(calls, fallback)
after: Final = _settled_rss_per_worker(proxy)
_record_batch_growth(record_property, batch, before, after)
if batch == last_batch:
return after
return _settled_rss_after_batches(
proxy, key, model, override, fallback, record_property, after, batch + 1, last_batch
)
def _heaviest_worker_growth(
warm: Mapping[WorkerKey, RssReading], after: Mapping[WorkerKey, RssReading]
) -> WorkerGrowth:
assert warm.keys() == after.keys(), (
f"the workers answering /debug/memory/summary changed around measured batches {FIRST_MEASURED_BATCH}-"
f"{LAST_BATCH}, so not every "
f"worker can be compared with itself: gone after it {sorted(warm.keys() - after.keys())} (a worker "
f"that died or was restarted under failing traffic, which is what an OOM kill looks like), first seen "
f"after it {sorted(after.keys() - warm.keys())} (the warm window never reached them, so they have no "
f"baseline; raise E2E_MEMORY_RSS_SETTLE_SAMPLES if the stack has more workers than the window covers)"
)
return max((WorkerGrowth(warm[worker], after[worker]) for worker in warm), key=lambda growth: growth.growth_mb)
def _assert_every_call_failed_through_fallback(calls: Sequence[FailedCall], fallback: str) -> None:
served: Final = tuple(call for call in calls if call.status_code == 200)
assert not served, (
f"{len(served)} of {len(calls)} calls came back 200, so they reached a provider and never "
f"exercised the retry loop: {served[0].body_head}"
)
without_fallback: Final = tuple(call for call in calls if fallback not in call.body_head)
assert not without_fallback, (
f"{len(without_fallback)} of {len(calls)} failures never named the fallback group {fallback}, "
f"so the request did not run through retries into the fallback: {without_fallback[0].body_head}"
)
class TestReliabilityMemory:
@pytest.mark.covers("reliability.perf.idle_memory.under_slo")
@meta(
Subject(
domain=Domain.DEPLOY_OPS,
)
)
def test_workers_idle_under_rss_budget_before_traffic(self, idle_rss: RssCapture) -> None:
assert not idle_rss.failures, (
f"{len(idle_rss.failures)} replica(s) gave no RSS reading when the session started, so their idle "
f"footprint went unmeasured: {'; '.join(idle_rss.failures)}"
)
unmeasured: Final = frozenset(PROXY_REPLICA_URLS) - frozenset(reading.replica for reading in idle_rss.readings)
assert not unmeasured, (
f"{len(unmeasured)} of {len(PROXY_REPLICA_URLS)} replica(s) gave neither an RSS reading nor a failure "
f"reason when the session started, so their idle footprint went unmeasured: {', '.join(sorted(unmeasured))}"
)
heaviest: Final = idle_rss.heaviest
assert heaviest is not None, "no replica was configured to read, so nothing was measured"
assert heaviest.ram_usage_mb <= MEMORY_IDLE_RSS_BUDGET_MB, (
f"{heaviest.where} sat at {heaviest.ram_usage_mb:.0f} MB RSS when the session started, before it sent "
f"any traffic, past the {MEMORY_IDLE_RSS_BUDGET_MB:.0f} MB idle budget; a DB-backed v1.100.x worker idled "
f"at 886 MB where v1.101.0rc1 idled at 544 MB, and at that size the release stack's 2 GiB pod limit "
f"leaves the worker little room for traffic"
)
@pytest.mark.filterwarnings("ignore:record_property is incompatible with junit_family")
@pytest.mark.covers("reliability.perf.memory.under_slo")
@meta(
Subject(
domain=Domain.ROUTING,
providers=(Provider.OPENAI,),
models=(REFUSING_MODEL,),
mode=Mode.NONSTREAM,
)
)
def test_failing_requests_do_not_grow_rss(
self,
proxy: ProxyClient,
resources: ResourceManager,
scoped_key: str,
record_property: Callable[[str, object], None],
) -> None:
marker: Final = unique_marker()
primary: Final = f"reliability-memory-{marker}"
fallback: Final = f"reliability-memory-fb-{marker}"
_register_refusing_group(proxy, resources, primary)
_register_refusing_group(proxy, resources, fallback)
override: Final = RouterSettingsOverride(
num_retries=MEMORY_RETRIES_PER_REQUEST, fallbacks=[{primary: [fallback]}]
)
warm: Final = _settled_rss_after_batches(
proxy,
scoped_key,
primary,
override,
fallback,
record_property,
_settled_rss_per_worker(proxy),
1,
WARMUP_BATCHES,
)
after: Final = _settled_rss_after_batches(
proxy,
scoped_key,
primary,
override,
fallback,
record_property,
warm,
FIRST_MEASURED_BATCH,
LAST_BATCH,
)
heaviest: Final = _heaviest_worker_growth(warm, after)
assert heaviest.growth_mb <= MEMORY_RSS_CEILING_MB, (
f"proxy RSS grew {heaviest.growth_mb:.1f} MB over measured batches {FIRST_MEASURED_BATCH}-{LAST_BATCH} "
f"({MEASURED_BATCHES * MEMORY_REQUESTS_PER_PHASE} failing requests) after {WARMUP_BATCHES} identical "
f"warmup batches, past the {MEMORY_RSS_CEILING_MB:.0f} MB ceiling: worker pid {heaviest.warm.worker_pid} on "
f"{heaviest.warm.hostname or 'an unnamed host'} behind {heaviest.warm.replica} settled at "
f"{heaviest.warm.ram_usage_mb:.1f} MB warm and {heaviest.after.ram_usage_mb:.1f} MB after; failing "
f"requests are leaking memory the way the v1.100.0 retry breadcrumbs did"
)