chore: import upstream snapshot with attribution
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
CI / lint (push) Has been cancelled
CI / js-syntax (push) Successful in 10m24s
CI / deps-audit (push) Has been cancelled
CI / test (push) Failing after 24m55s
CI / sast-bandit (push) Failing after 11m13s
CI / trivy (push) Failing after 9m32s
Docker Publish / build-and-push (push) Failing after 34m3s
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.core.config import JOB_ID_RE
|
||||
from app.core.registry import get as registry_get
|
||||
|
||||
router = APIRouter(tags=["events"])
|
||||
|
||||
# Close SSE connections that outlive this threshold to prevent zombie
|
||||
# connections from accumulating when clients disconnect without a TCP RST.
|
||||
_MAX_SSE_SECONDS = 4 * 3600 # 4 hours
|
||||
# Hard cap on concurrent SSE connections to prevent resource exhaustion
|
||||
# from tab leaks or aggressive reconnect loops.
|
||||
_MAX_SSE_CONNECTIONS = 200
|
||||
# Counter is only mutated from the async event loop (no awaits between
|
||||
# check+increment), so no lock is needed.
|
||||
_sse_active = 0
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/events")
|
||||
async def job_events(job_id: str) -> StreamingResponse:
|
||||
"""Server-Sent Events stream of job state updates. Closes when the job
|
||||
reaches a terminal status (done, error, cancelled) or after 4 hours."""
|
||||
global _sse_active
|
||||
if not JOB_ID_RE.match(job_id):
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
job = registry_get(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
if _sse_active >= _MAX_SSE_CONNECTIONS:
|
||||
raise HTTPException(status_code=503, detail="too many concurrent streams")
|
||||
_sse_active += 1
|
||||
|
||||
async def stream() -> AsyncIterator[str]:
|
||||
global _sse_active
|
||||
try:
|
||||
last = None
|
||||
keepalive_at = 0
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + _MAX_SSE_SECONDS
|
||||
while loop.time() < deadline:
|
||||
snapshot = job.to_state()
|
||||
serialized = json.dumps(snapshot)
|
||||
if serialized != last:
|
||||
yield f"data: {serialized}\n\n"
|
||||
last = serialized
|
||||
keepalive_at = 0
|
||||
if snapshot["status"] in ("done", "error", "cancelled"):
|
||||
return
|
||||
keepalive_at += 1
|
||||
if keepalive_at >= 75: # ~15s
|
||||
yield ": keepalive\n\n"
|
||||
keepalive_at = 0
|
||||
await asyncio.sleep(0.2)
|
||||
finally:
|
||||
_sse_active -= 1
|
||||
|
||||
return StreamingResponse(
|
||||
stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
Reference in New Issue
Block a user