feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 09:36:10 +02:00
co-authored by Claude Opus 5
parent 3cbd5026f8
commit 4aa79c93ff
20 changed files with 1640 additions and 84 deletions
+79
View File
@@ -0,0 +1,79 @@
/**
* Server-Sent Events: Format und Grenzen (#033).
*
* Framework-frei, weil beides geprueft werden muss, ohne eine Verbindung
* aufzubauen. Das Format ist winzig und hat genau eine Falle, die einen Strom
* still zerstoert — siehe `sseEreignis`.
*/
/** Der Kanal, auf dem die Datenbank Aenderungen meldet. Muss zur Migration passen. */
export const KANAL = "lauf_geaendert";
/**
* Ein Ereignis im SSE-Format.
*
* **Die Falle:** ein Zeilenumbruch in den Daten beendet das Feld. Steht in der
* Nutzlast ein `\n`, zerfaellt die Nachricht, und der Client bekommt Schrott
* oder gar nichts. Deshalb wird **jede** Zeile mit eigenem `data:` gesendet —
* so schreibt es die Spezifikation vor, und der Client fuegt sie mit `\n`
* wieder zusammen.
*
* Unsere Nutzlast ist JSON aus `JSON.stringify` ohne Einrueckung und hat heute
* nie einen Umbruch. „Hat heute nie“ ist aber keine Zusage, sondern ein Zufall:
* ein Lauftitel mit Umbruch, ein Notiztext, und der Strom reisst.
*/
export function sseEreignis(name: string, daten: string): string {
const zeilen = daten.split("\n").map((zeile) => `data: ${zeile}`);
return `event: ${name}\n${zeilen.join("\n")}\n\n`;
}
/**
* Der Herzschlag: ein Kommentar, den der Client verwirft.
*
* Ohne ihn schliesst ein Reverse Proxy die stille Verbindung nach seiner
* Leerlaufzeit — nginx nach 60 s, und der Browser sieht nur einen Abbruch.
* Ein Kommentar (Zeile mit `:`) kostet zwei Bytes und haelt sie offen.
*/
export const HERZSCHLAG = ": hz\n\n";
const STANDARD_HERZSCHLAG_MS = 25_000;
const STANDARD_MAX_STROEME = 200;
/**
* Abstand der Herzschlaege.
*
* 25 s, also unter der Leerlaufzeit von 60 s, die nginx und Caddy voreingestellt
* haben. Umstellbar, weil der Test nicht eine Minute warten soll, um zu sehen,
* dass ueberhaupt einer kommt.
*/
export function herzschlagMs(): number {
const roh = Number.parseInt(process.env["SSE_HERZSCHLAG_MS"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_HERZSCHLAG_MS;
}
/**
* Hoechstzahl gleichzeitig offener Stroeme je Instanz.
*
* Ein Strom belegt eine HTTP-Verbindung, solange die Lasche offen ist. Ohne
* Grenze belegt ein Haus mit vierzig offenen Laschen vierzig Verbindungen, und
* irgendwann nimmt der Server keine gewoehnliche Anfrage mehr an — die Seite
* waere dann nicht langsam, sondern tot. Ist die Grenze erreicht, antwortet der
* Handler gewoehnlich mit JSON, und der Client fuellt auf Polling zurueck.
*/
export function maxStroeme(): number {
const roh = Number.parseInt(process.env["SSE_MAX_STROEME"]?.trim() ?? "", 10);
return Number.isFinite(roh) && roh > 0 ? roh : STANDARD_MAX_STROEME;
}
/**
* Will der Aufrufer einen Strom?
*
* `EventSource` schickt `Accept: text/event-stream` von sich aus — das ist der
* einzige Unterschied zwischen dem Strom und dem Polling, und deshalb liegen
* beide an **derselben** Adresse: eine Rechtepruefung, ein Lesemodell, eine
* Wahrheit. Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand das
* Recht vergessen kann.
*/
export function willStrom(accept: string | null): boolean {
return (accept ?? "").split(",").some((teil) => teil.trim().startsWith("text/event-stream"));
}