mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
* test(integration): add read-replica routing harness * refactor(integration): hoist the maintenance url imports * fix(integration): keep per-test databases and the witness sequence readable under replica roles * fix(integration): opt bespoke database and pool tests out of the injected read replica * test(integration): commit recorded replica routing expectations * fix(integration): judge routing by role containment so shrinking role sets do not fail * fix(integration): run the pool-limit shutdown choreography on the superuser database url * ci(integration): add the mcp group to the replica matrix * fix(integration): judge routing by exact role sets with a named either-role allowlist * test(integration): drop containment-era routing expectations for re-recording * chore(integration): drop docstrings from the replica harness scripts * docs(integration): describe exact routing matching and the either-role list * test(integration): record exact replica routing expectations * test(integration): allow the SELECT 1 health probe on either role * test(integration): replace committed routing expectations with an on-demand base-vs-head parity run * test(integration): fix parity env scope, readme wording, and seed-deterministic serialization test * test(integration): make the sorted-role serialization test deterministic in-process * test(integration): swap all product code in parity runs and pin role gains * ci(integration): force tracked-file removal before parity checkout --------- Co-authored-by: yuneng <yuneng@berri.ai>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
import itertools
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections.abc import Iterator, Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from types import MappingProxyType
|
|
from typing import Final
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import psycopg
|
|
import pytest
|
|
from pydantic import TypeAdapter
|
|
|
|
WRITER_ROLE: Final = "litellm_writer"
|
|
READER_ROLE: Final = "litellm_reader"
|
|
ROLES: Final = (READER_ROLE, WRITER_ROLE)
|
|
DATABASE_NAME: Final = "circle_test"
|
|
OBSERVED_FILE: Final = "routing-observed.json"
|
|
DIFF_FILE: Final = "routing-diff.txt"
|
|
EITHER_ROLE_FILE: Final = Path(__file__).resolve().parents[1] / "routing" / "either_role.json"
|
|
|
|
RoleSet = frozenset[str]
|
|
RoutingMap = Mapping[str, frozenset[str]]
|
|
Snapshot = Mapping[tuple[str, str], int]
|
|
|
|
_PLACEHOLDERS: Final = re.compile(r"\$\d+(?:\s*,\s*\$\d+)*")
|
|
_QUERIES: Final = TypeAdapter(dict[str, tuple[str, ...]])
|
|
_OBSERVED: Final = TypeAdapter(dict[str, object])
|
|
|
|
|
|
def normalize(query: str) -> str:
|
|
return _PLACEHOLDERS.sub("$n", " ".join(query.split()))
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Observation:
|
|
queries: RoutingMap
|
|
tests: Mapping[str, RoutingMap]
|
|
calls: Mapping[str, int]
|
|
dealloc: int
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Mismatch:
|
|
test: str | None
|
|
query: str
|
|
base: tuple[str, ...]
|
|
head: tuple[str, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Report:
|
|
mismatches: tuple[Mismatch, ...]
|
|
only_base: tuple[str, ...]
|
|
only_head: tuple[str, ...]
|
|
calls: Mapping[str, Mapping[str, int]]
|
|
dealloc: Mapping[str, int]
|
|
either_role: tuple[str, ...] = ()
|
|
|
|
def failures(self) -> tuple[str, ...]:
|
|
mismatch_failures: Final = tuple(
|
|
f"{mismatch.test if mismatch.test is not None else 'global'}: {mismatch.query}: "
|
|
f"base [{', '.join(mismatch.base)}] head [{', '.join(mismatch.head)}]"
|
|
for mismatch in self.mismatches
|
|
)
|
|
side_failures: Final = tuple(
|
|
failure
|
|
for side in ("base", "head")
|
|
for failure in (
|
|
*(
|
|
(f"{side}: pg_stat_statements evicted {self.dealloc[side]} entries (dealloc > 0)",)
|
|
if self.dealloc[side] > 0
|
|
else ()
|
|
),
|
|
*(f"{side}: no {role} calls observed" for role in ROLES if self.calls[side].get(role, 0) == 0),
|
|
)
|
|
)
|
|
return (*mismatch_failures, *side_failures)
|
|
|
|
|
|
def _sorted_map(value: RoutingMap) -> RoutingMap:
|
|
return MappingProxyType(dict(sorted(value.items())))
|
|
|
|
|
|
def compare(base: Observation, head: Observation, either_role: frozenset[str] = frozenset()) -> Report:
|
|
mismatches: Final = (
|
|
*(
|
|
Mismatch(
|
|
None,
|
|
query,
|
|
tuple(sorted(base_roles)),
|
|
tuple(sorted(head.queries[query])),
|
|
)
|
|
for query, base_roles in base.queries.items()
|
|
if query in head.queries and head.queries[query] != base_roles and query not in either_role
|
|
),
|
|
*(
|
|
Mismatch(
|
|
test,
|
|
query,
|
|
tuple(sorted(base_roles)),
|
|
tuple(sorted(head.tests[test][query])),
|
|
)
|
|
for test, queries in base.tests.items()
|
|
if test in head.tests
|
|
for query, base_roles in queries.items()
|
|
if query in head.tests[test] and head.tests[test][query] != base_roles and query not in either_role
|
|
),
|
|
)
|
|
varying: Final = frozenset(
|
|
query
|
|
for query in either_role
|
|
if (query in base.queries and query in head.queries and head.queries[query] != base.queries[query])
|
|
or any(
|
|
query in base.tests[test]
|
|
and query in head.tests[test]
|
|
and head.tests[test][query] != base.tests[test][query]
|
|
for test in frozenset(base.tests) & frozenset(head.tests)
|
|
)
|
|
)
|
|
return Report(
|
|
mismatches,
|
|
tuple(sorted(query for query in base.queries if query not in head.queries)),
|
|
tuple(sorted(query for query in head.queries if query not in base.queries)),
|
|
MappingProxyType({"base": base.calls, "head": head.calls}),
|
|
MappingProxyType({"base": base.dealloc, "head": head.dealloc}),
|
|
tuple(sorted(varying)),
|
|
)
|
|
|
|
|
|
def render(report: Report) -> str:
|
|
failures: Final = report.failures()
|
|
lines: Final = (
|
|
"== failures ==",
|
|
*(failures or ("none",)),
|
|
"",
|
|
"== either role ==",
|
|
*(report.either_role or ("none",)),
|
|
"",
|
|
"== queries only in base ==",
|
|
*(report.only_base or ("none",)),
|
|
"",
|
|
"== queries only in head ==",
|
|
*(report.only_head or ("none",)),
|
|
"",
|
|
"== calls ==",
|
|
*(
|
|
line
|
|
for side in ("base", "head")
|
|
for line in (
|
|
*(f"{side} {role}: {report.calls[side].get(role, 0)}" for role in ROLES),
|
|
f"{side} dealloc: {report.dealloc[side]}",
|
|
)
|
|
),
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _roles(document: Mapping[str, tuple[str, ...]]) -> RoutingMap:
|
|
return _sorted_map({query: frozenset(roles) for query, roles in document.items()})
|
|
|
|
|
|
def _tests(document: Mapping[str, Mapping[str, tuple[str, ...]]]) -> Mapping[str, RoutingMap]:
|
|
return MappingProxyType({node: _roles(queries) for node, queries in document.items()})
|
|
|
|
|
|
def load_observation(path: Path) -> Observation:
|
|
document: Final = _OBSERVED.validate_python(json.loads(path.read_text()))
|
|
queries: Final = _QUERIES.validate_python(document.get("queries", {}))
|
|
tests: Final = TypeAdapter(dict[str, dict[str, tuple[str, ...]]]).validate_python(document.get("tests", {}))
|
|
calls: Final = TypeAdapter(dict[str, int]).validate_python(document.get("calls", {}))
|
|
dealloc: Final = TypeAdapter(int).validate_python(document.get("dealloc", 0))
|
|
return Observation(_roles(queries), _tests(tests), MappingProxyType(calls), dealloc)
|
|
|
|
|
|
def load_either_role(path: Path) -> frozenset[str]:
|
|
if not path.exists():
|
|
return frozenset()
|
|
document: Final = TypeAdapter(dict[str, str]).validate_python(json.loads(path.read_text()))
|
|
return frozenset(document)
|
|
|
|
|
|
def _serializable(queries: RoutingMap, tests: Mapping[str, RoutingMap]) -> dict[str, object]:
|
|
return {
|
|
"queries": {query: sorted(roles) for query, roles in queries.items()},
|
|
"tests": {node: {query: sorted(roles) for query, roles in mapping.items()} for node, mapping in tests.items()},
|
|
}
|
|
|
|
|
|
def dump_observation(observation: Observation) -> str:
|
|
document: Final = _serializable(observation.queries, observation.tests)
|
|
return (
|
|
json.dumps(
|
|
{**document, "calls": dict(observation.calls), "dealloc": observation.dealloc},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
def _maintenance_url() -> str:
|
|
parsed: Final = urlsplit(os.environ["DATABASE_URL"])
|
|
return urlunsplit(parsed._replace(path="/postgres"))
|
|
|
|
|
|
def snapshot(connection: psycopg.Connection[object]) -> Mapping[tuple[str, str], int]:
|
|
rows: Final = connection.execute(
|
|
"""
|
|
SELECT r.rolname, s.query, s.calls
|
|
FROM pg_stat_statements s
|
|
JOIN pg_roles r ON r.oid = s.userid
|
|
WHERE s.dbid = (SELECT oid FROM pg_database WHERE datname = %s)
|
|
AND r.rolname = ANY(%s)
|
|
""",
|
|
(DATABASE_NAME, list(ROLES)),
|
|
).fetchall()
|
|
return MappingProxyType(
|
|
{
|
|
key: sum(calls for _, _, calls in grouped)
|
|
for key, grouped in itertools.groupby(
|
|
sorted((str(role), normalize(str(query)), int(calls)) for role, query, calls in rows),
|
|
key=lambda row: (row[0], row[1]),
|
|
)
|
|
}
|
|
)
|
|
|
|
|
|
def delta(before: Snapshot, after: Snapshot) -> RoutingMap:
|
|
pairs: Final = {key: after.get(key, 0) - before.get(key, 0) for key in frozenset(before) | frozenset(after)}
|
|
queries: Final = frozenset(query for (_, query), change in pairs.items() if change > 0)
|
|
return MappingProxyType(
|
|
{query: frozenset(role for role in ROLES if pairs.get((role, query), 0) > 0) for query in sorted(queries)}
|
|
)
|
|
|
|
|
|
def role_calls(before: Snapshot, after: Snapshot) -> Mapping[str, int]:
|
|
return MappingProxyType(
|
|
{
|
|
role: sum(
|
|
max(after.get((role, query), 0) - before.get((role, query), 0), 0)
|
|
for query in frozenset(q for _, q in before) | frozenset(q for _, q in after)
|
|
)
|
|
for role in ROLES
|
|
}
|
|
)
|
|
|
|
|
|
def dealloc(connection: psycopg.Connection[object]) -> int:
|
|
return int(connection.execute("SELECT dealloc FROM pg_stat_statements_info").fetchone()[0])
|
|
|
|
|
|
class RoutingPlugin:
|
|
def __init__(self, config: pytest.Config) -> None:
|
|
self.config = config
|
|
self._session_start: Snapshot | None = None
|
|
self._tests: tuple[tuple[str, RoutingMap], ...] = ()
|
|
|
|
def _snapshot(self) -> Snapshot:
|
|
with psycopg.connect(_maintenance_url(), autocommit=True) as connection:
|
|
return snapshot(connection)
|
|
|
|
def pytest_sessionstart(self, session: pytest.Session) -> None:
|
|
if hasattr(self.config, "workerinput"):
|
|
return
|
|
self._session_start = self._snapshot()
|
|
|
|
@pytest.hookimpl(hookwrapper=True)
|
|
def pytest_runtest_protocol(self, item: pytest.Item, nextitem: pytest.Item | None) -> Iterator[None]:
|
|
if self.config.getoption("numprocesses", default=None) or hasattr(self.config, "workerinput"):
|
|
yield
|
|
return
|
|
before: Final = self._snapshot()
|
|
yield
|
|
after: Final = self._snapshot()
|
|
self._tests = (*self._tests, (item.nodeid, delta(before, after)))
|
|
|
|
def pytest_sessionfinish(self, session: pytest.Session, exitstatus: int) -> None:
|
|
if hasattr(self.config, "workerinput"):
|
|
return
|
|
end: Final = self._snapshot()
|
|
with psycopg.connect(_maintenance_url(), autocommit=True) as connection:
|
|
evictions: Final = dealloc(connection)
|
|
start: Final = self._session_start or {}
|
|
tests: Final = MappingProxyType({node: mapping for node, mapping in self._tests})
|
|
destination: Final = Path(os.environ["INTEGRATION_RESULTS_DIR"])
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
(destination / OBSERVED_FILE).write_text(
|
|
dump_observation(
|
|
Observation(
|
|
delta(start, end),
|
|
tests,
|
|
role_calls(start, end),
|
|
evictions,
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def main(argv: tuple[str, ...] | list[str]) -> int:
|
|
parser: Final = argparse.ArgumentParser()
|
|
parser.add_argument("command", choices=("check",))
|
|
parser.add_argument("base_dir", type=Path)
|
|
parser.add_argument("head_dir", type=Path)
|
|
parser.add_argument("--either-role", type=Path, default=EITHER_ROLE_FILE)
|
|
parser.add_argument("--diff", type=Path, default=None)
|
|
options: Final = parser.parse_args(argv)
|
|
base_path: Final = options.base_dir / OBSERVED_FILE
|
|
head_path: Final = options.head_dir / OBSERVED_FILE
|
|
for path in (base_path, head_path):
|
|
if not path.exists():
|
|
sys.stderr.write(f"observed routing file missing: {path}\n")
|
|
if not base_path.exists() or not head_path.exists():
|
|
return 1
|
|
report: Final = compare(
|
|
load_observation(base_path),
|
|
load_observation(head_path),
|
|
load_either_role(options.either_role),
|
|
)
|
|
diff: Final = render(report)
|
|
(options.diff or options.head_dir.parent / DIFF_FILE).write_text(diff)
|
|
sys.stdout.write(diff)
|
|
return 1 if report.failures() else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|