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.
72 lines
2.3 KiB
Python
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)
|