bifroest/app/tasks/routes.py
2026-10-27 08:34:17 +01:00

84 lines
3.4 KiB
Python

"""Run-Ereignisse per SSE und Abbruch (→ Konzept 4.4, 11)."""
import asyncio
import uuid
from collections.abc import AsyncIterable
from typing import Annotated
from fastapi import APIRouter, Depends, Header, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import Response
from fastapi.sse import EventSourceResponse, ServerSentEvent
from app import web
from app.tasks import engine, events, states
from app.tasks.models import RunEvent, TaskRun
# Wartezeit auf ein NOTIFY; ohne LISTEN-Verbindung kürzer (Fallback-Polling)
WAIT_SECONDS = 15.0
POLL_SECONDS = 2.0
def visible_run(request: Request, principal: web.User, run_id: uuid.UUID) -> uuid.UUID:
"""Als Dependency, damit Fremde ein 404 bekommen, bevor der Stream beginnt."""
with web.db(request).session() as session:
engine.get(session, principal, run_id)
return run_id
def start_after(last_event_id: str | None, after: int | None) -> int:
for value in (last_event_id, after):
try:
return max(int(value), 0) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
return 0
def router() -> APIRouter:
r = APIRouter()
@r.get("/runs/{run_id}/events", response_class=EventSourceResponse)
async def run_events(request: Request, run_id: Annotated[uuid.UUID, Depends(visible_run)],
last_event_id: Annotated[str | None, Header()] = None,
after: int | None = None) -> AsyncIterable[ServerSentEvent]:
db, hub = web.db(request), request.app.state.hub
position = start_after(last_event_id, after)
def load(since: int) -> tuple[list[RunEvent], str]:
# Erst den Status, dann die Ereignisse: Statuswechsel und Ereignis werden gemeinsam
# committet. Andersherum könnte ein Abschluss zwischen beiden Abfragen liegen; der
# Stream endete dann, ohne das letzte Ereignis gesendet zu haben.
with db.session() as session:
status = session.get(TaskRun, run_id).status
rows = events.read(session, run_id, since)
return rows, status
wakeup = hub.subscribe(run_id)
try:
while not await request.is_disconnected():
# Erst zurücksetzen, dann lesen: so geht kein NOTIFY zwischen Lesen und Warten verloren
wakeup.clear()
rows, status = await run_in_threadpool(load, position)
for row in rows:
position = row.seq
yield ServerSentEvent(data={"seq": row.seq, **row.data}, event=row.type, id=str(row.seq))
if rows:
continue
if status in states.RESTING:
yield ServerSentEvent(data={"status": status}, event="end")
return
try:
await asyncio.wait_for(wakeup.wait(), WAIT_SECONDS if hub.connected else POLL_SECONDS)
except TimeoutError:
yield ServerSentEvent(comment="ping")
finally:
hub.unsubscribe(run_id, wakeup)
@r.post("/runs/{run_id}/cancel")
def cancel(request: Request, principal: web.Checked, run_id: uuid.UUID) -> Response:
with web.db(request).session() as session:
engine.request_cancel(session, principal, run_id)
return Response(status_code=204)
return r