"""Cross-process progress event bus. The legacy bus was process-local, which worked when FastAPI spawned content generation threads. With an external DSH worker, events are persisted in SQLite and replayed by SSE/polling; the in-memory deque remains a fast path for events emitted inside the API process. """ from __future__ import annotations import logging import threading import time from collections import deque from typing import Any logger = logging.getLogger("yawen.progress") _LOCK = threading.Lock() _QUEUES: dict[int, deque] = {} _MAX_PER_USER = 200 def publish(user_id: int, event: str, **fields: Any) -> None: item = {"event": event, "ts": time.time(), **fields} uid = int(user_id or 0) with _LOCK: targets = list(_QUEUES.keys()) if uid == 0 else [uid] for target in targets: q = _QUEUES.get(target) if q is not None: q.append(item) while len(q) > _MAX_PER_USER: q.popleft() try: from sqlmodel import Session from .db import engine from .models import ProgressEvent with Session(engine) as session: session.add(ProgressEvent(user_id=uid, event=str(event)[:80], payload=item)) session.commit() except Exception as exc: # progress must never block production logger.debug("persist progress event failed: %s", exc) def register(user_id: int) -> deque: with _LOCK: q = _QUEUES.get(int(user_id)) if q is None: q = deque(maxlen=_MAX_PER_USER) _QUEUES[int(user_id)] = q return q def unregister(user_id: int) -> None: with _LOCK: _QUEUES.pop(int(user_id), None) def list_events(user_id: int, after_ts: float = 0.0, limit: int = 200) -> list[dict]: uid = int(user_id or 0) events: list[dict] = [] try: from sqlmodel import Session, select from .db import engine from .models import ProgressEvent with Session(engine) as session: rows = session.exec( select(ProgressEvent) .where(ProgressEvent.user_id.in_([0, uid])) .order_by(ProgressEvent.id.desc()) .limit(500) ).all() events = [dict(row.payload or {}) for row in reversed(rows)] except Exception: events = [] with _LOCK: q = _QUEUES.get(uid) if q is not None: events.extend(list(q)) result: list[dict] = [] seen: set[tuple] = set() for event in events: if float(event.get("ts") or 0.0) <= float(after_ts or 0.0): continue key = (event.get("event"), event.get("ts"), event.get("campaign_id"), event.get("segment_id"), event.get("title")) if key in seen: continue seen.add(key) result.append(event) return result[-limit:] def snapshot(user_id: int, after_ts: float = 0.0, limit: int = 200) -> list[dict]: return list_events(user_id, after_ts, limit=limit)