Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions src/tests/test_k8s_pod_ip_service_discovery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Unit tests for K8sPodIPServiceDiscovery._on_engine_update.

These tests focus on the pure event-handling logic of the pod-IP based
service discovery, in particular the MODIFIED branch that removes a stale
("ghost") endpoint when a pod's podIP is cleared by a node-pressure
eviction.

The real __init__ loads a kubeconfig and starts a watcher thread, so we
construct the instance with __new__ and inject only the attributes that
_on_engine_update touches.
"""

import threading
from unittest.mock import MagicMock

from vllm_router.service_discovery import EndpointInfo, K8sPodIPServiceDiscovery


def _make_discovery() -> K8sPodIPServiceDiscovery:
d = K8sPodIPServiceDiscovery.__new__(K8sPodIPServiceDiscovery)
d.available_engines = {}
d.available_engines_lock = threading.Lock()
d.known_models = set()
d.known_models_lock = threading.Lock()
d.namespace = "test-ns"
d.port = "8000"
return d


def _register(d: K8sPodIPServiceDiscovery, name: str) -> None:
d.available_engines[name] = MagicMock(spec=EndpointInfo)


def test_modified_with_none_ip_removes_registered_engine():
"""Core regression: an evicted pod delivers MODIFIED with podIP=None.

Before the fix this returned early and the endpoint lingered as a
ghost; after the fix the registered endpoint is removed.
"""
d = _make_discovery()
_register(d, "pod-a")

d._on_engine_update(
engine_name="pod-a",
engine_ip=None,
event="MODIFIED",
is_pod_ready=False,
model_names=[],
model_label=None,
)

assert "pod-a" not in d.available_engines


def test_modified_with_none_ip_unregistered_is_noop():
"""A Pending pod (not yet registered) with no IP must be skipped, not error."""
d = _make_discovery()

d._on_engine_update(
engine_name="pending-pod",
engine_ip=None,
event="MODIFIED",
is_pod_ready=False,
model_names=[],
model_label=None,
)

assert d.available_engines == {}


def test_added_with_none_ip_is_skipped():
"""ADDED with no IP (Pending pod) must not register anything.

Confirms the ADDED branch is unchanged by the fix.
"""
d = _make_discovery()
d._add_engine = MagicMock()

d._on_engine_update(
engine_name="pending-pod",
engine_ip=None,
event="ADDED",
is_pod_ready=False,
model_names=[],
model_label=None,
)

d._add_engine.assert_not_called()
assert d.available_engines == {}


def test_modified_ready_with_ip_adds_engine():
"""MODIFIED with a real IP + ready + models must (re)add the engine."""
d = _make_discovery()
d._add_engine = MagicMock()

d._on_engine_update(
engine_name="pod-b",
engine_ip="172.16.0.5",
event="MODIFIED",
is_pod_ready=True,
model_names=["Qwen2.5-7B"],
model_label="Qwen2.5-7B",
)

d._add_engine.assert_called_once_with(
"pod-b", "172.16.0.5", ["Qwen2.5-7B"], "Qwen2.5-7B"
)


def test_modified_not_ready_with_ip_removes_registered():
"""Graceful drain: a registered pod goes not-ready while its IP is still
present. The existing (pre-fix) removal path must still work.
"""
d = _make_discovery()
_register(d, "pod-c")

d._on_engine_update(
engine_name="pod-c",
engine_ip="172.16.0.9",
event="MODIFIED",
is_pod_ready=False,
model_names=[],
model_label=None,
)

assert "pod-c" not in d.available_engines
14 changes: 14 additions & 0 deletions src/vllm_router/service_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,20 @@ def _on_engine_update(

elif event == "MODIFIED":
if engine_ip is None:
# An empty IP is ambiguous: a Pending pod has not been
# assigned an IP yet (skip it), but an Evicted pod has had
# its podIP cleared by the kubelet. In the latter case the
# endpoint is already registered and must be removed, or it
# lingers as a stale ("ghost") backend that still receives
# traffic until the router process restarts. Complete the
# symmetric removal path here.
if engine_name in self.available_engines:
logger.warning(
f"Serving engine {engine_name} has an empty IP "
f"(likely evicted, podIP cleared by kubelet); "
f"removing it to avoid a stale (ghost) endpoint"
)
self._delete_engine(engine_name)
return

if is_pod_ready and model_names:
Expand Down