This commit is contained in:
喆OvO
2026-08-12 09:50:37 +08:00
parent cc43873ad2
commit 15b54f204e
10 changed files with 360 additions and 41 deletions
+119 -1
View File
@@ -2,14 +2,29 @@
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 support.
"""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.
@@ -18,6 +33,27 @@ class EventBus:
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)."""
@@ -31,6 +67,14 @@ class EventBus:
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 = []
@@ -44,3 +88,77 @@ class EventBus:
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