181 lines
6.7 KiB
Python
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()
|