[EPD] Make encoder register/unregister health-check robust (#31576)
Co-authored-by: siyu <liusy58@linux.alibaba.com>
This commit is contained in:
@@ -65,9 +65,10 @@ class EncoderBootstrapServer:
|
|||||||
accessible through :meth:`list_urls`.
|
accessible through :meth:`list_urls`.
|
||||||
|
|
||||||
Health-check tuning is controlled by env vars
|
Health-check tuning is controlled by env vars
|
||||||
``SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_INTERVAL`` (seconds; 0 disables)
|
``SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_INTERVAL`` (seconds; 0 disables),
|
||||||
and ``SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT`` (seconds). Explicit
|
``SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT`` (seconds), and
|
||||||
constructor args take precedence over the env vars.
|
``SGLANG_ENCODER_BOOTSTRAP_EVICTED_TTL`` (seconds; 0 keeps probing
|
||||||
|
forever). Explicit constructor args take precedence over the env vars.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -77,6 +78,7 @@ class EncoderBootstrapServer:
|
|||||||
urls: Optional[List[str]] = None,
|
urls: Optional[List[str]] = None,
|
||||||
health_check_interval: Optional[float] = None,
|
health_check_interval: Optional[float] = None,
|
||||||
health_check_timeout: Optional[float] = None,
|
health_check_timeout: Optional[float] = None,
|
||||||
|
evicted_ttl: Optional[float] = None,
|
||||||
):
|
):
|
||||||
|
|
||||||
self.host = host
|
self.host = host
|
||||||
@@ -94,8 +96,19 @@ class EncoderBootstrapServer:
|
|||||||
if health_check_timeout is not None
|
if health_check_timeout is not None
|
||||||
else envs.SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT.get()
|
else envs.SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT.get()
|
||||||
)
|
)
|
||||||
self._consecutive_failures: Dict[str, int] = {}
|
self._evicted_ttl = (
|
||||||
self._max_consecutive_failures = 3
|
evicted_ttl
|
||||||
|
if evicted_ttl is not None
|
||||||
|
else envs.SGLANG_ENCODER_BOOTSTRAP_EVICTED_TTL.get()
|
||||||
|
)
|
||||||
|
# Evict only after this many consecutive probe failures (a busy
|
||||||
|
# encoder can miss a single 2s probe under load), and keep probing
|
||||||
|
# evicted URLs so they re-register automatically once healthy.
|
||||||
|
# Values are eviction timestamps; URLs older than ``_evicted_ttl``
|
||||||
|
# (when > 0) are permanently dropped.
|
||||||
|
self._health_fail_threshold = 3
|
||||||
|
self._health_fail_counts: Dict[str, int] = {}
|
||||||
|
self._evicted_urls: Dict[str, float] = {}
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(fast_api_app: FastAPI):
|
async def lifespan(fast_api_app: FastAPI):
|
||||||
@@ -153,7 +166,8 @@ class EncoderBootstrapServer:
|
|||||||
def register(self, url: str) -> bool:
|
def register(self, url: str) -> bool:
|
||||||
"""Add *url* if not already present. Returns True if added."""
|
"""Add *url* if not already present. Returns True if added."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._consecutive_failures.pop(url, None)
|
self._health_fail_counts.pop(url, None)
|
||||||
|
self._evicted_urls.pop(url, None)
|
||||||
if url not in self._urls:
|
if url not in self._urls:
|
||||||
self._urls.append(url)
|
self._urls.append(url)
|
||||||
logger.info(f"Registered encoder URL: {url}")
|
logger.info(f"Registered encoder URL: {url}")
|
||||||
@@ -162,14 +176,20 @@ class EncoderBootstrapServer:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def unregister(self, url: str) -> bool:
|
def unregister(self, url: str) -> bool:
|
||||||
"""Remove *url* if present. Returns True if removed."""
|
"""Remove *url* if present. Returns True if removed.
|
||||||
|
|
||||||
|
An explicit unregister also drops the URL from the health-check
|
||||||
|
revival set so it does not come back automatically.
|
||||||
|
"""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
removed = url in self._urls or url in self._evicted_urls
|
||||||
if url in self._urls:
|
if url in self._urls:
|
||||||
self._urls.remove(url)
|
self._urls.remove(url)
|
||||||
self._consecutive_failures.pop(url, None)
|
self._evicted_urls.pop(url, None)
|
||||||
|
self._health_fail_counts.pop(url, None)
|
||||||
|
if removed:
|
||||||
logger.info(f"Unregistered encoder URL: {url}")
|
logger.info(f"Unregistered encoder URL: {url}")
|
||||||
return True
|
return removed
|
||||||
return False
|
|
||||||
|
|
||||||
def list_urls(self) -> List[str]:
|
def list_urls(self) -> List[str]:
|
||||||
"""Return a snapshot of all registered encoder URLs."""
|
"""Return a snapshot of all registered encoder URLs."""
|
||||||
@@ -187,42 +207,78 @@ class EncoderBootstrapServer:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _health_check_loop(self):
|
async def _health_check_loop(self):
|
||||||
"""Probe each registered encoder periodically and evict dead ones."""
|
"""Probe registered (and previously evicted) encoders periodically.
|
||||||
|
|
||||||
|
A URL is evicted only after ``_health_fail_threshold`` consecutive
|
||||||
|
probe failures — a busy encoder may miss a single short-timeout probe
|
||||||
|
under load. Evicted URLs keep being probed and re-register
|
||||||
|
automatically once they respond again. After ``_evicted_ttl`` seconds
|
||||||
|
without a successful probe (when > 0), they are permanently dropped
|
||||||
|
so a dead encoder does not get probed forever.
|
||||||
|
"""
|
||||||
|
|
||||||
timeout = ClientTimeout(total=self._health_check_timeout)
|
timeout = ClientTimeout(total=self._health_check_timeout)
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(self._health_check_interval)
|
await asyncio.sleep(self._health_check_interval)
|
||||||
snapshot = self.list_urls()
|
now = time.time()
|
||||||
if not snapshot:
|
with self._lock:
|
||||||
|
expired = []
|
||||||
|
if self._evicted_ttl > 0:
|
||||||
|
expired = [
|
||||||
|
url
|
||||||
|
for url, ts in self._evicted_urls.items()
|
||||||
|
if now - ts >= self._evicted_ttl
|
||||||
|
]
|
||||||
|
for url in expired:
|
||||||
|
self._evicted_urls.pop(url, None)
|
||||||
|
self._health_fail_counts.pop(url, None)
|
||||||
|
candidates = list(
|
||||||
|
dict.fromkeys(self._urls + list(self._evicted_urls))
|
||||||
|
)
|
||||||
|
if expired:
|
||||||
|
logger.warning(
|
||||||
|
f"Health check permanently dropped {len(expired)} "
|
||||||
|
f"encoder(s) after {self._evicted_ttl}s unhealthy: "
|
||||||
|
f"{expired}"
|
||||||
|
)
|
||||||
|
if not candidates:
|
||||||
continue
|
continue
|
||||||
async with ClientSession(timeout=timeout) as session:
|
async with ClientSession(timeout=timeout) as session:
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*(self._probe(session, url) for url in snapshot),
|
*(self._probe(session, url) for url in candidates),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
evicted = []
|
evicted, revived = [], []
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for url, ok in zip(snapshot, results):
|
for url, ok in zip(candidates, results):
|
||||||
if ok is True:
|
if ok is True:
|
||||||
self._consecutive_failures.pop(url, None)
|
self._health_fail_counts.pop(url, None)
|
||||||
|
if url in self._evicted_urls:
|
||||||
|
self._evicted_urls.pop(url, None)
|
||||||
|
if url not in self._urls:
|
||||||
|
self._urls.append(url)
|
||||||
|
revived.append(url)
|
||||||
else:
|
else:
|
||||||
self._consecutive_failures[url] = (
|
if url in self._evicted_urls:
|
||||||
self._consecutive_failures.get(url, 0) + 1
|
continue
|
||||||
)
|
count = self._health_fail_counts.get(url, 0) + 1
|
||||||
if (
|
self._health_fail_counts[url] = count
|
||||||
self._consecutive_failures[url]
|
if count >= self._health_fail_threshold:
|
||||||
>= self._max_consecutive_failures
|
|
||||||
):
|
|
||||||
if url in self._urls:
|
if url in self._urls:
|
||||||
self._urls.remove(url)
|
self._urls.remove(url)
|
||||||
self._consecutive_failures.pop(url, None)
|
self._evicted_urls[url] = now
|
||||||
|
self._health_fail_counts.pop(url, None)
|
||||||
evicted.append(url)
|
evicted.append(url)
|
||||||
|
if revived:
|
||||||
|
logger.info(
|
||||||
|
f"Health check revived {len(revived)} encoder(s): {revived}"
|
||||||
|
)
|
||||||
if evicted:
|
if evicted:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Health check evicted {len(evicted)} encoder(s) "
|
f"Health check evicted {len(evicted)} encoder(s) after "
|
||||||
f"after {self._max_consecutive_failures} consecutive "
|
f"{self._health_fail_threshold} consecutive failures "
|
||||||
f"failures: {evicted}"
|
f"(will re-add when healthy): {evicted}"
|
||||||
)
|
)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1123,6 +1123,8 @@ class Envs:
|
|||||||
# EncoderBootstrapServer health-check tuning. Interval == 0 disables it.
|
# EncoderBootstrapServer health-check tuning. Interval == 0 disables it.
|
||||||
SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_INTERVAL = EnvFloat(10.0)
|
SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_INTERVAL = EnvFloat(10.0)
|
||||||
SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT = EnvFloat(2.0)
|
SGLANG_ENCODER_BOOTSTRAP_HEALTH_CHECK_TIMEOUT = EnvFloat(2.0)
|
||||||
|
# Seconds before permanently dropping an unhealthy encoder (0 = keep probing).
|
||||||
|
SGLANG_ENCODER_BOOTSTRAP_EVICTED_TTL = EnvFloat(600.0)
|
||||||
# Persistent receiver-side GPU embedding pool size for mooncake EPD transport.
|
# Persistent receiver-side GPU embedding pool size for mooncake EPD transport.
|
||||||
# 0 disables (per-request register/deregister). 4096 = 4GB default per TP
|
# 0 disables (per-request register/deregister). 4096 = 4GB default per TP
|
||||||
SGLANG_EMBEDDING_POOL_SIZE_MB = EnvInt(4096)
|
SGLANG_EMBEDDING_POOL_SIZE_MB = EnvInt(4096)
|
||||||
|
|||||||
Reference in New Issue
Block a user