165 lines
6.5 KiB
Python
165 lines
6.5 KiB
Python
"""Server-Sent Event bus for pushing updates to displays.
|
|
|
|
Events are scoped by user_id so that each user's display only receives
|
|
updates about their own data (alarms, calendar, pomodoro, ...).
|
|
|
|
Cross-process broadcast:
|
|
The app runs under multiple Gunicorn workers; each worker has its own
|
|
in-process EventBus AND its own SSE subscribers. A publish() on worker A
|
|
would never reach an SSE connection held by worker B. To fix this,
|
|
publish() additionally sends the event over a PostgreSQL LISTEN/NOTIFY
|
|
channel; every worker runs a listener thread that re-dispatches remote
|
|
events to its local subscribers. Oversized payloads (rare, > ~7KB) are
|
|
delivered as a ``db_changed`` signal so the display re-fetches full state.
|
|
"""
|
|
import json
|
|
import os
|
|
import queue
|
|
import threading
|
|
|
|
import psycopg2
|
|
|
|
_NOTIFY_CHANNEL = "iot_events"
|
|
_NOTIFY_MAX_LEN = 7000 # stay well under PostgreSQL's 8KB NOTIFY payload limit
|
|
|
|
|
|
class EventBus:
|
|
"""Simple pub/sub event system with SSE + cross-process broadcast support.
|
|
|
|
publish(event_type, data, user_id=None): when user_id is given, only
|
|
subscribers registered for that user receive the event.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self._subscribers: list[tuple[int | None, queue.Queue]] = []
|
|
self._lock = threading.Lock()
|
|
self._notify_conn = None
|
|
self._notify_lock = threading.Lock()
|
|
self._listener = None
|
|
|
|
def attach(self, db_url: str):
|
|
"""Enable cross-process broadcast for THIS process: opens a NOTIFY
|
|
connection and starts a LISTEN thread. Call once per worker (start.py
|
|
/ run.py). Without attach, publish() still works locally (tests)."""
|
|
if self._notify_conn is not None:
|
|
return
|
|
try:
|
|
self._notify_conn = psycopg2.connect(db_url, connect_timeout=5)
|
|
self._notify_conn.autocommit = True
|
|
except Exception:
|
|
self._notify_conn = None
|
|
return
|
|
self._listener = threading.Thread(
|
|
target=self._listen_loop, args=(db_url,), daemon=True,
|
|
name="eventbus-listener",
|
|
)
|
|
self._listener.start()
|
|
|
|
def subscribe(self, user_id: int | None = None) -> queue.Queue:
|
|
"""Subscribe to events. user_id=None receives ALL events (broadcast)."""
|
|
q: queue.Queue = queue.Queue()
|
|
with self._lock:
|
|
self._subscribers.append((user_id, q))
|
|
return q
|
|
|
|
def unsubscribe(self, q: queue.Queue):
|
|
with self._lock:
|
|
self._subscribers = [(uid, sq) for uid, sq in self._subscribers if sq is not q]
|
|
|
|
def publish(self, event_type: str, data: dict, user_id: int | None = None):
|
|
"""Deliver locally AND broadcast to other workers (best-effort).
|
|
The NOTIFY carries this process's PID so our own listener thread can
|
|
skip the echo (each subscriber must receive an event exactly once)."""
|
|
self._dispatch(event_type, data, user_id)
|
|
self._broadcast(event_type, data, user_id)
|
|
|
|
# ─── local delivery ───────────────────────
|
|
def _dispatch(self, event_type: str, data: dict, user_id: int | None = None):
|
|
msg = json.dumps({"type": event_type, "data": data})
|
|
with self._lock:
|
|
dead = []
|
|
for sub_uid, q in self._subscribers:
|
|
if sub_uid is not None and user_id is not None and sub_uid != user_id:
|
|
continue
|
|
try:
|
|
q.put_nowait(msg)
|
|
except queue.Full:
|
|
dead.append((sub_uid, q))
|
|
for item in dead:
|
|
self._subscribers.remove(item)
|
|
|
|
# ─── cross-process broadcast ──────────────
|
|
def _broadcast(self, event_type: str, data: dict, user_id: int | None):
|
|
if self._notify_conn is None:
|
|
return
|
|
try:
|
|
payload = json.dumps(
|
|
{"pid": os.getpid(), "type": event_type, "data": data,
|
|
"user_id": user_id},
|
|
default=str,
|
|
)
|
|
if len(payload) > _NOTIFY_MAX_LEN:
|
|
# Too big for NOTIFY: receivers re-fetch full state from the DB.
|
|
payload = json.dumps(
|
|
{"pid": os.getpid(), "type": event_type,
|
|
"oversized": True, "user_id": user_id}
|
|
)
|
|
with self._notify_lock:
|
|
cur = self._notify_conn.cursor()
|
|
cur.execute("SELECT pg_notify(%s, %s)", (_NOTIFY_CHANNEL, payload))
|
|
self._notify_conn.commit()
|
|
cur.close()
|
|
except Exception:
|
|
# Broadcast is best-effort (local delivery already happened).
|
|
# Drop the dead connection so the next publish reconnects once.
|
|
try:
|
|
self._notify_conn.close()
|
|
except Exception:
|
|
pass
|
|
self._notify_conn = None
|
|
|
|
def handle_remote(self, payload: str):
|
|
"""Listener-thread entry: re-dispatch a remote event locally (no NOTIFY).
|
|
Events broadcast by THIS process are skipped (already delivered locally)."""
|
|
try:
|
|
msg = json.loads(payload)
|
|
if msg.get("pid") == os.getpid():
|
|
return # echo of our own broadcast
|
|
if msg.get("oversized"):
|
|
self._dispatch("db_changed", {}, msg.get("user_id"))
|
|
else:
|
|
self._dispatch(
|
|
msg.get("type", "db_changed"),
|
|
msg.get("data") or {},
|
|
msg.get("user_id"),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
def _listen_loop(self, db_url: str):
|
|
"""Blocking LISTEN loop with reconnect on DB failure."""
|
|
import select
|
|
import time
|
|
while True:
|
|
conn = None
|
|
try:
|
|
conn = psycopg2.connect(db_url, connect_timeout=5)
|
|
conn.autocommit = True
|
|
cur = conn.cursor()
|
|
cur.execute(f"LISTEN {_NOTIFY_CHANNEL}")
|
|
cur.close()
|
|
while True:
|
|
if select.select([conn], [], [], 1.0)[0]:
|
|
conn.poll()
|
|
while conn.notifies:
|
|
n = conn.notifies.pop(0)
|
|
self.handle_remote(n.payload)
|
|
except Exception:
|
|
time.sleep(2) # DB temporarily unavailable: retry
|
|
finally:
|
|
if conn is not None:
|
|
try:
|
|
conn.close()
|
|
except Exception:
|
|
pass
|