from __future__ import annotations
import asyncio
import contextlib
import time
from collections.abc import Mapping
from contextlib import AbstractAsyncContextManager
from typing import Any, AsyncIterator, Callable, Protocol
from uuid import UUID
from ezmsg.core.graphcontext import GraphContext
from ezmsg.core.graphmeta import GraphSnapshot, ProfilingStreamControl, ProfilingTraceControl
from ezmsg.core.netprotocol import GRAPHSERVER_ADDR, Address
from ..json_encoding import decode_float_token
from ..models.events import (
EventEnvelopeModel,
ProfilingTraceEnvelope,
SettingsChangedEnvelope,
SystemErrorEnvelope,
SystemHeartbeatEnvelope,
SystemReadyEnvelope,
TopologyChangedEnvelope,
)
from .adapters import (
adapt_graph_snapshot,
adapt_profiling_snapshot,
adapt_profiling_trace_batch,
adapt_settings_event,
adapt_settings_snapshot,
adapt_settings_value,
adapt_topology_event,
)
from .stream_tap import (
DEFAULT_WINDOW_SECONDS,
StreamTapClient,
StreamTapManager,
StreamTapUnavailableError,
availability_payload,
)
_MISSING = object()
def _read_field_path(structured_value: Any, field_path: str) -> Any:
"""Read a dotted ``field_path`` out of a structured settings value."""
current = structured_value
for field_name in field_path.split("."):
if not isinstance(current, Mapping) or field_name not in current:
return _MISSING
current = current[field_name]
return current
[docs]
class GraphServiceUnavailableError(RuntimeError):
"""Raised when GraphContext lifecycle service is not currently active."""
[docs]
class SettingsPatchError(RuntimeError):
"""Raised when a settings patch request fails in GraphContext."""
[docs]
class ProfilingTraceControlError(RuntimeError):
"""Raised when profiling trace control cannot be applied."""
[docs]
class GraphServiceProtocol(Protocol):
[docs]
async def startup(self) -> None: ...
[docs]
async def shutdown(self) -> None: ...
[docs]
async def health_payload(self) -> dict[str, Any]: ...
[docs]
async def snapshot_payload(self) -> dict[str, Any]: ...
[docs]
async def settings_payload(self) -> dict[str, Any]: ...
[docs]
async def update_setting_field(
self,
*,
component_address: str,
field_path: str,
value: Any,
timeout: float,
) -> dict[str, Any]: ...
[docs]
async def set_profiling_trace_control(
self,
*,
process_id: str,
enabled: bool,
publisher_endpoint_id: str | None,
publisher_topic: str | None,
subscriber_topic: str | None,
metrics: list[str] | None,
sample_mod: int,
ttl_seconds: float | None,
timeout: float,
) -> dict[str, Any]: ...
[docs]
async def event_envelopes(
self,
*,
topology_after_seq: int,
settings_after_seq: int,
profiling_interval: float,
profiling_max_samples: int,
) -> AsyncIterator[dict[str, Any]]: ...
self,
*,
topic: str,
mode: str,
max_columns: int,
window_seconds: float,
) -> AbstractAsyncContextManager[StreamTapClient]: ...
[docs]
class GraphContextLifecycleService:
[docs]
def __init__(
self,
*,
graph_address: Any = None,
auto_start: bool | None = None,
heartbeat_seconds: float = 10.0,
queue_size: int = 512,
graph_context_factory: Callable[..., GraphContext] = GraphContext,
) -> None:
self._graph_address = graph_address
self._auto_start = auto_start
self._heartbeat_seconds = heartbeat_seconds
self._queue_size = queue_size
self._graph_context_factory = graph_context_factory
self._startup_lock = asyncio.Lock()
self._shutdown_lock = asyncio.Lock()
self._context: GraphContext | None = None
self._active_trace_route_units: set[str] = set()
self._tap_manager: StreamTapManager | None = None
def _graph_context_address(self) -> Address | None:
if self._graph_address is None:
return None
if isinstance(self._graph_address, Address):
return self._graph_address
return Address.from_string(str(self._graph_address))
@property
def is_started(self) -> bool:
return self._context is not None
[docs]
async def startup(self) -> None:
async with self._startup_lock:
if self._context is not None:
return
context = self._graph_context_factory(
graph_address=self._graph_context_address(),
auto_start=self._auto_start,
)
await context.__aenter__()
self._context = context
self._tap_manager = StreamTapManager(
self._create_tap_subscriber,
subscriber_release=self._release_tap_subscriber,
)
[docs]
async def shutdown(self) -> None:
async with self._shutdown_lock:
context = self._context
if context is None:
return
self._context = None
self._active_trace_route_units.clear()
tap_manager, self._tap_manager = self._tap_manager, None
if tap_manager is not None:
# Before the context: a tap still holding a live subscriber
# would otherwise be closed twice, once here and once by
# GraphContext.revert().
await tap_manager.shutdown()
await context.__aexit__(None, None, None)
async def _create_tap_subscriber(self, topic: str, **kwargs: Any) -> Any:
context = self._require_context()
return await context.subscriber(topic, **kwargs)
def _release_tap_subscriber(self, subscriber: Any) -> None:
"""Drop a closed tap subscriber from GraphContext's client set.
Reaches for a private attribute because there is no public way to
un-register a client: ``GraphContext`` only clears the set wholesale on
session teardown, which is the wrong lifetime for something a user opens
and closes from a panel. Guarded so that an ezmsg release which renames
or removes the attribute costs nothing worse than the small leak this
exists to avoid.
"""
context = self._context
clients = getattr(context, "_clients", None)
if isinstance(clients, set):
clients.discard(subscriber)
def _require_context(self) -> GraphContext:
if self._context is None:
raise GraphServiceUnavailableError("GraphContext is not active.")
return self._context
def _effective_graph_address(self) -> str:
context = self._context
if context is not None and getattr(context, "graph_address", None) is not None:
return str(context.graph_address)
if self._graph_address is not None:
return str(self._graph_address)
return str(GRAPHSERVER_ADDR)
[docs]
async def health_payload(self) -> dict[str, Any]:
context = self._context
tap_manager = self._tap_manager
return {
"status": "ok",
"graph_session_active": context is not None,
"graph_address": self._effective_graph_address(),
"stream_tap": {
**availability_payload(),
"active_topics": [] if tap_manager is None else tap_manager.active_topics,
},
}
[docs]
def stream_tap(
self,
*,
topic: str,
mode: str,
max_columns: int,
window_seconds: float = DEFAULT_WINDOW_SECONDS,
) -> AbstractAsyncContextManager[StreamTapClient]:
"""Open (or join) a tap on ``topic`` for the duration of the context.
Not ``async def``: callers use it as ``async with``, and returning the
context manager directly means a caller that never enters it has not
already taken a reference on the tap.
"""
tap_manager = self._tap_manager
if tap_manager is None:
raise StreamTapUnavailableError("GraphContext is not active; cannot tap a stream.")
return tap_manager.client(topic, mode=mode, max_columns=max_columns, window_seconds=window_seconds)
[docs]
async def snapshot_payload(self) -> dict[str, Any]:
context = self._require_context()
graph_snapshot, settings_snapshot, profiling_snapshot = await asyncio.gather(
context.snapshot(),
context.settings_snapshot(),
context.profiling_snapshot_all(),
)
settings_with_patchability = self._settings_with_patchability(
settings_snapshot=settings_snapshot,
graph_snapshot=graph_snapshot,
)
return {
"snapshot": adapt_graph_snapshot(graph_snapshot),
"settings": settings_with_patchability,
"profiling": adapt_profiling_snapshot(profiling_snapshot),
}
[docs]
async def settings_payload(self) -> dict[str, Any]:
context = self._require_context()
settings_snapshot, graph_snapshot = await asyncio.gather(
context.settings_snapshot(),
context.snapshot(),
)
settings_with_patchability = self._settings_with_patchability(
settings_snapshot=settings_snapshot,
graph_snapshot=graph_snapshot,
)
return {"settings": settings_with_patchability}
[docs]
async def update_setting_field(
self,
*,
component_address: str,
field_path: str,
value: Any,
timeout: float = 2.0,
) -> dict[str, Any]:
context = self._require_context()
graph_snapshot = await context.snapshot()
if not self._component_is_patchable(graph_snapshot, component_address):
raise SettingsPatchError(f"Component '{component_address}' does not support dynamic settings patches.")
patch_value = await self._decoded_patch_value(
context=context,
component_address=component_address,
field_path=field_path,
value=value,
)
try:
updated_value = await context.update_setting(
component_address=component_address,
field_path=field_path,
value=patch_value,
timeout=timeout,
)
except Exception as exc:
raise SettingsPatchError(str(exc)) from exc
return {
"component_address": component_address,
"field_path": field_path,
"updated_value": adapt_settings_value(updated_value),
}
async def _decoded_patch_value(
self,
*,
context: GraphContext,
component_address: str,
field_path: str,
value: Any,
) -> Any:
"""Turn a non-finite token back into the float it stands for.
The wire encoding is a string (JSON has no ``inf`` literal), so a patch
must be decoded before it reaches ezmsg -- which writes the value into
the dataclass as-is and would otherwise leave a ``str`` in a ``float``
field. Only fields that currently hold a number are decoded; anything
else is rejected rather than silently rewritten.
"""
decoded = decode_float_token(value)
if decoded is None:
return value
settings_snapshot = await context.settings_snapshot()
snapshot_value = settings_snapshot.get(component_address)
current = _MISSING
if snapshot_value is not None:
structured = snapshot_value.structured_value
if structured is None and isinstance(snapshot_value.repr_value, Mapping):
structured = snapshot_value.repr_value
current = _read_field_path(structured, field_path)
if isinstance(current, (int, float)) and not isinstance(current, bool):
return decoded
raise SettingsPatchError(
f"Cannot apply non-finite value '{value}' to "
f"'{component_address}.{field_path}': the field does not currently hold a number"
+ ("." if current is _MISSING else f" (current value: {current!r}).")
)
[docs]
async def set_profiling_trace_control(
self,
*,
process_id: str,
enabled: bool,
publisher_endpoint_id: str | None,
publisher_topic: str | None,
subscriber_topic: str | None,
metrics: list[str] | None,
sample_mod: int = 1,
ttl_seconds: float | None = None,
timeout: float = 2.0,
) -> dict[str, Any]:
context = self._require_context()
graph_snapshot = await context.snapshot()
try:
process_uuid = UUID(process_id)
except ValueError as exc:
raise ProfilingTraceControlError(f"Invalid process_id '{process_id}'.") from exc
process_meta = graph_snapshot.processes.get(process_uuid)
if process_meta is None:
raise ProfilingTraceControlError(f"Process '{process_id}' is not present in the graph snapshot.")
if len(process_meta.units) == 0:
raise ProfilingTraceControlError(f"Process '{process_id}' has no routable units for control requests.")
route_unit = process_meta.units[0]
normalized_sample_mod = max(1, int(sample_mod))
if not enabled:
targets = set(self._active_trace_route_units)
if len(targets) == 0:
targets = {route_unit}
await self._apply_disable_trace_controls(
context=context,
route_units=targets,
sample_mod=normalized_sample_mod,
timeout=timeout,
)
self._active_trace_route_units.clear()
return {
"process_id": process_id,
"unit_address": route_unit,
"unit_addresses": sorted(targets),
"enabled": False,
"control": {
"sample_mod": normalized_sample_mod,
"publisher_topics": [],
"subscriber_topics": [],
"publisher_endpoint_ids": [],
"metrics": [],
"ttl_seconds": ttl_seconds,
},
}
# Keep at most one active dashboard trace scope at a time across processes.
if len(self._active_trace_route_units) > 0:
await self._apply_disable_trace_controls(
context=context,
route_units=set(self._active_trace_route_units),
sample_mod=normalized_sample_mod,
timeout=timeout,
)
self._active_trace_route_units.clear()
subscriber_metrics = self._subscriber_trace_metrics(metrics)
subscriber_seed_topic = subscriber_topic or publisher_topic
candidate_topic_scope: set[str] = set()
if subscriber_seed_topic and len(subscriber_metrics) > 0:
candidate_topic_scope = self._topic_scope_for_seed(
graph_snapshot=graph_snapshot,
seed_topic=subscriber_seed_topic,
)
publisher_control = ProfilingTraceControl(
enabled=True,
sample_mod=normalized_sample_mod,
publisher_topics=[publisher_topic] if publisher_topic else None,
# The publisher's own process owns subscribers too -- in a
# single-process graph it owns all of them -- so it needs the same
# downstream scope the remote route units get. Scoping it to the
# seed topic alone matched no subscriber at all, because a
# subscriber registers under its own input topic, never under the
# topic of the publisher feeding it.
subscriber_topics=sorted(candidate_topic_scope) if candidate_topic_scope else None,
publisher_endpoint_ids=[publisher_endpoint_id] if publisher_endpoint_id else None,
metrics=metrics if metrics else None,
ttl_seconds=ttl_seconds,
)
controls_by_route_unit: dict[str, ProfilingTraceControl] = {route_unit: publisher_control}
if len(candidate_topic_scope) > 0:
profiling_snapshot = await context.profiling_snapshot_all(timeout_per_process=timeout)
route_units_for_subscribers = self._route_units_with_subscribers_for_scope(
graph_snapshot=graph_snapshot,
profiling_snapshot=profiling_snapshot,
topic_scope=candidate_topic_scope,
)
route_units_for_subscribers.discard(route_unit)
for subscriber_route_unit in route_units_for_subscribers:
controls_by_route_unit[subscriber_route_unit] = ProfilingTraceControl(
enabled=True,
sample_mod=normalized_sample_mod,
publisher_topics=None,
subscriber_topics=sorted(candidate_topic_scope),
publisher_endpoint_ids=None,
metrics=subscriber_metrics,
ttl_seconds=ttl_seconds,
)
for target_route_unit, control in controls_by_route_unit.items():
try:
response = await context.process_set_profiling_trace(
target_route_unit,
control,
timeout=timeout,
)
except Exception as exc:
raise ProfilingTraceControlError(str(exc)) from exc
if not response.ok:
raise ProfilingTraceControlError(f"Process trace control rejected for '{process_id}': {response.error}")
self._active_trace_route_units = set(controls_by_route_unit.keys())
return {
"process_id": process_id,
"unit_address": route_unit,
"unit_addresses": sorted(controls_by_route_unit.keys()),
"enabled": True,
"control": {
"sample_mod": normalized_sample_mod,
"publisher_topics": publisher_control.publisher_topics,
"subscriber_topics": publisher_control.subscriber_topics,
"publisher_endpoint_ids": publisher_control.publisher_endpoint_ids,
"metrics": publisher_control.metrics,
"ttl_seconds": publisher_control.ttl_seconds,
},
"subscriber_scope": {
"seed_topic": subscriber_seed_topic,
# The resolved forward chain. Without it a caller cannot tell an
# empty `route_units` (every subscriber lives in the publisher's
# own process) apart from a scope that resolved to nothing.
"topic_scope": sorted(candidate_topic_scope),
"route_units": sorted(unit for unit in controls_by_route_unit.keys() if unit != route_unit),
"metrics": subscriber_metrics,
},
}
async def _apply_disable_trace_controls(
self,
*,
context: GraphContext,
route_units: set[str],
sample_mod: int,
timeout: float,
) -> None:
disable_control = ProfilingTraceControl(
enabled=False,
sample_mod=sample_mod,
publisher_topics=None,
subscriber_topics=None,
publisher_endpoint_ids=None,
metrics=None,
ttl_seconds=None,
)
for target_route_unit in route_units:
try:
await context.process_set_profiling_trace(
target_route_unit,
disable_control,
timeout=timeout,
)
except Exception:
continue
def _subscriber_trace_metrics(self, metrics: list[str] | None) -> list[str]:
allowed = {"lease_time_ns", "user_span_ns"}
if metrics is None:
return ["lease_time_ns", "user_span_ns"]
return [metric for metric in metrics if metric in allowed]
def _topic_scope_for_seed(
self,
*,
graph_snapshot: GraphSnapshot,
seed_topic: str,
) -> set[str]:
"""Every topic reachable from ``seed_topic`` by following routes.
A publisher nested in collections rarely feeds a subscriber directly:
each collection boundary forwards the topic to an alias, and only the
topic at the end of that chain is one a subscriber is registered under.
Walking a single hop finds the first alias and stops, which is why a
publisher two or more boundaries away from its subscriber reported no
subscriber profiling data at all. Forwards terminate at subscriber
input topics, so the closure stays confined to this publisher's
downstream; the visited set keeps a cyclic graph from spinning.
"""
out: set[str] = set()
pending: list[str] = [seed_topic]
while pending:
topic = pending.pop()
if topic in out:
continue
out.add(topic)
for routed_topic in graph_snapshot.graph.get(topic, []):
if isinstance(routed_topic, str) and routed_topic not in out:
pending.append(routed_topic)
return out
def _route_units_with_subscribers_for_scope(
self,
*,
graph_snapshot: GraphSnapshot,
profiling_snapshot: dict[UUID, Any],
topic_scope: set[str],
) -> set[str]:
route_units: set[str] = set()
for process_uuid, process_profile in profiling_snapshot.items():
process_meta = graph_snapshot.processes.get(process_uuid)
if process_meta is None or len(process_meta.units) == 0:
continue
subscribers = getattr(process_profile, "subscribers", {})
for subscriber in subscribers.values():
topic = getattr(subscriber, "topic", None)
if not isinstance(topic, str):
continue
if self._topic_matches_scope(topic, topic_scope):
route_units.add(process_meta.units[0])
break
return route_units
def _topic_matches_scope(self, topic: str, topic_scope: set[str]) -> bool:
if topic in topic_scope:
return True
for scope_topic in topic_scope:
if topic.startswith(f"{scope_topic}:"):
return True
return False
def _settings_with_patchability(
self,
*,
settings_snapshot: dict[str, Any],
graph_snapshot: GraphSnapshot,
) -> dict[str, dict[str, Any]]:
component_info = self._component_info_by_component(graph_snapshot)
adapted = adapt_settings_snapshot(settings_snapshot)
out: dict[str, dict[str, Any]] = {}
for component_address, value in adapted.items():
info = component_info.get(
component_address,
{
"patchable": False,
"component_type": None,
"component_name": None,
},
)
patchable = bool(info["patchable"])
out[component_address] = {
**value,
"patchable": patchable,
"patch_error": (None if patchable else "Read-only: component does not expose dynamic settings."),
"component_type": info["component_type"],
"component_name": info["component_name"],
}
return out
def _component_is_patchable(
self,
graph_snapshot: GraphSnapshot,
component_address: str,
) -> bool:
return bool(
self._component_info_by_component(graph_snapshot).get(component_address, {"patchable": False})["patchable"]
)
def _component_info_by_component(
self,
graph_snapshot: GraphSnapshot,
) -> dict[str, dict[str, Any]]:
component_info: dict[str, dict[str, Any]] = {}
for session in graph_snapshot.sessions.values():
metadata = session.metadata
if metadata is None:
continue
for component_address, component in metadata.components.items():
dynamic_settings = getattr(component, "dynamic_settings", None)
enabled = bool(dynamic_settings is not None and getattr(dynamic_settings, "enabled", False))
existing = component_info.get(
component_address,
{
"patchable": False,
"component_type": None,
"component_name": None,
},
)
component_info[component_address] = {
"patchable": bool(existing["patchable"]) or enabled,
"component_type": existing["component_type"] or getattr(component, "component_type", None),
"component_name": existing["component_name"] or getattr(component, "name", None),
}
return component_info
[docs]
async def event_envelopes(
self,
*,
topology_after_seq: int = 0,
settings_after_seq: int = 0,
profiling_interval: float = 0.05,
profiling_max_samples: int = 5000,
) -> AsyncIterator[dict[str, Any]]:
context = self._require_context()
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=self._queue_size)
async def enqueue(item: EventEnvelopeModel) -> None:
payload = item.model_dump(mode="json")
try:
queue.put_nowait(payload)
except asyncio.QueueFull:
with contextlib.suppress(asyncio.QueueEmpty):
queue.get_nowait()
queue.put_nowait(payload)
async def topology_worker() -> None:
try:
async for event in context.subscribe_topology_events(after_seq=topology_after_seq):
await enqueue(
TopologyChangedEnvelope(
kind="topology.changed",
data=adapt_topology_event(event),
)
)
except asyncio.CancelledError:
raise
except Exception as exc:
await enqueue_error(f"topology stream stopped: {exc}")
async def settings_worker() -> None:
try:
async for event in context.subscribe_settings_events(after_seq=settings_after_seq):
await enqueue(
SettingsChangedEnvelope(
kind="settings.changed",
data=adapt_settings_event(event),
)
)
except asyncio.CancelledError:
raise
except Exception as exc:
await enqueue_error(f"settings stream stopped: {exc}")
async def profiling_worker() -> None:
control = ProfilingStreamControl(
interval=profiling_interval,
max_samples=profiling_max_samples,
)
try:
async for batch in context.subscribe_profiling_trace(control):
await enqueue(
ProfilingTraceEnvelope(
kind="profiling.trace",
data=adapt_profiling_trace_batch(batch),
)
)
except asyncio.CancelledError:
raise
except Exception as exc:
await enqueue_error(f"profiling stream stopped: {exc}")
async def enqueue_error(message: str) -> None:
await enqueue(
SystemErrorEnvelope(
kind="system.error",
data={"timestamp": time.time(), "message": message},
)
)
workers = [
asyncio.create_task(topology_worker(), name="dashboard-topology-events"),
asyncio.create_task(settings_worker(), name="dashboard-settings-events"),
asyncio.create_task(profiling_worker(), name="dashboard-profiling-events"),
]
try:
yield SystemReadyEnvelope(
kind="system.ready",
data={
"timestamp": time.time(),
"message": "Subscriptions active.",
},
).model_dump(mode="json")
while True:
try:
message = await asyncio.wait_for(
queue.get(),
timeout=self._heartbeat_seconds,
)
yield message
except TimeoutError:
heartbeat = SystemHeartbeatEnvelope(
kind="system.heartbeat",
data={"timestamp": time.time()},
)
yield heartbeat.model_dump(mode="json")
finally:
for worker in workers:
worker.cancel()
for worker in workers:
with contextlib.suppress(asyncio.CancelledError):
await worker