litellm/tests/integration/_support/ordering.py
yuneng-jiang 9b53ee878f
perf(ci): isolate and shard integration suites (#44146)
* perf(ci): distribute unit cases and reuse security sweep connections

* revert(ci): reject first speed trial after new failures

* perf(ci): avoid repeated test discovery and parsing

* perf(ci): isolate parallel security workers

* revert(ci): restore measured security execution isolation

* perf(tests): reuse source scans and deduplicate cache cleanup

* perf(ci): restore parallel security with safe log batching

* test(ci): isolate fixture state and schema comparisons

* test(ci): make schema and scheduler fixtures deterministic

* test(ci): drain queued logging before fixture teardown

* test(ci): preserve logging delivery without idle worker restarts

* test(ci): keep guardrail sinks available during parallel bursts

* test(ci): isolate full proxy lifecycle from gateway imports

* test(ci): preserve first failure responses and sweep evidence

* test(ci): restore documentation URL settings between cases

* test(ci): isolate open MCP tools in private gateways

* test(ci): group private MCP cases and handle exited workers

* test(ci): retain proxy exits and MCP failure details

* test(ci): repair merge interactions with main

Key the schema shard's full clone on artifact-name, since main removed the
unit-flag input. Give each owned Redis its own log file again. Hand the
realtime transcription tests main added a logging object whose dispatch
handlers are awaitable, so the per-test logging drain no longer waits on a
MagicMock task forever

* perf(ci): shard owned integration cases and close lifecycle leaks

* fix(ci): qualify shard execution and harden interrupted cleanup

* fix(ci): bound worker supervision and own fault traffic

* perf(ci): reuse unparametrized fixtures across bounded case groups

* fix(ci): preserve boot fault allowances and complete exit records

* test(ci): tolerate startup retries and retain readiness details

* perf(ci): balance integration shards by recorded duration

* fix(ci): load migration database helpers only for workers
2026-10-10 11:15:19 -07:00

178 lines
6.6 KiB
Python

from __future__ import annotations
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from functools import reduce
from itertools import chain, groupby
from math import ceil, isfinite
from statistics import median
from types import MappingProxyType
from typing import Final
import pytest
from _pytest.python import get_direct_param_fixture_func
from pydantic import BaseModel, ConfigDict, Field, FiniteFloat, TypeAdapter, ValidationError
EMPTY_DURATIONS: Final[Mapping[str, float]] = MappingProxyType({})
class CaseTiming(BaseModel):
model_config = ConfigDict(frozen=True, strict=True)
nodeid: str = Field(min_length=1)
seconds: FiniteFloat = Field(ge=0)
TIMINGS: Final = TypeAdapter(tuple[CaseTiming, ...])
def parse_timings(content: str) -> Mapping[str, float]:
try:
values: Final = TIMINGS.validate_json(content)
except ValidationError:
return EMPTY_DURATIONS
durations: Final = {value.nodeid: max(value.seconds, 0.001) for value in values}
if len(durations) != len(values) or not isfinite(sum(durations.values())):
return EMPTY_DURATIONS
return MappingProxyType(durations)
@dataclass(frozen=True, slots=True)
class ScopedParameter:
owner: str
name: str
value: object
@dataclass(frozen=True, slots=True)
class CollectedCase:
nodeid: str
parameters: tuple[ScopedParameter, ...] = ()
fixtures: tuple[str, ...] = ()
def _parameter(item: pytest.Function, name: str, value: object) -> ScopedParameter | None:
definitions: Final = item._fixtureinfo.name2fixturedefs.get(name)
scope: Final = item.callspec._arg2scope[name].value
if not definitions or scope == "function" or definitions[-1].func is get_direct_param_fixture_func:
return None
parent: Final = item.getparent(pytest.Class) if scope == "class" else None
owner: Final = parent.nodeid if parent is not None else definitions[-1].baseid
return ScopedParameter(f"{scope}:{owner}", name, value)
def collected_case(item: pytest.Item) -> CollectedCase:
if not isinstance(item, pytest.Function):
return CollectedCase(item.nodeid)
fixtures: Final = tuple(
f"{definitions[-1].scope}:{definitions[-1].baseid}:{name}"
for name, definitions in item._fixtureinfo.name2fixturedefs.items()
if definitions
and definitions[-1].scope in ("module", "class")
and definitions[-1].baseid
and definitions[-1].func is not get_direct_param_fixture_func
)
parameters: Final = (
tuple(_parameter(item, name, value) for name, value in item.callspec.params.items())
if hasattr(item, "callspec")
else ()
)
scoped: Final = tuple(parameter for parameter in parameters if parameter is not None)
return CollectedCase(
item.nodeid,
tuple(sorted(scoped, key=lambda parameter: (parameter.owner, parameter.name))),
tuple(sorted(fixtures)),
)
def _equal(first: object, second: object) -> bool:
try:
return bool(first == second)
except (ValueError, RuntimeError):
return first is second
def _same_parameters(first: CollectedCase, second: CollectedCase) -> bool:
if len(first.parameters) != len(second.parameters):
return False
if not first.parameters and first.fixtures != second.fixtures:
return False
return all(
left.owner == right.owner and left.name == right.name and _equal(left.value, right.value)
for left, right in zip(first.parameters, second.parameters, strict=True)
)
def _file(node: CollectedCase) -> str:
return node.nodeid.split("::", 1)[0]
def _file_groups(cases: Iterable[CollectedCase]) -> tuple[tuple[str, str], ...]:
members: Final = tuple(cases)
return tuple(
(case.nodeid, min(other.nodeid for other in members if _same_parameters(case, other)))
if case.parameters or case.fixtures
else (case.nodeid, "")
for case in members
)
def fixture_groups(cases: tuple[CollectedCase, ...]) -> Mapping[str, str]:
files: Final = groupby(sorted(cases, key=_file), key=_file)
return MappingProxyType(dict(chain.from_iterable(_file_groups(members) for _, members in files)))
def _allocate(
shards: tuple[tuple[str, ...], ...], group: tuple[str, ...], durations: Mapping[str, float]
) -> tuple[tuple[str, ...], ...]:
index: Final = min(
range(len(shards)),
key=lambda candidate: (sum(durations[nodeid] for nodeid in shards[candidate]), len(shards[candidate])),
)
return tuple((*shard, *group) if candidate == index else shard for candidate, shard in enumerate(shards))
def _add_case(
groups: tuple[tuple[str, ...], ...], case: CollectedCase, maximum: float, durations: Mapping[str, float]
) -> tuple[tuple[str, ...], ...]:
if not groups:
return ((case.nodeid,),)
if sum(durations[nodeid] for nodeid in groups[-1]) + durations[case.nodeid] > maximum:
return (*groups, (case.nodeid,))
return (*groups[:-1], (*groups[-1], case.nodeid))
def _partition_group(
cases: Iterable[CollectedCase], maximum: float, durations: Mapping[str, float]
) -> tuple[tuple[str, ...], ...]:
members: Final = tuple(sorted(cases, key=lambda case: case.nodeid))
nodeids: Final = tuple(case.nodeid for case in members)
if members[0].parameters:
return (nodeids,)
return reduce(lambda groups, case: _add_case(groups, case, maximum, durations), members, ())
def case_shards(
cases: tuple[CollectedCase, ...], count: int, durations: Mapping[str, float] = EMPTY_DURATIONS
) -> tuple[frozenset[str], ...]:
if count < 1:
raise ValueError("Integration shard count must be positive")
groups: Final = fixture_groups(cases)
known: Final = tuple(durations[case.nodeid] for case in cases if case.nodeid in durations)
default: Final = max(float(median(known)), 0.001) if known else 1.0
weights: Final = MappingProxyType({case.nodeid: max(durations.get(case.nodeid, default), 0.001) for case in cases})
def key(case: CollectedCase) -> str:
return groups[case.nodeid] or case.nodeid
maximum: Final = sum(weights.values()) / count if known else float(max(1, ceil(len(cases) / count)))
members: Final = tuple(
chain.from_iterable(
_partition_group(group, maximum, weights) for _, group in groupby(sorted(cases, key=key), key=key)
)
)
ordered: Final = tuple(sorted(members, key=lambda group: (-sum(weights[nodeid] for nodeid in group), group[0])))
allocation: Final = reduce(
lambda shards, group: _allocate(shards, group, weights), ordered, tuple(() for _ in range(count))
)
return tuple(frozenset(shard) for shard in allocation)