Files
bonamin 34ae328b0d feat: bump client-services (accumulated feature work + deploy fixes)
Snapshot of in-progress work across local_backend, manager_dashboard,
and waiter_pwa (pricing, chat, fiscal, prep zones, recovery codes, CRM,
inventory, permissions), plus the nginx/docker-compose deploy fixes for
the Unraid + NPM reverse-proxy setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 10:00:14 +03:00

171 lines
5.6 KiB
Python

"""
WebSocket Event Bus — replaces sse_bus.py for real-time communication.
Drop-in replacement: all routers continue calling broadcast_sync() unchanged.
The WS endpoint uses connect()/disconnect()/replay_missed() to manage clients.
Protocol (JSON frames):
Server → Client: { "seq": 123, "type": "order_updated", "data": { ... } }
Client → Server: { "cursor": 120 } (sent immediately after connect)
Server → Client: { "type": "ping" } (every 25s keepalive)
Client → Server: { "type": "pong" } (optional, ignored if not sent)
On connect the client sends its last known seq. The server replays everything
it has stored since that seq, then switches to live streaming. Events are stored
in the sync_events SQLite table (written here, pruned on startup).
"""
import asyncio
import json
import logging
from typing import Dict, Set
from datetime import datetime, timedelta, timezone
from fastapi import WebSocket
logger = logging.getLogger(__name__)
# ── Event loop (captured once at startup) ────────────────────────────────────
_main_loop: asyncio.AbstractEventLoop | None = None
def init_loop(loop: asyncio.AbstractEventLoop) -> None:
global _main_loop
_main_loop = loop
# ── Connected clients: user_id → set of asyncio.Queue ────────────────────────
_connections: Dict[int, Set[asyncio.Queue]] = {}
async def connect(user_id: int) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue(maxsize=512)
if user_id not in _connections:
_connections[user_id] = set()
_connections[user_id].add(q)
return q
async def disconnect(user_id: int, q: asyncio.Queue) -> None:
if user_id in _connections:
_connections[user_id].discard(q)
if not _connections[user_id]:
del _connections[user_id]
# ── Persistence helpers ───────────────────────────────────────────────────────
def _get_db():
from database import SessionLocal
return SessionLocal()
def _persist_event(event_type: str, data: dict, user_ids: list[int] | None) -> int:
"""Write event to sync_events table, return the new seq_id."""
from sqlalchemy import text
db = _get_db()
try:
result = db.execute(
text(
"INSERT INTO sync_events (event_type, payload, target_user_ids, created_at) "
"VALUES (:et, :payload, :uids, :now)"
),
{
"et": event_type,
"payload": json.dumps(data),
"uids": json.dumps(user_ids) if user_ids is not None else None,
"now": datetime.now(timezone.utc).isoformat(),
},
)
db.commit()
return result.lastrowid
finally:
db.close()
def get_events_since(cursor: int, user_id: int) -> list[dict]:
"""Return all events with seq_id > cursor that are visible to user_id."""
from sqlalchemy import text
db = _get_db()
try:
rows = db.execute(
text(
"SELECT id, event_type, payload, target_user_ids FROM sync_events "
"WHERE id > :cursor ORDER BY id ASC LIMIT 500"
),
{"cursor": cursor},
).fetchall()
finally:
db.close()
events = []
for row in rows:
target_user_ids = json.loads(row[3]) if row[3] else None
if target_user_ids is None or user_id in target_user_ids:
events.append({
"seq": row[0],
"type": row[1],
"data": json.loads(row[2]),
})
return events
def prune_old_events(hours: int = 24) -> int:
"""Delete events older than `hours`. Called on startup."""
from sqlalchemy import text
db = _get_db()
try:
cutoff = (datetime.now(timezone.utc) - timedelta(hours=hours)).isoformat()
result = db.execute(
text("DELETE FROM sync_events WHERE created_at < :cutoff"),
{"cutoff": cutoff},
)
db.commit()
return result.rowcount
finally:
db.close()
# ── Broadcast ─────────────────────────────────────────────────────────────────
def broadcast_sync(event_type: str, data: dict, *, user_ids: list[int] | None = None) -> None:
"""
Called from sync route thread-pool workers.
Persists the event to DB (so reconnecting clients can replay it),
then schedules an async push to all currently connected sockets.
"""
try:
seq_id = _persist_event(event_type, data, user_ids)
except Exception:
logger.exception("ws_bus: failed to persist event %s", event_type)
seq_id = 0
if _main_loop is None:
return
_main_loop.call_soon_threadsafe(
_main_loop.create_task,
_broadcast_live(seq_id, event_type, data, user_ids),
)
async def _broadcast_live(
seq_id: int,
event_type: str,
data: dict,
user_ids: list[int] | None,
) -> None:
frame = json.dumps({"seq": seq_id, "type": event_type, "data": data})
targets = (
{uid: qs for uid, qs in _connections.items() if uid in user_ids}
if user_ids is not None
else dict(_connections)
)
for qs in targets.values():
for q in list(qs):
try:
q.put_nowait(frame)
except asyncio.QueueFull:
pass # slow client — drop live frame; they'll replay on reconnect