mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
* test(integration): build worker stacks concurrently and record proxy boot phases The controller built every xdist worker's database, Redis, upstream and gateway proxy one after another before any test ran, about 95 s per extensions shard, and since every multi-worker suite is now isolated the same serial wait hits mcp, security and cost too. The stacks now start on a thread pool bounded by the CPU count. Each stack reads its endpoints and results directory from an explicit environment instead of a patched os.environ, every stack that started is registered for cleanup, and a failure in one stops the others before the error is raised Owned proxy startup.json now records the wall-clock spawn time, the worker count and when the Prisma CLI child ran, when the port started accepting and when the first worker answered readiness, and exit.json records when the stop started and ended, so the boot time can be split and overlapping gateway boots can be seen in the artifacts * test(integration): boot one uvicorn worker where a test does not assert cross-worker behaviour A 2-worker owned proxy takes about twice as long to become ready as a 1-worker one, because the uvicorn parent imports the app and each spawned worker imports it again. The passthrough upstream error visibility cases, the single-request S3 dedupe cases and the single-request spend log payload cases send one or two requests and assert on the response, the proxy log and the spend row, none of which depends on a second worker, so they now boot the default single worker Cases that do rely on two workers keep them, the concurrent S3 burst through a sink outage, previous_response_id replay, response cache hits, the concurrent spend bursts and upstream stop and recovery * test(observability): settle OTel excluded-services traces by a quiet period instead of a full window _trace_spans slept through its whole 15 or 30 s window on every call even when the trace had long stopped growing. It now polls and returns once the trace has spans and has not gained one for five tenant export intervals, and the module pins the tenant batch exporter's schedule delay to 1 s so that bound is set by the test rather than by the SDK default. Absence checks still run only after the trace stopped growing, and against a build that leaks postgres spans to the tenant they still fail These cases also boot one uvicorn worker now. None of them asserts anything about a second worker --------- Co-authored-by: Yuneng Jiang <yuneng@berri.ai>
228 lines
8.1 KiB
Python
228 lines
8.1 KiB
Python
import os
|
|
import shutil
|
|
import signal
|
|
import socket
|
|
import subprocess
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable, Iterator, Mapping
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Final, TextIO
|
|
|
|
from redis import Redis
|
|
from redis.exceptions import ConnectionError as RedisConnectionError
|
|
|
|
PORT_ATTEMPTS: Final = 5
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PortUnavailable:
|
|
reason: str
|
|
|
|
|
|
def free_loopback_port() -> int:
|
|
with socket.socket() as reservation:
|
|
reservation.bind(("127.0.0.1", 0))
|
|
return reservation.getsockname()[1]
|
|
|
|
|
|
@dataclass
|
|
class OwnedRedis:
|
|
host: str
|
|
port: int
|
|
command: tuple[str, ...]
|
|
log: TextIO
|
|
pid_file: str
|
|
process: subprocess.Popen | None = None
|
|
server_pid: int | None = None
|
|
|
|
def serves(self) -> bool:
|
|
with Redis(host=self.host, port=self.port, socket_connect_timeout=0.2, socket_timeout=0.2) as client:
|
|
try:
|
|
return bool(client.ping())
|
|
except RedisConnectionError:
|
|
return False
|
|
|
|
def owned_pid(self) -> int | None:
|
|
assert self.process is not None
|
|
if self.command[0] != "docker":
|
|
return self.process.pid
|
|
result: Final = subprocess.run(
|
|
["docker", "exec", "redis-cache", "cat", self.pid_file],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
if result.returncode != 0:
|
|
return None
|
|
return int(result.stdout)
|
|
|
|
def start(self) -> None:
|
|
unavailable: Final = self.try_start()
|
|
assert unavailable is None, unavailable.reason
|
|
|
|
def try_start(self) -> PortUnavailable | None:
|
|
assert self.process is None
|
|
if self.serves():
|
|
return PortUnavailable(f"Another Redis already serves {self.host}:{self.port} before the owned one starts")
|
|
process: Final = subprocess.Popen(
|
|
self.command, stdout=self.log, stderr=subprocess.STDOUT, start_new_session=True
|
|
)
|
|
self.process = process
|
|
deadline: Final = time.monotonic() + 8
|
|
with Redis(host=self.host, port=self.port, socket_connect_timeout=0.2, socket_timeout=0.2) as client:
|
|
while True:
|
|
code: Final = process.poll()
|
|
if code is not None:
|
|
self.process = None
|
|
return PortUnavailable(
|
|
f"Owned Redis exited with code {code} before readiness on {self.host}:{self.port}"
|
|
)
|
|
try:
|
|
if client.ping():
|
|
actual: Final = int(client.info("server")["process_id"])
|
|
expected: Final = self.owned_pid()
|
|
if actual == expected:
|
|
self.server_pid = actual
|
|
return None
|
|
try:
|
|
process.wait(timeout=max(deadline - time.monotonic(), 0))
|
|
except subprocess.TimeoutExpired as error:
|
|
raise AssertionError("Redis readiness reached a different process") from error
|
|
self.process = None
|
|
return PortUnavailable(f"A different Redis answered on {self.host}:{self.port} during startup")
|
|
except RedisConnectionError:
|
|
pass
|
|
assert time.monotonic() < deadline, "Owned Redis readiness deadline exceeded"
|
|
time.sleep(0.05)
|
|
|
|
def stop(self) -> None:
|
|
assert self.process is not None
|
|
failure = None
|
|
forced = False
|
|
try:
|
|
if self.process.poll() is None:
|
|
with Redis(host=self.host, port=self.port, socket_connect_timeout=1, socket_timeout=1) as client:
|
|
assert int(client.info("server")["process_id"]) == self.server_pid, (
|
|
"Redis ownership changed before shutdown"
|
|
)
|
|
client.shutdown(nosave=True)
|
|
except Exception as error:
|
|
failure = error
|
|
finally:
|
|
try:
|
|
self.process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
forced = True
|
|
self.signal(signal.SIGTERM)
|
|
try:
|
|
self.process.wait(timeout=5)
|
|
except subprocess.TimeoutExpired:
|
|
self.signal(signal.SIGKILL)
|
|
self.process.wait(timeout=3)
|
|
self.process = None
|
|
self.server_pid = None
|
|
assert not self.serves(), "Owned Redis still serves after shutdown"
|
|
assert failure is None and not forced, f"Owned Redis required shutdown recovery: {failure!r}"
|
|
|
|
def signal(self, action: signal.Signals) -> None:
|
|
assert self.process is not None
|
|
if self.command[0] != "docker":
|
|
self.process.send_signal(action)
|
|
return
|
|
pid: Final = int(subprocess.check_output(["docker", "exec", "redis-cache", "cat", self.pid_file], timeout=2))
|
|
command: Final = subprocess.check_output(
|
|
["docker", "exec", "redis-cache", "cat", f"/proc/{pid}/cmdline"], timeout=2
|
|
)
|
|
assert self.pid_file.encode() in command, "Redis process ownership changed"
|
|
subprocess.run(["docker", "exec", "redis-cache", "kill", f"-{int(action)}", str(pid)], check=True, timeout=2)
|
|
|
|
@contextmanager
|
|
def paused(self) -> Iterator[None]:
|
|
self.signal(signal.SIGSTOP)
|
|
try:
|
|
yield
|
|
finally:
|
|
self.signal(signal.SIGCONT)
|
|
|
|
|
|
@contextmanager
|
|
def _started(
|
|
build: Callable[[int], OwnedRedis],
|
|
choose_port: Callable[[], int],
|
|
attempts: int,
|
|
log_name: str,
|
|
) -> Iterator[OwnedRedis]:
|
|
server: Final = build(choose_port())
|
|
try:
|
|
unavailable: Final = server.try_start()
|
|
if unavailable is None:
|
|
yield server
|
|
return
|
|
assert attempts > 1, (
|
|
f"Owned Redis found no usable port after {PORT_ATTEMPTS} attempts: {unavailable.reason} (log {log_name})"
|
|
)
|
|
with _started(build, choose_port, attempts - 1, log_name) as started:
|
|
yield started
|
|
finally:
|
|
if server.process is not None:
|
|
server.stop()
|
|
|
|
|
|
@contextmanager
|
|
def owned_redis(
|
|
directory: Path,
|
|
choose_port: Callable[[], int] = free_loopback_port,
|
|
inherited: Mapping[str, str] = os.environ,
|
|
) -> Iterator[OwnedRedis]:
|
|
binary: Final = shutil.which("redis-server")
|
|
if binary:
|
|
host: Final = "127.0.0.1"
|
|
prefix: Final = (binary,)
|
|
else:
|
|
host: Final = subprocess.check_output(
|
|
[
|
|
"docker",
|
|
"inspect",
|
|
"--format",
|
|
"{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}",
|
|
"redis-cache",
|
|
],
|
|
text=True,
|
|
).strip()
|
|
assert host, "CircleCI owned Redis container has no address"
|
|
prefix: Final = ("docker", "exec", "redis-cache", "redis-server")
|
|
output: Final = Path(inherited.get("INTEGRATION_RESULTS_DIR", str(directory)))
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
with (output / f"owned-redis-{uuid.uuid4().hex}.log").open("w") as log:
|
|
|
|
def build(port: int) -> OwnedRedis:
|
|
pid_file: Final = (
|
|
str(directory / f"owned-redis-{port}.pid")
|
|
if binary
|
|
else f"/tmp/integration-redis-{uuid.uuid4().hex}.pid"
|
|
)
|
|
command: Final = (
|
|
*prefix,
|
|
"--port",
|
|
str(port),
|
|
"--set-proc-title",
|
|
"no",
|
|
"--pidfile",
|
|
pid_file,
|
|
"--bind",
|
|
"0.0.0.0" if not binary else "127.0.0.1",
|
|
"--protected-mode",
|
|
"no",
|
|
"--save",
|
|
"",
|
|
"--appendonly",
|
|
"no",
|
|
)
|
|
return OwnedRedis(host, port, command, log, pid_file)
|
|
|
|
with _started(build, choose_port, PORT_ATTEMPTS, log.name) as server:
|
|
yield server
|