feat(api): V2-3 (Teil 1) — Ereignis-Bus und SSE-Endpunkt /api/v2/events
Ampel / ampel (push) Failing after 41s
Ampel / ampel (push) Failing after 41s
WAS: rippy/bus mit Ereignis-Schema, In-Process-Treiber und Ringpuffer.
Dazu der SSE-Strom in der API: erst ein Snapshot, danach nur Deltas,
mit lueckenloser Wiederaufnahme ueber Last-Event-ID.
WARUM: Ein offener Tab plus ein Worker verursachen heute rund 133
Anfragen pro Minute (Dashboard 75 + Log-Kasten 24 + Laufwerke 12 +
Worker-Liste 4 + Log-Seite 6 + Tray 12). Das Rate-Limit stand einmal
UNTER dieser Zahl — daher die sich leerende Job-Liste im Sekundentakt.
Mit einer offenen Verbindung sind es null.
DREI EIGENSCHAFTEN, ALLE AUS v1-FEHLERN:
1. Der Bus traegt NUR Nachrichten ueber Aenderungen, nie den Zustand.
Wer den Zustand will, fragt den Store. Damit kann ein verpasstes
Ereignis auch keinen Zustand loeschen — anders als beim fuenffachen
`catch(() => [])` im alten UI, wo jeder fehlgeschlagene Abruf
"es gibt keine Jobs" bedeutete.
2. Eine zu grosse Luecke wird ANGESAGT, nicht verschluckt. nachliefern()
gibt None ("hol dir ein ganzes Bild") statt [] ("nichts verpasst") —
dieselbe Unterscheidung wie timeout-Rueckgabe 124 bei den Netzpfaden.
Stillschweigend weiterzumachen waere schlimmer: Das UI hielte sich
fuer aktuell und waere es nicht.
3. Der Snapshot meldet unlesbare Laufwerke als None, nicht als leere
Liste. "Konnte nicht nachsehen" ist etwas anderes als "gibt es nicht".
Ein unbekannter Ereignistyp fliegt beim Senden HOCH statt durchzugehen.
Ein Tippfehler waere sonst der stillste aller Fehlschlaege: Nachricht
raus, kein Empfaenger, nirgends ein Hinweis.
Ein langsamer Zuhoerer (Tab im Hintergrund, lahmes Handy) bremst den
Sender nicht — er wird markiert und bekommt beim naechsten Mal einen
Snapshot. Ein Rip darf nicht auf einen Browser warten.
GEMESSEN: ruff sauber, 359 Tests gruen + 3 uebersprungen (vorher 346).
13 neue Bus-Tests laufen auf jeder Plattform; die vier SSE-Tests haengen
an main.py und laufen damit in der Ampel.
NOCH OFFEN in V2-3: das UI auf den Strom umstellen (neun setInterval)
und die Ereignisse an den Zustandsaenderungen ausloesen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d2645f87e8
commit
55db9eb13f
+125
-1
@@ -1,6 +1,6 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional, Dict
|
||||
import asyncio
|
||||
@@ -18,6 +18,8 @@ from rippy.rip import makemkv_daten
|
||||
import makemkv_key
|
||||
import mounts as mount_verwaltung
|
||||
from rippy.core import notify
|
||||
from rippy.bus import schema as bus_schema
|
||||
from rippy.bus.memory import bus as ereignis_bus
|
||||
import phasen
|
||||
import presets as preset_auswahl
|
||||
import rohdaten
|
||||
@@ -338,6 +340,128 @@ def _job_row_to_model(zeile: dict) -> Job:
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Live-Ereignisse (SSE) — Etappe V2-3
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# WAS DAS ERSETZT: Bis hierher fragte das UI im Takt nach. Nachgerechnet an
|
||||
# den Taktgebern (der Kommentar in ratelimit.py führt sie auf) verursacht EIN
|
||||
# offener Tab plus ein Worker rund 133 Anfragen pro Minute — und das Rate-Limit
|
||||
# stand einmal UNTER dieser Zahl, weshalb sich die Job-Liste im Sekundentakt
|
||||
# leerte. Mit einer offenen Verbindung sind es null.
|
||||
#
|
||||
# WARUM SSE UND NICHT WEBSOCKET: Gebraucht wird genau EIN Rückkanal, Server zu
|
||||
# Browser. Kommandos gehen weiter per REST (idempotent, protokollierbar, mit
|
||||
# der CLI teilbar). SSE bringt Wiederaufnahme (Last-Event-ID) und den
|
||||
# automatischen Reconnect des Browsers mit; bei WebSocket müsste man beides
|
||||
# selbst bauen.
|
||||
|
||||
SSE_HERZSCHLAG_SEKUNDEN = 15
|
||||
|
||||
|
||||
async def _snapshot() -> dict:
|
||||
"""Der vollständige Zustand — das ERSTE, was jeder Client bekommt.
|
||||
|
||||
Ohne diesen Anfang sähe ein frisch verbundenes UI nur die Änderungen ab
|
||||
jetzt und müsste den Rest raten. Mit ihm gilt: erst das ganze Bild, danach
|
||||
nur noch Deltas.
|
||||
"""
|
||||
def sammeln():
|
||||
return {
|
||||
"jobs": [_job_row_to_model(z).model_dump() for z in db.list_jobs(limit=50)],
|
||||
"workers": db.list_workers(),
|
||||
"logs": db.list_logs(limit=50),
|
||||
}
|
||||
|
||||
zustand = await asyncio.to_thread(sammeln)
|
||||
# Laufwerke getrennt: device_info macht ioctls, die hängen können —
|
||||
# ein defektes Laufwerk darf den Snapshot nicht aufhalten.
|
||||
try:
|
||||
zustand["devices"] = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
lambda: [device_discovery.device_info(p)
|
||||
for p in device_discovery.list_optical_devices()]),
|
||||
timeout=5,
|
||||
)
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
# „konnte nicht nachsehen" ist etwas anderes als „es gibt keine".
|
||||
# Deshalb None und nicht [] — das UI behält dann seinen alten Stand.
|
||||
zustand["devices"] = None
|
||||
return zustand
|
||||
|
||||
|
||||
def _sse_rahmen(ereignis: dict) -> str:
|
||||
"""Ein Ereignis im SSE-Format. `id:` ist die Basis der Wiederaufnahme."""
|
||||
nutzlast = json.dumps(ereignis, ensure_ascii=False)
|
||||
zeilen = [
|
||||
"id: " + str(ereignis["seq"]),
|
||||
"event: " + ereignis["typ"],
|
||||
"data: " + nutzlast,
|
||||
"",
|
||||
"",
|
||||
]
|
||||
return "\n".join(zeilen)
|
||||
|
||||
|
||||
@app.get("/api/v2/events")
|
||||
async def events(request: Request, last_event_id: str = None):
|
||||
"""Live-Strom: erst ein Snapshot, danach nur noch Änderungen.
|
||||
|
||||
Der Browser schickt beim Wiederverbinden von selbst `Last-Event-ID` mit.
|
||||
Passt die Lücke in den Ringpuffer, wird sie nachgeliefert; passt sie nicht,
|
||||
kommt ein neuer Snapshot — AUSDRÜCKLICH, nicht stillschweigend. Ein UI, das
|
||||
sich fälschlich für aktuell hält, ist schlimmer als eines, das neu lädt.
|
||||
"""
|
||||
kopf = request.headers.get("last-event-id") or last_event_id
|
||||
try:
|
||||
ab_seq = int(kopf) if kopf else None
|
||||
except ValueError:
|
||||
ab_seq = None
|
||||
|
||||
async def strom():
|
||||
abo = ereignis_bus.abonnieren(ab_seq)
|
||||
try:
|
||||
verpasst = ereignis_bus.nachliefern(ab_seq) if ab_seq is not None else None
|
||||
if verpasst is None:
|
||||
# Kein Rückstand bekannt oder Lücke zu groß -> ganzes Bild.
|
||||
yield _sse_rahmen(bus_schema.baue_ereignis(
|
||||
"snapshot", await _snapshot(), seq=ereignis_bus.seq))
|
||||
else:
|
||||
for ereignis in verpasst:
|
||||
yield _sse_rahmen(ereignis)
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
return
|
||||
ereignis = await abo.naechstes(timeout=SSE_HERZSCHLAG_SEKUNDEN)
|
||||
if ereignis is None:
|
||||
# Herzschlag: manche Proxys schließen stille Verbindungen.
|
||||
yield ": herzschlag\n\n"
|
||||
continue
|
||||
if abo.abgehaengt:
|
||||
# Dieser Zuhörer hat den Anschluss verloren — ganzes Bild
|
||||
# statt Bruchstücken.
|
||||
abo.abgehaengt = False
|
||||
yield _sse_rahmen(bus_schema.baue_ereignis(
|
||||
"snapshot", await _snapshot(), seq=ereignis_bus.seq))
|
||||
continue
|
||||
yield _sse_rahmen(ereignis)
|
||||
finally:
|
||||
abo.schliessen()
|
||||
|
||||
return StreamingResponse(
|
||||
strom(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
# nginx puffert `text/event-stream` sonst und der Strom käme
|
||||
# in Schüben statt live an.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "ok", "service": "api"}
|
||||
|
||||
Reference in New Issue
Block a user