"""EndpointView: worker-side per-process singleton for XPU endpoint state.
Reads health file (written by HB subprocess) + resolves providers + round-robin
load balancing. No writes. All TTK_XPU_* env READS stay in this module.
"""
import logging
import os
import random
import time
from typing import List, Optional
from ttk.remote.health_file import read_health_file
from ttk.utilities import Singleton
log = logging.getLogger(__name__)
HEALTH_BARRIER_TIMEOUT_S = 15.0
def _parse_provider_filter(raw: Optional[str]) -> Optional[List[str]]:
"""Parse --provider filter from a raw CLI string (comma-separated).
空字符串/None → None(resolve_providers 见 None 跳过 cli 过滤)。
"""
parsed = [p.strip() for p in (raw or "").split(",") if p.strip()]
return parsed or None
def _wait_health_file(path: str, timeout: float = HEALTH_BARRIER_TIMEOUT_S,
interval: float = 0.5) -> None:
"""Lazy barrier: poll os.path.exists until health file appears (once per process)."""
if not path:
log.warning("No TTK_XPU_HEALTH_PATH set, skipping health file barrier")
return
deadline = time.time() + timeout
while not os.path.exists(path):
if time.time() >= deadline:
log.warning("Health file %s not ready after %ss, continuing anyway", path, timeout)
return
time.sleep(min(interval, max(0.0, deadline - time.time())))
class EndpointView(metaclass=Singleton):
"""Per-process singleton. Reads health file + resolves providers + round-robin."""
def __init__(self):
from ttk.remote.config import get_remote_config
config = get_remote_config()
self._endpoints = config.endpoints if config else []
self._health_path = os.environ.get("TTK_XPU_HEALTH_PATH", "")
_wait_health_file(self._health_path)
random.shuffle(self._endpoints)
self._rr_index: dict = {}
def resolve_providers(self, spec_providers: Optional[List[str]] = None,
cli_providers: Optional[List[str]] = None) -> List[str]:
"""all_effective(alive + detect∩yaml) ∩ spec_providers ∩ cli_providers.
Sequential intersection. Raises RuntimeError when nothing survives
(fail-loud: caller turns this into a single-case FAIL, worker survives).
Error messages carry detect/yaml diagnostics.
Return order: priority = first spec in input order.
When ``spec_providers`` is given the survivors are returned in spec
insertion order — the app controls priority via spec order, this layer
does not silently sort. When no ``spec_providers`` is declared there is
no priority to honor, so survivors are returned ``sorted`` (deterministic
neutral).
"""
effective = self._all_effective_providers()
if not effective:
health = read_health_file(self._health_path)
ep_keys = [f"{e.host}:{e.port}" for e in self._endpoints]
raise RuntimeError(
f"no usable provider (detect∩yaml∩alive empty); "
f"health_present={health is not None}, endpoints={ep_keys}")
candidates = set(effective)
if spec_providers:
candidates &= set(spec_providers)
if cli_providers:
candidates &= set(cli_providers)
if not candidates:
raise RuntimeError(
f"no provider after filter (effective={sorted(effective)}, "
f"spec={spec_providers}, cli={cli_providers})")
if spec_providers:
return [p for p in spec_providers if p in candidates]
return sorted(candidates)
def pick_endpoint(self, provider: str):
"""Round-robin: next alive endpoint effectively supporting provider, else None.
Load balancing, NOT failover: each call returns the next EP in rotation.
The ONLY endpoint decision point.
"""
eps = self._alive_effective_endpoints(provider)
if not eps:
return None
idx = self._rr_index.get(provider, 0) % len(eps)
self._rr_index[provider] = idx + 1
return eps[idx]
def _all_effective_providers(self) -> set:
"""Union of detect∩yaml across ALIVE endpoints (dead/empty skipped)."""
health = read_health_file(self._health_path)
if not health:
return set()
result = set()
for ep in self._endpoints:
effective = self._ep_effective(ep, health)
if effective is not None:
result |= effective
return result
def _alive_effective_endpoints(self, provider: str) -> list:
"""Alive endpoints whose effective set contains provider."""
health = read_health_file(self._health_path) or {}
result = []
for ep in self._endpoints:
effective = self._ep_effective(ep, health)
if effective and provider in effective:
result.append(ep)
return result
def _ep_effective(self, ep, health) -> Optional[set]:
"""detect∩yaml for one endpoint if alive, else None. yaml not in detect -> warn+drop."""
eps_state = health.get("endpoints", {})
ep_key = f"{ep.host}:{ep.port}"
state = eps_state.get(ep_key, {})
if not state.get("alive", False):
return None
detect = set(state.get("providers", []))
yaml_filter = set(ep.providers or [])
if yaml_filter:
dropped = yaml_filter - detect
if dropped:
log.warning("endpoint %s: yaml %s not in detect %s, dropped",
ep_key, sorted(dropped), sorted(detect))
return detect & yaml_filter
return detect