Files
DECNET/decnet/bus/app.py
anti f611e7363b feat(mutator,web): live topology mutation pipeline backend (DEBT-030)
Wire the mutator and web API into the service bus so live-topology
edits flow sub-second from enqueue to UI:

- Mutator publishes every state transition on the bus (mutation.applying
  /applied/failed + topology.status). Fire-and-forget; DB stays source
  of truth.
- Mutator watch loop subscribes to topology.*.mutation.enqueued and
  wakes early via asyncio.Event — the 10s poll becomes a fallback
  heartbeat, not the primary dispatch trigger.
- POST /topologies/{id}/mutations publishes mutation.enqueued after
  the DB write succeeds.
- New GET /topologies/{id}/events SSE route: snapshot on connect
  (status + in-flight mutations), live forwards topology.{id}.>
  bus events, 15s keepalive. ?token= auth mirrors /stream.
- New decnet/bus/app.py — process-wide lazy bus singleton for the
  API, closed cleanly on lifespan shutdown.
2026-04-21 14:38:25 -04:00

72 lines
2.3 KiB
Python

"""Process-wide bus singleton for request-serving workers (API, SSE routes).
A single connected :class:`~decnet.bus.base.BaseBus` shared across request
handlers — opening a UNIX socket per request would be wasteful and add
latency to the hot path. The API lifespan is responsible for calling
:func:`close_app_bus` on shutdown; connect is lazy so tests and
contract-test mode that never hit a publish/subscribe code path don't
pay for a bus connection they'll never use.
Failures during :meth:`BaseBus.connect` are swallowed and logged — a
dead bus must never break request serving. Publishers should treat a
``None`` return from :func:`get_app_bus` as "skip this notification",
same as ``DECNET_BUS_ENABLED=false``.
"""
from __future__ import annotations
import asyncio
from decnet.bus.base import BaseBus
from decnet.bus.factory import get_bus
from decnet.logging import get_logger
log = get_logger("bus.app")
_lock = asyncio.Lock()
_shared: BaseBus | None = None
_tried = False
async def get_app_bus() -> BaseBus | None:
"""Return the process-wide connected bus, or ``None`` if unavailable.
On first call, constructs a client via :func:`get_bus` and awaits
``connect()``. Subsequent calls return the cached instance. If the
initial connect raises, we remember the failure and return ``None``
from here on — callers are expected to fall back cleanly.
"""
global _shared, _tried
if _shared is not None:
return _shared
if _tried:
return None
async with _lock:
if _shared is not None:
return _shared
if _tried:
return None
_tried = True
try:
candidate = get_bus(client_name="api")
await candidate.connect()
_shared = candidate
except Exception as exc: # noqa: BLE001
log.warning("app bus unavailable: %s", exc)
return None
return _shared
async def close_app_bus() -> None:
"""Close the shared bus if one is open; reset the tried-once guard.
Call from the API lifespan shutdown. Safe to call multiple times.
"""
global _shared, _tried
bus, _shared = _shared, None
_tried = False
if bus is not None:
try:
await bus.close()
except Exception as exc: # noqa: BLE001
log.warning("app bus close raised: %s", exc)