feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
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:
co-authored by
Claude Opus 5
parent
3ccf3aa2d9
commit
1be73b266c
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user