-
Notifications
You must be signed in to change notification settings - Fork 345
feat: judge endpoint resiliency #2383
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,7 +19,7 @@ | |
| import logging | ||
| import os | ||
| from copy import deepcopy | ||
| from time import time, time_ns | ||
| from time import monotonic, time, time_ns | ||
| from typing import Any, ClassVar, Dict, List, Optional, Union | ||
|
|
||
| from aiohttp.client_exceptions import ClientResponseError | ||
|
|
@@ -193,6 +193,19 @@ class VLLMModelConfig(BaseResponsesAPIModelConfig): | |
| extra_body: Optional[Dict[str, Any]] = None | ||
|
|
||
| default_headers: Dict[str, str] = Field(default_factory=dict) | ||
|
|
||
| # Optional path to a file that publishes the current backend base_url. | ||
| # Used for shared serving jobs that move hosts when they restart. | ||
| endpoint_file: Optional[str] = None | ||
|
|
||
| # How long a missing endpoint file allows for the use of the last-known-good clients. | ||
| endpoint_stale_grace_s: float = 300.0 | ||
|
|
||
| # Connection-error retry bound applied to clients when endpoint_file is set. | ||
| endpoint_connection_retries: Optional[int] = 8 | ||
|
|
||
| # How often endpoint_file may be stat'd; otherwise the `os.stat` results is cached and reused. | ||
| endpoint_check_interval_s: float = 10.0 | ||
| # Optional prefix for resolving relative ``metadata.audio_path`` (or | ||
| # entries in ``metadata.audio_paths``) against. Absolute paths are used | ||
| # as-is. When unset, relative paths raise. Audio is always inlined as a | ||
|
|
@@ -269,11 +282,17 @@ def _post_init(self) -> None: | |
| base_url=base_url, | ||
| api_key=self.config.api_key, | ||
| default_headers=self.config.default_headers, | ||
| max_connection_retries=( | ||
| self.config.endpoint_connection_retries if self.config.endpoint_file else None | ||
| ), | ||
| ) | ||
| for base_url in self.config.base_url | ||
| ] | ||
|
|
||
| self._session_id_to_client: Dict[str, NeMoGymAsyncOpenAI] = dict() | ||
| self._endpoint_file_mtime: Optional[float] = None | ||
| self._endpoint_missing_since: Optional[float] = None | ||
| self._endpoint_last_check_at: Optional[float] = None | ||
|
|
||
| self._converter = self.get_converter() | ||
| self._transport_call_index = 0 | ||
|
|
@@ -1270,7 +1289,80 @@ def _create_empty_chat_completion(self) -> NeMoGymChatCompletion: | |
| ], | ||
| ) | ||
|
|
||
| def _maybe_rebind_endpoint(self) -> None: | ||
| """Rebind clients when a shared serving job publishes a new endpoint. | ||
|
|
||
| Raises when the endpoints have stayed unpublished for longer than `endpoint_stale_grace_s`. | ||
| """ | ||
| if not self.config.endpoint_file: | ||
| return | ||
| now = monotonic() | ||
| if ( | ||
| self._endpoint_last_check_at is not None | ||
| and now - self._endpoint_last_check_at < self.config.endpoint_check_interval_s | ||
| ): | ||
| if self._endpoint_missing_since is not None: | ||
| self._note_endpoint_unpublished() | ||
| return | ||
| self._endpoint_last_check_at = now | ||
| try: | ||
| mtime = os.stat(self.config.endpoint_file).st_mtime | ||
| except FileNotFoundError: | ||
| # Serving jobs remove the endpoint file while rotating; | ||
| # keep the current clients until the successor publishes. | ||
| self._note_endpoint_unpublished() | ||
| return | ||
| except OSError: | ||
| # Transient filesystem trouble is not a backend exit; retry the current clients. | ||
| return | ||
| if mtime == self._endpoint_file_mtime: | ||
| if self._endpoint_missing_since is not None: | ||
| self._note_endpoint_unpublished() | ||
| return | ||
| try: | ||
| with open(self.config.endpoint_file) as endpoint_stream: | ||
| url = endpoint_stream.read().strip() | ||
| except OSError: | ||
| return | ||
| self._endpoint_file_mtime = mtime | ||
| if not url: | ||
| # An empty file is as unpublished as a missing one. | ||
| self._note_endpoint_unpublished() | ||
| return | ||
| self._endpoint_missing_since = None | ||
| if [url] == self.config.base_url: | ||
| return | ||
| print( | ||
| f"vllm_model '{self.config.name}': backend endpoint changed " | ||
| f"{self.config.base_url} -> {[url]}; rebinding clients.", | ||
| flush=True, | ||
| ) | ||
| self.config.base_url = [url] | ||
| self._clients = [ | ||
| NeMoGymAsyncOpenAI( | ||
| base_url=url, | ||
| api_key=self.config.api_key, | ||
| default_headers=self.config.default_headers, | ||
| max_connection_retries=self.config.endpoint_connection_retries, | ||
| ) | ||
| ] | ||
| # Every session re-resolves onto the new host. | ||
| self._session_id_to_client.clear() | ||
|
|
||
| def _note_endpoint_unpublished(self) -> None: | ||
| now = monotonic() | ||
| if self._endpoint_missing_since is None: | ||
| self._endpoint_missing_since = now | ||
| elif now - self._endpoint_missing_since > self.config.endpoint_stale_grace_s: | ||
| raise RuntimeError( | ||
| f"vllm_model endpoint file {self.config.endpoint_file} unpublished (absent " | ||
| f"or empty) for {now - self._endpoint_missing_since:.0f}s (grace " | ||
| f"{self.config.endpoint_stale_grace_s:.0f}s); refusing to keep serving " | ||
| "against a backend that is no longer published." | ||
| ) | ||
|
|
||
| def _resolve_client(self, request: Request) -> NeMoGymAsyncOpenAI: | ||
| self._maybe_rebind_endpoint() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NOTE (operability): BLAST RADIUS: throughput stall under high concurrency when the shared FS is slow — the exact conditions this feature is deployed in. Inference latency normally dominates a sub-ms stat, so this is a tail-risk, not a common case. FIX: throttle the filesystem check to at most once per N seconds (track last-checked
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're absolutely right about this! Fixed exactly how you suggested. |
||
| session_id = request.session[SESSION_ID_KEY] | ||
| if session_id not in self._session_id_to_client: | ||
| # Uvicorn workers do not share this cache. A stable assignment keeps | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NOTE — blocking file I/O on the async event loop.
_maybe_rebind_endpoint()is a sync function called from_resolve_client(), which runs inside the asyncresponses()/chat_completions()handlers.os.stat()(and the subsequentopen().read()) block the single event loop, stalling all concurrent sessions on this process, not just the caller.BLAST RADIUS: bounded in practice — the check is throttled to once per
endpoint_check_interval_s(10s) and the file read only fires on an mtime change. On a local/tmpfs endpoint file this is microseconds and harmless. But this feature explicitly targets HPC shared serving jobs, and ifendpoint_filelives on a networked FS (NFS/Lustre — which CLAUDE.md flags this project runs on), a metadata stall inos.statfreezes every in-flight model request on the process for the duration.FIX (optional/defense-in-depth): if the endpoint file may live on a cluster FS, run the stat/read via
asyncio.to_thread(...)(which requires making the rebind path async) or a thread executor. Given the 10s throttle, author's call — not a blocker.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with your analysis that this is a well-bounded problem.
Your better solution that you suggest would involve a larger refactor and is not suited for this PR.