feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) 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 22:50:32 +02:00
co-authored by Claude Opus 5
parent 3ccf3aa2d9
commit 1be73b266c
20 changed files with 1640 additions and 84 deletions
+149 -29
View File
@@ -1,34 +1,45 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { RefreshCw } from "lucide-react";
/**
* Holt alle zehn Sekunden den Zustand des Laufs (#013).
* Haelt die Abarbeitungsansicht aktuell (#013, Strom seit #033).
*
* Damit sehen zwei Browser am selben Lauf die Arbeit des anderen — der Fall aus
* dem Auftrag: zwei Techniker teilen sich ein Onboarding.
*
* Vier Entscheidungen stecken darin:
* **Seit #033 ueber einen Strom**, nicht mehr alle zehn Sekunden. Die Datenbank
* meldet die Aenderung, der Server schiebt sie weiter, und der Haken des
* Kollegen steht in unter einer Sekunde auf dem anderen Schirm statt nach bis zu
* zehn. Das Polling bleibt als **Rueckfall** — an derselben Adresse, mit
* derselben Nutzlast.
*
* Fuenf Entscheidungen stecken darin:
*
* 1. **Pause bei verstecktem Tab.** Ein Lauf bleibt oft stundenlang offen im
* Hintergrund; ohne Pause waeren das 360 Abfragen je Stunde und Tab, fuer
* niemanden.
* Hintergrund. Der Strom wird dabei **geschlossen**, nicht nur ignoriert:
* eine offene Verbindung je Hintergrundlasche ist genau das, was die
* Obergrenze im Handler verhindern soll.
* 2. **`router.refresh()` statt eigener Zustandsverwaltung.** Die Seite ist eine
* Server-Komponente; ein Neuladen ihrer Daten ist genau das, was gebraucht
* wird, und es gibt keine zweite Wahrheit im Browser. Aktualisiert wird nur,
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite alle zehn
* Sekunden neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
* wenn sich wirklich etwas geaendert hat — sonst wuerde die Seite bei jedem
* Ereignis neu rendern und dabei Eingaben verlieren, an denen jemand tippt.
* 3. **Steht ein generiertes Passwort auf der Seite, wird nicht von selbst
* neu geladen**, sondern gefragt. Das Passwort entsteht bei jedem Aufbau der
* Seite neu; ein Neuladen im Hintergrund tauschte es dem Techniker zwischen
* Lesen und Einfuegen aus. Die Antwort des Pollings traegt deshalb keine
* Befehle (siehe `laufZustand`) — und diese Zusage waere nichts wert, wenn
* das Polling stattdessen die ganze Seite neu bauen liesse.
* Lesen und Einfuegen aus. Die Nutzlast traegt deshalb keine Befehle (siehe
* `laufZustand`) — und diese Zusage waere nichts wert, wenn die
* Aktualisierung stattdessen die ganze Seite neu bauen liesse.
* 4. **Abbruch bei 401 und 404.** Eine abgelaufene Anmeldung oder ein
* verschwundener Lauf sind kein Grund weiterzufragen.
* 5. **Der Rueckfall wird einmal gemeldet, nicht bei jedem Versuch.** Sonst
* schreibt eine Seite hinter einem puffernden Proxy im Minutentakt dieselbe
* Zeile ins Protokoll der Konsole.
*/
/**
* Was ein geaenderter Zustand ausloesen soll.
*
@@ -40,6 +51,22 @@ export function reaktionAufAenderung(passwortSichtbar: boolean): "neu-laden" | "
return passwortSichtbar ? "fragen" : "neu-laden";
}
/** `EventSource.CLOSED`, ohne im Test ein `EventSource` zu brauchen. */
export const STROM_GESCHLOSSEN = 2;
/**
* Ist ein Fehler am Strom das Ende – oder nur eine Unterbrechung?
*
* `EventSource` verbindet sich nach einem Abbruch **selbst** wieder; dann steht
* `readyState` auf `CONNECTING`, und zurueckzufallen waere verfruehtes Aufgeben.
* Endgueltig ist nur `CLOSED` — so antwortet der Browser etwa, wenn die Antwort
* nicht `text/event-stream` ist. Genau das schickt der Handler bei erreichter
* Obergrenze, und genau dann soll gepollt werden.
*/
export function sollZurueckfallen(readyState: number): boolean {
return readyState === STROM_GESCHLOSSEN;
}
export function LaufAktualisierung({
laufId,
passwortSichtbar = false,
@@ -57,29 +84,46 @@ export function LaufAktualisierung({
const letzterStand = useRef<string | null>(null);
const [neues, setzeNeues] = useState(false);
/**
* Ein gelesener Stand: vergleichen, und nur bei echter Aenderung reagieren.
*
* Dieselbe Stelle fuer Strom und Polling. Waere der Vergleich zweimal
* geschrieben, koennten die beiden Wege unterschiedlich reagieren — und der
* Rueckfall waere dann kein Rueckfall, sondern ein zweites Verhalten.
*/
const standVerarbeiten = useCallback(
(stand: string): void => {
if (letzterStand.current !== null && letzterStand.current !== stand) {
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
else router.refresh();
}
letzterStand.current = stand;
},
[passwortSichtbar, router],
);
useEffect(() => {
let abgebrochen = false;
const adresse = `/api/laeufe/${laufId}/zustand`;
let eingestellt = false;
let quelle: EventSource | undefined;
let zeitgeber: ReturnType<typeof setTimeout> | undefined;
let polltJetzt = false;
let rueckfallGemeldet = false;
/* ---------------- Polling: der Rueckfall ---------------- */
async function holen(): Promise<void> {
if (abgebrochen || document.visibilityState === "hidden") {
if (eingestellt || document.visibilityState === "hidden") {
plane();
return;
}
try {
const antwort = await fetch(`/api/laeufe/${laufId}/zustand`, { cache: "no-store" });
const antwort = await fetch(adresse, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
abgebrochen = true;
eingestellt = true;
return;
}
if (antwort.ok) {
const stand = JSON.stringify(await antwort.json());
if (letzterStand.current !== null && letzterStand.current !== stand) {
if (reaktionAufAenderung(passwortSichtbar) === "fragen") setzeNeues(true);
else router.refresh();
}
letzterStand.current = stand;
}
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
} catch {
// Netz weg, Rechner im Standby: einfach beim naechsten Mal wieder.
}
@@ -87,23 +131,99 @@ export function LaufAktualisierung({
}
function plane(): void {
if (abgebrochen) return;
if (eingestellt || !polltJetzt) return;
zeitgeber = setTimeout(() => void holen(), 10_000);
}
void holen();
// Kommt der Tab zurueck, sofort fragen statt bis zu zehn Sekunden zu warten.
const beiSichtbarkeit = () => {
if (document.visibilityState === "visible") void holen();
function aufPollingUmstellen(grund: string): void {
if (eingestellt || polltJetzt) return;
polltJetzt = true;
stromSchliessen();
if (!rueckfallGemeldet) {
rueckfallGemeldet = true;
// Einmal, nicht bei jedem Versuch – siehe Kopfkommentar.
console.info(`Live-Aktualisierung nicht moeglich (${grund}), es wird gepollt.`);
}
void holen();
}
/* ---------------- Strom: der Normalfall ---------------- */
function stromSchliessen(): void {
quelle?.close();
quelle = undefined;
}
function stromOeffnen(): void {
if (eingestellt || polltJetzt || quelle || typeof EventSource === "undefined") return;
const neu = new EventSource(adresse);
quelle = neu;
neu.addEventListener("zustand", (ereignis) => {
standVerarbeiten((ereignis as MessageEvent<string>).data);
});
// Der Server sagt, dass es nichts mehr zu sehen gibt – nicht weiterfragen.
neu.addEventListener("ende", () => {
eingestellt = true;
stromSchliessen();
});
neu.addEventListener("error", () => {
if (sollZurueckfallen(neu.readyState)) aufPollingUmstellen("Strom geschlossen");
// Sonst: `EventSource` verbindet selbst wieder, hier ist nichts zu tun.
});
}
/* ---------------- Sichtbarkeit ---------------- */
const beiSichtbarkeit = (): void => {
if (eingestellt) return;
if (document.visibilityState === "hidden") {
// Verbindung wirklich freigeben, nicht bloss ignorieren.
stromSchliessen();
if (zeitgeber) clearTimeout(zeitgeber);
return;
}
/*
* Zurueck im Vordergrund: erst den Stand holen, dann wieder zuhoeren.
* Waehrend die Lasche weg war, kam keine Meldung an — ohne diesen Abruf
* stuende der alte Stand da, bis das naechste Mal jemand etwas aendert.
*/
if (polltJetzt) void holen();
else {
stromOeffnen();
void einmalHolen();
}
};
/** Ein einzelner Abruf, ohne das Polling einzuschalten. */
async function einmalHolen(): Promise<void> {
try {
const antwort = await fetch(adresse, { cache: "no-store" });
if (antwort.status === 401 || antwort.status === 404) {
eingestellt = true;
stromSchliessen();
return;
}
if (antwort.ok) standVerarbeiten(JSON.stringify(await antwort.json()));
} catch {
// Der Strom liefert den Stand ohnehin gleich mit.
}
}
if (typeof EventSource === "undefined") aufPollingUmstellen("kein EventSource");
else stromOeffnen();
document.addEventListener("visibilitychange", beiSichtbarkeit);
return () => {
abgebrochen = true;
eingestellt = true;
stromSchliessen();
if (zeitgeber) clearTimeout(zeitgeber);
document.removeEventListener("visibilitychange", beiSichtbarkeit);
};
}, [laufId, router, passwortSichtbar]);
}, [laufId, standVerarbeiten]);
// Ohne Passwort auf der Seite ist die Wirkung unsichtbar: sie erneuert sich.
if (!neues) return null;