feat(v2): stream execution revisions over sse

This commit is contained in:
2026-07-27 22:40:16 +02:00
parent 717653b329
commit 3b7ef2e874
2 changed files with 109 additions and 9 deletions
+31 -7
View File
@@ -2,7 +2,7 @@ import asyncio
import base64
import hashlib
import json
from collections.abc import AsyncIterator
from collections.abc import AsyncGenerator, AsyncIterator
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Annotated, Any, cast
@@ -32,6 +32,7 @@ from backup_tool.db.models import (
User,
)
from backup_tool.execution import (
TERMINAL_STATES,
EnqueueError,
enqueue,
public_event,
@@ -57,8 +58,30 @@ from backup_tool.security.redaction import redact
from backup_tool.security.secrets import EnvelopeCipher
async def single_execution_event(event: dict[str, object]) -> AsyncIterator[str]:
yield f"event: execution\\ndata: {json.dumps(event)}\\n\\n"
def execution_event_id(execution: Execution) -> str:
"""Stable monotonic cursor for an execution's current durable revision."""
updated = execution.updated_at.astimezone(UTC).isoformat()
return f"{updated}:{execution.state}:{execution.attempt}"
async def execution_events_stream(
sessions: async_sessionmaker[AsyncSession], execution_id: str, last_event_id: str | None
) -> AsyncGenerator[str, None]:
"""Poll durable execution state and yield each changed redacted revision as SSE."""
last_sent = last_event_id
while True:
async with sessions() as stream_db:
execution = await stream_db.get(Execution, execution_id)
if execution is None:
return
event_id = execution_event_id(execution)
if event_id != last_sent:
payload = public_event(execution)
yield (f"id: {event_id}\\nevent: execution\\ndata: {json.dumps(payload)}\\n\\n")
last_sent = event_id
if execution.state in TERMINAL_STATES:
return
await asyncio.sleep(0.1)
class Problem(Exception):
@@ -838,14 +861,15 @@ def create_app(settings: Settings) -> FastAPI:
execution_id: str,
db: Annotated[AsyncSession, Depends(session)],
identity: Annotated[tuple[User, set[str], bool], Depends(actor)],
last_event_id: Annotated[str | None, Header(alias="Last-Event-ID")] = None,
) -> StreamingResponse:
enforce_scope(identity[1], "execution:read")
execution = await db.get(Execution, execution_id)
if execution is None:
if await db.get(Execution, execution_id) is None:
raise Problem(404, "resource_not_found", "Execution was not found.")
return StreamingResponse(
single_execution_event(public_event(execution)), media_type="text/event-stream"
execution_events_stream(app.state.sessions, execution_id, last_event_id),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@app.get("/api/v2/audit")