Add SSE updates
This commit is contained in:
parent
d1732128e2
commit
688bc0cfe9
1 changed files with 958 additions and 42 deletions
77
backend/src/life_towers/events.py
Normal file
77
backend/src/life_towers/events.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""In-process pub/sub for Server-Sent Events.
|
||||
|
||||
Single-worker deployment (see Dockerfile: uvicorn with no ``--workers``), so a
|
||||
plain in-memory registry is enough — there is one event loop and every
|
||||
subscriber queue lives on it. If this ever grows to multiple workers, this
|
||||
module is the seam to swap for a cross-process bus (Redis pub/sub, LISTEN/NOTIFY,
|
||||
or polling the ``users.revision`` column).
|
||||
|
||||
Each subscriber gets a 1-slot queue that always holds the *latest* revision to
|
||||
notify. Coalescing is intentional: the SSE payload is just "something changed,
|
||||
current revision is N", so a burst of writes collapses to a single refetch
|
||||
signal rather than a backlog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
# user_id -> set of per-connection queues. Each queue carries the most recent
|
||||
# revision the connection still needs to flush to its client.
|
||||
_subscribers: dict[str, set[asyncio.Queue[int]]] = {}
|
||||
|
||||
|
||||
def subscribe(user_id: str) -> asyncio.Queue[int]:
|
||||
"""Register a new SSE connection for ``user_id`` and return its queue.
|
||||
|
||||
Must be called from the event loop (the SSE route handler is async), so the
|
||||
queue binds to the running loop.
|
||||
"""
|
||||
queue: asyncio.Queue[int] = asyncio.Queue(maxsize=1)
|
||||
_subscribers.setdefault(user_id, set()).add(queue)
|
||||
return queue
|
||||
|
||||
|
||||
def unsubscribe(user_id: str, queue: asyncio.Queue[int]) -> None:
|
||||
"""Drop a connection's queue; prune the user entry when the last one goes."""
|
||||
subs = _subscribers.get(user_id)
|
||||
if subs is None:
|
||||
return
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
_subscribers.pop(user_id, None)
|
||||
|
||||
|
||||
def publish(user_id: str, revision: int) -> None:
|
||||
"""Notify every live connection for ``user_id`` of the new revision.
|
||||
|
||||
Called from the (single) event loop right after a PUT commits, so the
|
||||
``put_nowait`` calls are loop-safe. Coalesces into each 1-slot queue: if a
|
||||
connection has not yet drained its previous signal, replace it with the
|
||||
newer revision rather than block or grow unbounded.
|
||||
"""
|
||||
subs = _subscribers.get(user_id)
|
||||
if not subs:
|
||||
return
|
||||
for queue in subs:
|
||||
try:
|
||||
queue.put_nowait(revision)
|
||||
except asyncio.QueueFull:
|
||||
# Connection is behind — drop the stale value and keep the newest.
|
||||
try:
|
||||
queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
pass
|
||||
try:
|
||||
queue.put_nowait(revision)
|
||||
except asyncio.QueueFull:
|
||||
pass
|
||||
|
||||
|
||||
def connection_count(user_id: str) -> int:
|
||||
"""Number of live SSE connections for a user (used in tests)."""
|
||||
return len(_subscribers.get(user_id, ()))
|
||||
Loading…
Reference in a new issue