Files

220 lines
8.8 KiB
Python

"""Diagnostics for a stalled dashboard-agent asyncio event loop.
The dashboard agent runs a single asyncio loop that serves metric ingestion,
event aggregation, and the ``POST /api/jobs/`` submit handler, so a stall in
that loop hangs job submission while the dashboard head stays healthy on its
own loop.
``EventLoopMonitor`` watches the loop three ways:
* It observes loop lag (via :func:`enable_monitor_loop_lag`) and logs the lag
and pending-task count when lag is high.
* A daemon thread watches a heartbeat the loop refreshes and dumps every
thread's stack via :mod:`faulthandler` when the heartbeat goes stale,
capturing the frame the loop thread is blocked in.
* On demand, it dumps every thread's stack when the process receives a signal
(``SIGUSR2`` by default), so an operator -- or an external watchdog that
detected the hang from outside the process -- can capture the blocked frame
immediately, without waiting for the stall threshold.
The stack-dump watchdog runs on its own thread on purpose: a fully blocked loop
cannot run an asyncio callback or fire an asyncio timeout, so an in-loop monitor
would be wedged too. It is read-only and never cancels or restarts anything.
"""
import asyncio
import faulthandler
import logging
import os
import signal
import sys
import threading
import time
from typing import Optional
from ray._common.utils import get_or_create_event_loop
from ray._private.async_utils import enable_monitor_loop_lag
from ray._private.ray_constants import env_bool, env_float
logger = logging.getLogger(__name__)
# Diagnostic-only instrumentation; off by default. Opt in with
# RAY_DASHBOARD_AGENT_LOOP_MONITOR_ENABLED=1.
EVENT_LOOP_MONITOR_ENABLED = env_bool("RAY_DASHBOARD_AGENT_LOOP_MONITOR_ENABLED", False)
# How often the loop-lag monitor samples (and the loop refreshes its heartbeat).
_SAMPLE_INTERVAL_S = env_float("RAY_DASHBOARD_AGENT_LOOP_MONITOR_INTERVAL_S", 0.25)
# Log a warning when observed loop lag exceeds this.
_LAG_WARN_THRESHOLD_S = env_float("RAY_DASHBOARD_AGENT_LOOP_LAG_WARN_THRESHOLD_S", 1.0)
# Dump all thread stacks once the loop has been blocked continuously this long.
_STALL_DUMP_THRESHOLD_S = env_float(
"RAY_DASHBOARD_AGENT_LOOP_STALL_DUMP_THRESHOLD_S", 10.0
)
# Minimum gap between successive dumps so a long stall does not spam the log.
_DUMP_COOLDOWN_S = env_float("RAY_DASHBOARD_AGENT_LOOP_DUMP_COOLDOWN_S", 60.0)
# Signal that triggers an immediate all-thread stack dump on demand. Defaults
# to SIGUSR2, which is unused elsewhere in Ray (Tune already claims SIGUSR1).
# Set RAY_DASHBOARD_AGENT_LOOP_DUMP_SIGNAL to another signal name, or to an
# empty value to disable the handler.
_DUMP_SIGNAL_NAME = os.environ.get(
"RAY_DASHBOARD_AGENT_LOOP_DUMP_SIGNAL", "SIGUSR2"
).strip()
class EventLoopMonitor:
"""Watches the dashboard-agent event loop for stalls."""
def __init__(
self,
component: str = "dashboard_agent",
*,
sample_interval_s: float = _SAMPLE_INTERVAL_S,
lag_warn_threshold_s: float = _LAG_WARN_THRESHOLD_S,
stall_dump_threshold_s: float = _STALL_DUMP_THRESHOLD_S,
dump_cooldown_s: float = _DUMP_COOLDOWN_S,
):
self._component = component
self._sample_interval_s = sample_interval_s
self._lag_warn_threshold_s = lag_warn_threshold_s
self._stall_dump_threshold_s = stall_dump_threshold_s
self._dump_cooldown_s = dump_cooldown_s
self._loop: Optional[asyncio.AbstractEventLoop] = None
# Last time the loop proved it was alive (monotonic seconds). Written by
# the loop, read by the watchdog thread; a bare float access is atomic
# under CPython, so no lock is needed.
self._last_beat = time.monotonic()
self._last_dump = 0.0
self._stop = threading.Event()
self._watchdog: Optional[threading.Thread] = None
# Signal number the on-demand dump handler is registered on, if any.
self._dump_signum: Optional[int] = None
def start(self) -> None:
"""Start the monitor. Must be called from within the agent's loop."""
self._loop = get_or_create_event_loop()
# Start the stall clock at activation, not at construction time.
self._last_beat = time.monotonic()
enable_monitor_loop_lag(
self._on_lag, interval_s=self._sample_interval_s, loop=self._loop
)
self._watchdog = threading.Thread(
target=self._watchdog_loop,
name="dashboard_agent_loop_watchdog",
daemon=True,
)
self._watchdog.start()
self._register_dump_signal()
logger.info(
"[EventLoopMonitor] watching %s event loop "
"(lag_warn=%.1fs, stall_dump=%.1fs)",
self._component,
self._lag_warn_threshold_s,
self._stall_dump_threshold_s,
)
def _on_lag(self, lag_s: float) -> None:
# Runs on the loop, so reaching here proves the loop is alive.
self._last_beat = time.monotonic()
if lag_s >= self._lag_warn_threshold_s:
try:
pending = len(asyncio.all_tasks(self._loop))
except Exception:
pending = -1
logger.warning(
"[EventLoopMonitor] %s event loop lag %.3fs (pending_tasks=%d); "
"job submission and metric/event export run on this loop.",
self._component,
lag_s,
pending,
)
def _watchdog_loop(self) -> None:
while not self._stop.wait(self._sample_interval_s):
# If the loop is gone (shutdown/tests), stop rather than fire on a
# heartbeat that will never advance again.
if self._loop is not None and self._loop.is_closed():
break
stalled_for = time.monotonic() - self._last_beat
if stalled_for < self._stall_dump_threshold_s:
continue
now = time.monotonic()
if now - self._last_dump < self._dump_cooldown_s:
continue
self._last_dump = now
self._dump_stacks(stalled_for)
def _dump_stacks(self, stalled_for: float) -> None:
logger.warning(
"[EventLoopMonitor] %s event loop blocked for ~%.1fs (threshold "
"%.1fs); dumping all thread stacks.",
self._component,
stalled_for,
self._stall_dump_threshold_s,
)
# faulthandler writes Python frames for every thread, including the
# blocked loop thread, to stderr, which the agent redirects to its log.
try:
faulthandler.dump_traceback(file=sys.stderr, all_threads=True)
except Exception:
logger.exception("[EventLoopMonitor] failed to dump thread stacks")
def _register_dump_signal(self) -> None:
"""Install an on-demand stack-dump handler on the configured signal.
Uses :func:`faulthandler.register`, whose C-level handler still fires
when the loop thread is hard-blocked holding the GIL -- exactly the
case the watchdog exists for -- so an external caller can force a dump
at will. ``chain=False`` because ``SIGUSR2``'s default action is to
kill the process, which must not happen here.
Signal handlers can only be registered on the main thread; if the loop
runs elsewhere this logs and skips rather than failing start-up.
"""
if not _DUMP_SIGNAL_NAME:
return
signum = getattr(signal, _DUMP_SIGNAL_NAME, None)
if signum is None:
# e.g. SIGUSR2 does not exist on Windows.
logger.debug(
"[EventLoopMonitor] signal %r unavailable; "
"on-demand stack dump disabled.",
_DUMP_SIGNAL_NAME,
)
return
try:
faulthandler.register(
signum, file=sys.stderr, all_threads=True, chain=False
)
except (ValueError, RuntimeError) as e:
logger.warning(
"[EventLoopMonitor] could not register %s dump handler "
"(must run on the main thread): %s",
_DUMP_SIGNAL_NAME,
e,
)
return
self._dump_signum = signum
logger.info(
"[EventLoopMonitor] send %s to pid %d to dump all thread stacks.",
_DUMP_SIGNAL_NAME,
os.getpid(),
)
def _unregister_dump_signal(self) -> None:
if self._dump_signum is None:
return
try:
faulthandler.unregister(self._dump_signum)
except Exception:
logger.exception("[EventLoopMonitor] failed to unregister dump signal")
self._dump_signum = None
def stop(self) -> None:
self._stop.set()
self._unregister_dump_signal()
if self._watchdog is not None and self._watchdog.is_alive():
self._watchdog.join(timeout=1.0)