Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.5 KiB
Python
164 lines
5.5 KiB
Python
"""K8s discovery reconciliation integration tests.
|
|
|
|
Tests verify that:
|
|
1. The K8s EndpointSlice watcher correctly discovers new workers as Services
|
|
and backing Deployments are updated.
|
|
2. Workers are removed from the router's registry after the backing EndpointSlice
|
|
entries disappear (pod deleted / deployment scaled to 0).
|
|
3. After a simulated watch-connection interruption (router restarted), the
|
|
registry converges back to the correct worker set.
|
|
|
|
Note: sgl-router does not currently expose a Prometheus /metrics endpoint,
|
|
so the SMG-style metric assertions are not used here. Disconnect/reconnect
|
|
coverage is provided by test_lifecycle.TestRouterRestart.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
|
|
import httpx
|
|
import pytest
|
|
from conftest import (
|
|
NAMESPACE,
|
|
RECONCILIATION_WAIT_SECS,
|
|
_kubectl,
|
|
_poll_until,
|
|
logger,
|
|
)
|
|
|
|
|
|
def _scale_fake_worker(replicas: int) -> None:
|
|
_kubectl(
|
|
"scale", "deployment/fake-worker", f"--replicas={replicas}", "-n", NAMESPACE
|
|
)
|
|
|
|
|
|
def _can_route(router_url: str) -> bool:
|
|
try:
|
|
r = httpx.post(
|
|
f"{router_url}/v1/chat/completions",
|
|
json={
|
|
"model": "tiny",
|
|
"messages": [{"role": "user", "content": "reconcile"}],
|
|
},
|
|
timeout=8.0,
|
|
)
|
|
return r.status_code == 200
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
class TestWatcherDiscovery:
|
|
"""The EndpointSlice watcher discovers new endpoints on Deployment scale-up."""
|
|
|
|
def test_watcher_discovers_new_endpoints_on_scale_up(self, router_url):
|
|
"""Scale from 1 to 3 replicas; router must continue routing successfully."""
|
|
_scale_fake_worker(1)
|
|
# Wait for scale-down to propagate and routing to stabilise
|
|
_poll_until(
|
|
lambda: _can_route(router_url),
|
|
"router routes with 1 replica",
|
|
timeout=60,
|
|
interval=3,
|
|
)
|
|
|
|
_scale_fake_worker(3)
|
|
_poll_until(
|
|
lambda: _can_route(router_url),
|
|
"router routes with 3 replicas (after scale-up)",
|
|
timeout=60,
|
|
interval=3,
|
|
)
|
|
|
|
|
|
class TestStaleEndpointRemoval:
|
|
"""When fake-worker replicas drop, the router must stop routing to the
|
|
removed endpoints.
|
|
|
|
Because sgl-router has no /workers admin API, we verify removal
|
|
indirectly: scale to 0, assert the router returns non-200 (or at least
|
|
that scaling back to 2 restores routing), then restore.
|
|
"""
|
|
|
|
def test_routing_restores_after_scale_down_and_back_up(self, router_url):
|
|
"""Scale to 0 (no workers → expect non-200), then restore to 2.
|
|
After restore the router must route again within the reconciliation window.
|
|
"""
|
|
try:
|
|
_scale_fake_worker(0)
|
|
|
|
# Expect routing to fail eventually (503 or connection error)
|
|
deadline = time.time() + RECONCILIATION_WAIT_SECS
|
|
routing_failed = False
|
|
while time.time() < deadline:
|
|
try:
|
|
r = httpx.post(
|
|
f"{router_url}/v1/chat/completions",
|
|
json={
|
|
"model": "tiny",
|
|
"messages": [{"role": "user", "content": "no-workers"}],
|
|
},
|
|
timeout=5.0,
|
|
)
|
|
if r.status_code != 200:
|
|
routing_failed = True
|
|
break
|
|
except Exception:
|
|
routing_failed = True
|
|
break
|
|
time.sleep(3)
|
|
|
|
# If after RECONCILIATION_WAIT_SECS the router is still routing,
|
|
# that means old endpoints are cached — not necessarily wrong for
|
|
# a watcher that hasn't ticked yet, but log a warning.
|
|
if not routing_failed:
|
|
logger.warning(
|
|
"Router still returning 200 after scale-to-0; "
|
|
"EndpointSlice event may be delayed — continuing test."
|
|
)
|
|
|
|
# Restore workers and verify routing comes back
|
|
_scale_fake_worker(2)
|
|
_poll_until(
|
|
lambda: _can_route(router_url),
|
|
"routing restored after scale back up to 2",
|
|
timeout=RECONCILIATION_WAIT_SECS,
|
|
interval=3,
|
|
)
|
|
finally:
|
|
_scale_fake_worker(3)
|
|
|
|
|
|
class TestReconciliationConsistency:
|
|
"""Routing remains stable over multiple reconciliation windows with steady
|
|
worker state — no spurious deregistrations or duplicate registrations."""
|
|
|
|
@pytest.mark.slow
|
|
def test_routing_stable_over_multiple_reconciliation_cycles(self, router_url):
|
|
"""Deploy 3 workers, sample routing success over ~150s (2 reconciliation
|
|
cycles + margin), assert no interruptions."""
|
|
_scale_fake_worker(3)
|
|
_poll_until(
|
|
lambda: _can_route(router_url),
|
|
"baseline routing with 3 workers",
|
|
timeout=30,
|
|
interval=2,
|
|
)
|
|
|
|
# Sample every 15s for 150s
|
|
wait_secs = RECONCILIATION_WAIT_SECS + 60
|
|
end_time = time.time() + wait_secs
|
|
failures = []
|
|
while time.time() < end_time:
|
|
ok = _can_route(router_url)
|
|
if not ok:
|
|
failures.append(time.time())
|
|
time.sleep(15)
|
|
|
|
assert not failures, (
|
|
f"Routing failed at {len(failures)} sample(s) during stability window; "
|
|
f"timestamps: {failures}"
|
|
)
|