"""경량 인메모리 이벤트 버스 — SSE 브로드캐스트용. 단일 워커 환경(개발/온프레미스 데모)에서 모든 SSE 구독자에게 실시간 이벤트를 전달합니다. 사용법: from core.events import broadcast, subscribe, unsubscribe # 이벤트 발행 (router 내 async 함수에서) await broadcast("sr_updated", {"sr_id": "SR-...", "new_status": "APPROVED"}) # SSE 엔드포인트에서 구독 queue = subscribe() try: msg = await asyncio.wait_for(queue.get(), timeout=20.0) finally: unsubscribe(queue) """ import asyncio import json from typing import Any # 구독 중인 클라이언트 큐 목록 _subscribers: list[asyncio.Queue] = [] async def broadcast(event_type: str, data: dict[str, Any] | None = None) -> None: """모든 SSE 구독자에게 이벤트를 전송합니다. 큐가 가득 찬(오프라인) 구독자는 자동으로 제거됩니다. """ msg = json.dumps({"type": event_type, **(data or {})}, ensure_ascii=False) dead: list[asyncio.Queue] = [] for q in list(_subscribers): try: q.put_nowait(msg) except asyncio.QueueFull: dead.append(q) for q in dead: unsubscribe(q) def subscribe() -> asyncio.Queue: """새 구독 큐를 생성하고 등록합니다.""" q: asyncio.Queue = asyncio.Queue(maxsize=100) _subscribers.append(q) return q def unsubscribe(q: asyncio.Queue) -> None: """구독을 해제합니다.""" try: _subscribers.remove(q) except ValueError: pass def subscriber_count() -> int: """현재 활성 구독자 수를 반환합니다.""" return len(_subscribers) # ── 배치 실행 로그 SSE 스트리밍 ──────────────────────────────────────────────── async def stream_batch_log(run_id: int): """ 배치 실행 로그를 SSE 형식으로 스트리밍합니다. BatchRun.stdout_tail 을 주기적으로 폴링하여 새 내용을 yield. result 가 RUNNING 이 아닌 상태가 되면 마지막 로그를 전송 후 종료. Usage (FastAPI StreamingResponse): return StreamingResponse( stream_batch_log(run_id), media_type="text/event-stream", ) Yields: SSE 형식 문자열: "data: {json}\\n\\n" """ import asyncio from database import SessionLocal from models import BatchRun, BatchRunResult POLL_INTERVAL = 2.0 # 2초 폴링 MAX_WAIT_SEC = 3600 # 최대 1시간 대기 elapsed = 0.0 sent_lines = 0 # 이미 전송한 줄 수 (중복 방지) while elapsed < MAX_WAIT_SEC: async with SessionLocal() as db: from sqlalchemy import select row = (await db.execute( select(BatchRun).where(BatchRun.id == run_id) )).scalars().first() if not row: yield f"data: {json.dumps({'type': 'error', 'message': '실행 이력을 찾을 수 없습니다.'}, ensure_ascii=False)}\n\n" return # stdout_tail 에서 새 줄 추출 stdout = row.stdout_tail or "" all_lines = stdout.splitlines() new_lines = all_lines[sent_lines:] if new_lines: for line in new_lines: payload = json.dumps( {"type": "log", "run_id": run_id, "line": line}, ensure_ascii=False, ) yield f"data: {payload}\n\n" sent_lines += len(new_lines) # 완료/실패/타임아웃 → 종료 이벤트 전송 if row.result != BatchRunResult.RUNNING: result_payload = json.dumps({ "type": "done", "run_id": run_id, "result": row.result, "exit_code": row.exit_code, "ended_at": row.ended_at.isoformat() if row.ended_at else None, "error_msg": row.error_msg, }, ensure_ascii=False) yield f"data: {result_payload}\n\n" return # 아직 실행 중 — heartbeat 전송 후 대기 heartbeat = json.dumps( {"type": "heartbeat", "run_id": run_id, "elapsed": int(elapsed)}, ensure_ascii=False, ) yield f"data: {heartbeat}\n\n" await asyncio.sleep(POLL_INTERVAL) elapsed += POLL_INTERVAL # 타임아웃 — 스트리밍 강제 종료 timeout_payload = json.dumps( {"type": "timeout", "run_id": run_id, "message": "스트리밍 최대 대기 시간 초과"}, ensure_ascii=False, ) yield f"data: {timeout_payload}\n\n"