181 lines
6.7 KiB
Python

"""EventBus shim — drop-in for Sanad's core/event_bus.py API across containers.
Same surface as the in-process bus (`on` / `off` / `emit` / `emit_sync`) so
existing Sanad call-sites change only their *import*, not their logic. When
`pyzmq` is importable AND `SANAD_BUS_ADDR` is set, events are also published to
/ received from the central `sanad-busd` XPUB/XSUB proxy, so handlers in OTHER
containers fire too. Otherwise it degrades to a pure in-process bus (identical
behavior to today's monolith) — which is all P1-standalone needs.
Wire format on ZMQ: multipart [topic_bytes, json(kwargs)+_origin]. Each process
tags messages with a random origin id and ignores its own echoes.
Env:
SANAD_BUS_PUB address this process PUBLISHES to (default tcp://127.0.0.1:5560)
SANAD_BUS_SUB address this process SUBSCRIBES from (default tcp://127.0.0.1:5561)
SANAD_BUS_ADDR if set (any value), enable ZMQ mode using the two above
Kept Python-3.8 compatible.
"""
from __future__ import annotations
import asyncio
import json
import os
import threading
import uuid
from collections import defaultdict
from typing import Any, Callable, Dict, List
try:
import zmq # type: ignore
_HAVE_ZMQ = True
except Exception:
_HAVE_ZMQ = False
try:
# reuse Sanad's logger when running inside the image; fall back to print
from Project.Sanad.core.logger import get_logger # type: ignore
_log = get_logger("sanad_bus", to_console=False)
except Exception: # pragma: no cover
class _P(object):
def __getattr__(self, _n):
return lambda *a, **k: None
_log = _P()
class Bus(object):
def __init__(self) -> None:
self._lock = threading.Lock()
self._listeners = defaultdict(list) # type: Dict[str, List[Callable]]
self._origin = uuid.uuid4().hex
self._zmq_enabled = False
self._pub = None
self._ctx = None
self._sub_thread = None
self._stop = threading.Event()
# ── pub/sub registration (same as core/event_bus.EventBus) ──
def on(self, event: str, callback: Callable) -> None:
with self._lock:
self._listeners[event].append(callback)
def off(self, event: str, callback: Callable) -> None:
with self._lock:
try:
self._listeners[event].remove(callback)
except ValueError:
pass
async def emit(self, event: str, **kwargs: Any) -> None:
self._publish(event, kwargs)
await self._dispatch_async(event, kwargs)
def emit_sync(self, event: str, **kwargs: Any) -> None:
self._publish(event, kwargs)
self._dispatch_sync(event, kwargs)
# ── local dispatch (mirrors core/event_bus semantics) ──
def _dispatch_sync(self, event: str, kwargs: Dict[str, Any]) -> None:
with self._lock:
handlers = list(self._listeners.get(event, []))
for h in handlers:
try:
if asyncio.iscoroutinefunction(h):
try:
loop = asyncio.get_running_loop()
loop.create_task(h(**kwargs))
except RuntimeError:
_log.warning("async handler for %s dropped (no loop)", event)
continue
res = h(**kwargs)
if asyncio.iscoroutine(res):
try:
asyncio.get_running_loop().create_task(res)
except RuntimeError:
res.close()
except Exception:
_log.exception("handler for %s failed", event)
async def _dispatch_async(self, event: str, kwargs: Dict[str, Any]) -> None:
with self._lock:
handlers = list(self._listeners.get(event, []))
for h in handlers:
try:
res = h(**kwargs)
if asyncio.iscoroutine(res):
await res
except Exception:
_log.exception("handler for %s failed", event)
# ── ZMQ transport (optional) ──
def connect(self) -> bool:
"""Enable cross-container mode. Safe to call once at startup; no-op if
pyzmq missing or SANAD_BUS_ADDR unset. Returns True if ZMQ is active."""
if self._zmq_enabled:
return True
if not _HAVE_ZMQ or not os.environ.get("SANAD_BUS_ADDR"):
_log.info("bus: in-process mode (zmq=%s, addr=%s)",
_HAVE_ZMQ, bool(os.environ.get("SANAD_BUS_ADDR")))
return False
pub_addr = os.environ.get("SANAD_BUS_PUB", "tcp://127.0.0.1:5560")
sub_addr = os.environ.get("SANAD_BUS_SUB", "tcp://127.0.0.1:5561")
try:
self._ctx = zmq.Context.instance()
self._pub = self._ctx.socket(zmq.PUB)
self._pub.connect(pub_addr)
self._sub_thread = threading.Thread(
target=self._sub_loop, args=(sub_addr,), daemon=True)
self._sub_thread.start()
self._zmq_enabled = True
_log.info("bus: ZMQ mode pub=%s sub=%s origin=%s",
pub_addr, sub_addr, self._origin[:8])
return True
except Exception:
_log.exception("bus: ZMQ connect failed — staying in-process")
return False
def _publish(self, event: str, kwargs: Dict[str, Any]) -> None:
if not self._zmq_enabled or self._pub is None:
return
try:
body = dict(kwargs)
body["_origin"] = self._origin
self._pub.send_multipart(
[event.encode("utf-8"), json.dumps(body, default=str).encode("utf-8")])
except Exception:
_log.exception("bus: publish %s failed", event)
def _sub_loop(self, sub_addr: str) -> None:
sub = self._ctx.socket(zmq.SUB)
sub.connect(sub_addr)
sub.setsockopt(zmq.SUBSCRIBE, b"") # all topics; filter locally by listeners
while not self._stop.is_set():
try:
if sub.poll(timeout=500):
topic, raw = sub.recv_multipart()
event = topic.decode("utf-8", "replace")
data = json.loads(raw.decode("utf-8", "replace"))
if data.pop("_origin", None) == self._origin:
continue # skip our own echo
self._dispatch_sync(event, data)
except Exception:
_log.exception("bus: sub loop error")
try:
sub.close(0)
except Exception:
pass
def close(self) -> None:
self._stop.set()
for s in (self._pub,):
try:
if s is not None:
s.close(0)
except Exception:
pass
# singleton — `from sanad_pkg.bus import bus`
bus = Bus()