from __future__ import annotations
import atexit
import logging
import threading
import time
from collections.abc import Callable
from typing import TYPE_CHECKING
from .. import envs
if TYPE_CHECKING:
from ..client import MxClient
from ..nixl_transfer import NixlTransferManager
logger = logging.getLogger("modelexpress.metadata.publisher")
READY_POLL_SECS = 5
REREGISTER_AFTER_CONSECUTIVE_REJECTIONS = 2
class PublisherThread:
def __init__(
self,
mx_client: MxClient,
mx_source_id: str | None = None,
worker_id: str | None = None,
worker_rank: int | None = None,
nixl_manager: NixlTransferManager | None = None,
publish_fn: Callable[[], str] | None = None,
ready_fn: Callable[[], bool] | None = None,
cleanup_fn: Callable[[], None] | None = None,
publish_timeout_secs: int | None = None,
interval_secs: int | None = None,
heartbeat_after_publish: bool = True,
source_load_provider: Callable[[], float | None] | None = None,
):
if mx_source_id is None and publish_fn is None:
raise ValueError("PublisherThread requires mx_source_id or publish_fn")
if worker_id is None or worker_rank is None:
raise ValueError("PublisherThread requires worker_id and worker_rank")
self._mx_client = mx_client
self._mx_source_id = mx_source_id
self._worker_id = worker_id
self._worker_rank = worker_rank
self._nixl_manager = nixl_manager
self._source_load_provider = source_load_provider
self._publish_fn = publish_fn
self._ready_fn = ready_fn
self._cleanup_fn = cleanup_fn
self._heartbeat_after_publish = heartbeat_after_publish
self._publish_timeout = (
publish_timeout_secs
if publish_timeout_secs is not None
else envs.MX_PUBLISH_TIMEOUT_SECS
)
self._publish_started_at: float | None = None
self._publish_given_up = False
self._cleaned_up = False
self._unhealthy = False
self._heartbeat_rejections = 0
self._reregister_unavailable_logged = False
self._interval = (
interval_secs
if interval_secs is not None
else envs.MX_HEARTBEAT_INTERVAL_SECS
)
self._stop_event = threading.Event()
self._status_lock = threading.Lock()
self._started = False
self._thread: threading.Thread | None = None
@property
def mx_source_id(self) -> str | None:
return self._mx_source_id
@property
def _one_shot_publisher(self) -> bool:
return self._publish_fn is not None and not self._heartbeat_after_publish
def start(self) -> None:
self._thread = threading.Thread(
target=self._run,
name=f"mx-publisher-{self._worker_rank}",
daemon=True,
)
self._thread.start()
atexit.register(self._on_exit)
log = logger.debug if self._one_shot_publisher else logger.info
log(
f"[Worker {self._worker_rank}] Publisher thread started "
f"(interval={self._interval}s, "
f"publish_timeout={self._publish_timeout}s)"
)
def stop(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=self._interval + 5)
self._mark_stale()
self._cleanup()
logger.info(f"[Worker {self._worker_rank}] Publisher thread stopped")
def _on_exit(self) -> None:
self._stop_event.set()
if self._thread is not None:
self._thread.join(timeout=self._interval + 5)
self._mark_stale()
self._cleanup()
def _mark_stale(self) -> None:
with self._status_lock:
if not self._started:
return
try:
from .. import p2p_pb2
if self._update_status(p2p_pb2.SOURCE_STATUS_STALE):
logger.info(
f"[Worker {self._worker_rank}] Marked STALE on shutdown"
)
self._started = False
except Exception:
logger.debug(
f"[Worker {self._worker_rank}] Failed to mark STALE on shutdown",
exc_info=True,
)
self._started = False
def _mark_unhealthy(self) -> None:
with self._status_lock:
if self._stop_event.is_set() or self._unhealthy:
return
if not self._started:
return
reason = getattr(self._nixl_manager, "data_plane_error", None)
try:
from .. import p2p_pb2
self._update_status(p2p_pb2.SOURCE_STATUS_STALE)
self._unhealthy = True
logger.warning(
f"[Worker {self._worker_rank}] NIXL agent unhealthy, marked "
f"STALE so targets stop selecting it: {reason}"
)
except Exception:
logger.debug(
f"[Worker {self._worker_rank}] Failed to mark STALE for "
f"unhealthy agent",
exc_info=True,
)
def _update_status(self, status: int) -> bool:
if self._mx_source_id is None:
return False
source_load: float | None = None
if self._source_load_provider is not None:
try:
source_load = self._source_load_provider()
except Exception: source_load = None
return self._mx_client.update_status(
mx_source_id=self._mx_source_id,
worker_id=self._worker_id,
worker_rank=self._worker_rank,
status=status,
source_load=source_load,
)
def _on_heartbeat_rejected(self) -> None:
self._heartbeat_rejections += 1
if self._heartbeat_rejections < REREGISTER_AFTER_CONSECUTIVE_REJECTIONS:
return
self._heartbeat_rejections = 0
if self._publish_fn is None:
if not self._reregister_unavailable_logged:
self._reregister_unavailable_logged = True
logger.warning(
f"[Worker {self._worker_rank}] Server keeps rejecting "
f"heartbeats for mx_source_id={self._mx_source_id} and "
f"there is no publish_fn to re-register with; heartbeats "
f"continue in case the registration comes back"
)
return
logger.warning(
f"[Worker {self._worker_rank}] Server rejected "
f"{REREGISTER_AFTER_CONSECUTIVE_REJECTIONS} heartbeats in a row "
f"for mx_source_id={self._mx_source_id}; re-registering the source"
)
self._mx_source_id = None
self._publish_started_at = None
self._publish_given_up = False
def _cleanup(self) -> None:
if self._cleanup_fn is None or self._cleaned_up:
return
self._cleaned_up = True
try:
self._cleanup_fn()
except Exception:
logger.debug(
f"[Worker {self._worker_rank}] Publisher cleanup failed",
exc_info=True,
)
def _publish_elapsed(self) -> float:
if self._publish_started_at is None:
self._publish_started_at = time.monotonic()
return time.monotonic() - self._publish_started_at
def _publish_timed_out(self, elapsed: float) -> bool:
if elapsed <= self._publish_timeout:
return False
if not self._publish_given_up:
logger.warning(
f"[Worker {self._worker_rank}] Giving up on source publish "
f"after {elapsed:.0f}s (timeout={self._publish_timeout}s). "
f"Worker will continue without this source."
)
self._publish_given_up = True
self._stop_event.set()
self._cleanup()
return True
def _try_publish(self) -> bool:
if self._stop_event.is_set():
return False
if self._publish_fn is None:
return self._mx_source_id is not None
elapsed = self._publish_elapsed()
if self._publish_timed_out(elapsed):
return False
if self._ready_fn is not None:
try:
if not self._ready_fn():
return False
except Exception as exc:
logger.warning(
f"[Worker {self._worker_rank}] Source readiness check "
f"failed ({elapsed:.0f}s elapsed, "
f"timeout={self._publish_timeout}s), will retry: {exc}"
)
return False
if self._stop_event.is_set():
return False
try:
self._mx_source_id = self._publish_fn()
log = logger.debug if self._one_shot_publisher else logger.info
log(
f"[Worker {self._worker_rank}] Source published successfully "
f"(mx_source_id={self._mx_source_id})"
)
return True
except Exception as exc:
logger.warning(
f"[Worker {self._worker_rank}] Source publish attempt failed "
f"({elapsed:.0f}s elapsed, timeout={self._publish_timeout}s), "
f"will retry next tick: {exc}"
)
return False
def _tick(self) -> None:
from .. import p2p_pb2
if self._stop_event.is_set():
return
if self._mx_source_id is None:
if not self._try_publish():
return
if not self._heartbeat_after_publish:
self._stop_event.set()
return
if self._nixl_manager is not None and not self._nixl_manager.is_healthy():
self._mark_unhealthy()
return
with self._status_lock:
if self._stop_event.is_set():
return
if not self._update_status(p2p_pb2.SOURCE_STATUS_READY):
self._on_heartbeat_rejected()
return
self._heartbeat_rejections = 0
if self._unhealthy:
self._unhealthy = False
logger.info(
f"[Worker {self._worker_rank}] NIXL agent healthy again, "
f"status -> READY"
)
if not self._started:
logger.info(
f"[Worker {self._worker_rank}] Status -> READY"
)
self._started = True
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self._tick()
except Exception:
logger.exception(
f"[Worker {self._worker_rank}] Publisher tick failed"
)
interval = self._interval
if self._mx_source_id is None and self._ready_fn is not None:
interval = min(interval, READY_POLL_SECS)
self._stop_event.wait(timeout=interval)