litellm/tests/integration/_support/workers.py
devin-ai-integration[bot] e16d456291
test(integration): isolate every multi-worker integration suite by default (#46027)
* test(integration): isolate every multi-worker integration suite by default

Per-worker database, Redis, upstream and proxy were opt-in for management,
accounting, providers and extensions. mcp, security and cost ran 4 to 8
xdist workers against one shared proxy, database and Redis, and those suites
hold most of the integration flakes CircleCI records. Every run with more than
one worker now builds a private stack per worker, run_integration.sh no longer
starts or exports the shared proxy, upstream, database or Redis in that mode,
and each worker's scripted upstream runs in its own process instead of a
thread in the pytest controller.

Worker gateways get what the shared proxy used to provide: a peer for
management and mcp, the scripted cost map for cost, stdio MCP servers, and
coverage for mcp. mcp coverage now measures lines through sys.monitoring,
because branch measurement on CPython 3.12 uses the trace function and that
made a covered proxy take about 4x as long to become ready, which is why the
shared mcp proxy kept missing its 90 s readiness wait. Routing parity
snapshots now also read the worker databases, since its cost and mcp runs use
several workers. The security per-worker fixture is redundant and removed

* test(integration): give the nested probe root the files a worker gateway needs

Two worker probes now run isolated, so the nested controller boots a real per-worker upstream and proxy from the probe root. That upstream imports the fake OpenAI server and the cost map modules, and the proxy reads proxy_config.yaml, none of which the probe copied. The nested run also drops INTEGRATION_COVERAGE_FILE so it never writes into an outer coverage file

* test(integration): budget nested multi-worker runs for their worker stack boots

A nested run.py database run with more than one worker now boots a private database, Redis, upstream and proxy per worker, one after another, before any case runs. The fixed 60 s subprocess timeout left no room for that, so the 2 worker probes sat at 57-60 s on CircleCI. The subprocess budget is now the old 60 s plus 30 s per worker stack, and the outer pytest timeout keeps its previous 30 s of slack on top. 30 s is the slowest worker proxy readiness recorded in startup.json on the integration-cost job of workflow e09b108b (21.6-30.3 s on 4 CPUs, 2026-10-12)

---------

Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
2026-10-12 03:46:06 -07:00

217 lines
8.8 KiB
Python

from __future__ import annotations
import os
import socket
import time
import uuid
from collections.abc import Iterator, Mapping
from contextlib import contextmanager, nullcontext
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Final, Protocol
from urllib.parse import urlsplit, urlunsplit
import httpx
import psycopg
from psycopg import sql
from pydantic import TypeAdapter
from redis import Redis
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
from tests.integration._support.client import Gateway
from tests.integration._support.database import scratch_database
from tests.integration._support.redis_process import owned_redis
from tests.integration._support.routing import WORKER_DATABASE_PREFIX
READINESS_OBJECT: Final = TypeAdapter(dict[str, object])
SUBSCRIBER_COUNTS: Final = TypeAdapter(tuple[tuple[str, int], ...])
class ServiceSubscribers(Protocol):
def pubsub_numsub(self, *channels: str) -> object: ...
def readiness_failure(
client: httpx.Client, upstream: str, proxies: tuple[Gateway, ...], cache: ServiceSubscribers
) -> str | None:
try:
if client.get(upstream + "/health", timeout=2).status_code != 200:
return "scripted upstream health"
for index, proxy in enumerate(proxies):
if proxy.client.get("/health/readiness", timeout=2).status_code != 200:
return f"proxy {index} readiness"
response: Final = proxy.client.get(
"/cache/ping", headers={"Authorization": f"Bearer {proxy.key}"}, timeout=2
)
if response.status_code != 200:
return f"proxy {index} Redis health HTTP {response.status_code}"
result: Final = READINESS_OBJECT.validate_json(response.content)
if (
result.get("status") != "healthy"
or result.get("cache_type") != "redis"
or result.get("ping_response") is not True
or result.get("set_cache_response") != "success"
):
return f"proxy {index} Redis read/write health"
subscribers: Final = SUBSCRIBER_COUNTS.validate_python(
cache.pubsub_numsub("litellm_proxy.auth_cache_invalidation")
)
if subscribers[0][1] < len(proxies):
return f"auth-cache subscribers {subscribers[0][1]}/{len(proxies)}"
except (httpx.TransportError, RedisConnectionError, RedisTimeoutError) as error:
return f"service transport {type(error).__name__}"
return None
def wait_for_services(upstream: str, proxies: tuple[Gateway, ...], cache: Redis) -> None:
with httpx.Client(trust_env=False, timeout=2) as client:
deadline: Final = time.monotonic() + 90
while True:
failure: Final = readiness_failure(client, upstream, proxies, cache)
if failure is None:
return
assert time.monotonic() < deadline, f"Integration services did not become ready: {failure}"
time.sleep(0.2)
def worker_endpoints() -> Mapping[str, str]:
with socket.socket() as upstream, socket.socket() as provider:
upstream.bind(("127.0.0.1", 0))
provider.bind(("127.0.0.1", 0))
return MappingProxyType(
{
"INTEGRATION_UPSTREAM_URL": f"http://127.0.0.1:{upstream.getsockname()[1]}",
"INTEGRATION_PROVIDER_PORT": str(provider.getsockname()[1]),
}
)
def _replica_urls(database: str) -> Mapping[str, str]:
writer: Final = urlsplit(os.environ["INTEGRATION_PROXY_DATABASE_URL"])
reader: Final = urlsplit(os.environ["INTEGRATION_PROXY_READ_REPLICA_URL"])
database_path: Final = urlsplit(database).path
assert writer.username is not None and reader.username is not None
with psycopg.connect(database, autocommit=True) as admin:
admin.execute(
sql.SQL("ALTER DATABASE {} OWNER TO {}").format(
sql.Identifier(database_path.removeprefix("/")), sql.Identifier(writer.username)
)
)
admin.execute(
sql.SQL("GRANT CONNECT ON DATABASE {} TO {}").format(
sql.Identifier(database_path.removeprefix("/")), sql.Identifier(reader.username)
)
)
admin.execute(sql.SQL("GRANT USAGE ON SCHEMA public TO {}").format(sql.Identifier(reader.username)))
admin.execute(
sql.SQL("ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA public GRANT SELECT ON TABLES TO {}").format(
sql.Identifier(writer.username), sql.Identifier(reader.username)
)
)
return MappingProxyType(
{
"INTEGRATION_PROXY_DATABASE_URL": urlunsplit(writer._replace(path=database_path)),
"INTEGRATION_PROXY_READ_REPLICA_URL": urlunsplit(reader._replace(path=database_path)),
}
)
@dataclass(frozen=True, slots=True)
class GatewayProfile:
peer: bool
replica: bool
cost_map: bool
coverage_file: Path | None
def gateway_profile(environment: Mapping[str, str]) -> GatewayProfile:
coverage: Final = environment.get("INTEGRATION_COVERAGE_FILE", "")
return GatewayProfile(
peer=environment.get("INTEGRATION_WORKER_PEER") == "1",
replica=bool(environment.get("INTEGRATION_PROXY_READ_REPLICA_URL")),
cost_map=environment.get("INTEGRATION_WORKER_COST_MAP") == "1",
coverage_file=Path(coverage) if coverage else None,
)
def cost_map_environment(upstream: str) -> Mapping[str, str]:
return MappingProxyType(
{
"LITELLM_MODEL_COST_MAP_URL": f"{upstream}/_cost_map",
"MODEL_COST_MAP_MIN_MODEL_COUNT": "1",
"MODEL_COST_MAP_MAX_SHRINK_RATIO": "0",
"GEMINI_API_BASE": upstream,
"ANTHROPIC_API_BASE": upstream,
"GEMINI_API_KEY": "sk-scripted-provider",
"ANTHROPIC_API_KEY": "sk-scripted-provider",
}
)
@contextmanager
def worker_services(directory: Path, *, replica: bool = False) -> Iterator[Mapping[str, str]]:
directory.mkdir(parents=True, exist_ok=True)
with (
scratch_database(WORKER_DATABASE_PREFIX) as database,
owned_redis(directory) as cache,
):
yield MappingProxyType(
{
"DATABASE_URL": database,
"INTEGRATION_PROXY_DATABASE_URL": database,
"INTEGRATION_PROXY_READ_REPLICA_URL": "",
"REDIS_HOST": cache.host,
"REDIS_PORT": str(cache.port),
**(_replica_urls(database) if replica else {}),
}
)
@contextmanager
def worker_gateway(directory: Path, profile: GatewayProfile) -> Iterator[Mapping[str, str]]:
from tests.integration._support.process import owned_proxy, owned_upstream
upstream: Final = os.environ["INTEGRATION_UPSTREAM_URL"]
port: Final = urlsplit(upstream).port
assert port is not None
key: Final = f"sk-{uuid.uuid4().hex}"
removed: Final = ("LITELLM_LOCAL_MODEL_COST_MAP",) if profile.cost_map else ()
with worker_services(directory, replica=profile.replica) as services:
overrides: Final = {
**services,
"DATABASE_URL": services["INTEGRATION_PROXY_DATABASE_URL"],
**(
{"DATABASE_URL_READ_REPLICA": services["INTEGRATION_PROXY_READ_REPLICA_URL"]} if profile.replica else {}
),
"LITELLM_ENABLE_MCP_STDIO": "true",
**(cost_map_environment(upstream) if profile.cost_map else {}),
}
with (
owned_upstream(directory, port=port),
httpx.Client(base_url="http://127.0.0.1:1", trust_env=False) as client,
owned_proxy(
Gateway(client, key, upstream),
directory,
overrides,
remove_environment=removed,
coverage_file=profile.coverage_file,
) as primary,
owned_proxy(primary, directory, overrides, remove_environment=removed, coverage_file=profile.coverage_file)
if profile.peer
else nullcontext(None) as secondary,
):
with Redis(host=services["REDIS_HOST"], port=int(services["REDIS_PORT"]), socket_timeout=2) as cache:
wait_for_services(upstream, (primary, secondary) if secondary is not None else (primary,), cache)
primary.post("/config/update", {"router_settings": {"num_retries": 0}})
yield MappingProxyType(
{
**services,
"INTEGRATION_PROXY_URL": str(primary.client.base_url),
"INTEGRATION_PEER_URL": str(secondary.client.base_url) if secondary is not None else "",
"INTEGRATION_MASTER_KEY": key,
"LITELLM_MASTER_KEY": key,
"INTEGRATION_UPSTREAM_URL": upstream,
}
)