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
+143 -12
View File
@@ -1,21 +1,41 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
/**
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
* Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
*
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
* erste Ereignis fliesst.
*
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
*
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
* am selben Lauf voneinander wissen muessen.
* entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
* Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
* Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
* was zwei Browser am selben Lauf voneinander wissen muessen.
*/
/*
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
* fassen einen Schwung zusammen.
*/
const SAMMELFENSTER_MS = 150;
export async function GET(
_anfrage: Request,
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzerId: string;
@@ -34,16 +54,127 @@ export async function GET(
}
const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle });
const benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer);
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
/*
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
*/
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
});
}
const kodierer = new TextEncoder();
let abmelden: (() => void) | undefined;
let herzschlag: ReturnType<typeof setInterval> | undefined;
let sammler: ReturnType<typeof setTimeout> | undefined;
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
function schicken(text: string): void {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(text));
} catch {
// Gegenseite ist weg, bevor `abort` ankam.
offen = false;
}
}
function aufraeumen(): void {
if (!offen) return;
offen = false;
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
try {
steuerung.close();
} catch {
// Schon geschlossen.
}
}
async function standSchicken(): Promise<void> {
if (!offen) return;
try {
const jetzt = await laufZustand(id, benutzer);
if (!jetzt) {
/*
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
* entzogen wurde. Der Strom endet; der Client holt sich an der
* gewoehnlichen Adresse die 404 und stellt ein.
*/
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
aufraeumen();
return;
}
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
} catch (fehler) {
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
}
}
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
// ohne zusaetzlich zu pollen.
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
try {
abmelden = await aufLaufHoeren(id, () => {
if (sammler) return;
sammler = setTimeout(() => {
sammler = undefined;
void standSchicken();
}, SAMMELFENSTER_MS);
});
} catch (fehler) {
/*
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
*/
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
aufraeumen();
return;
}
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen);
},
cancel() {
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
/*
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
*/
"X-Accel-Buffering": "no",
},
});
}
+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;
+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"));
}
+195
View File
@@ -0,0 +1,195 @@
import { Client } from "pg";
import { KANAL } from "@/lib/sse";
/**
* Aenderungen an einem Lauf, wie die Datenbank sie meldet (#033).
*
* Die Trigger aus `20261005090000_lauf_ereignisse` schicken
* `NOTIFY lauf_geaendert, '<laufId>'`. Hier haengt **eine** Verbindung je
* Prozess mit `LISTEN` daran und verteilt an die offenen Stroeme.
*
* **Eine Verbindung, nicht eine je Strom.** `LISTEN` belegt eine Verbindung
* dauerhaft; eine je Lasche waere bei vierzig Laschen vierzig belegte
* Datenbankverbindungen, und PostgreSQL erlaubt voreingestellt hundert
* insgesamt. Die Nutzlast sagt, welcher Lauf gemeint ist — das Verteilen
* passiert hier im Prozess.
*
* **Nicht aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurueck und
* verteilt sie neu; ein `LISTEN` darauf gilt nach der Rueckgabe fuer eine
* fremde Anfrage oder fuer niemanden. Deshalb ein eigener `pg.Client`.
*/
type Rueckruf = () => void;
type Register = {
readonly abonnenten: Map<string, Set<Rueckruf>>;
verbindung: Client | undefined;
aufbau: Promise<void> | undefined;
geschlossen: boolean;
};
/*
* An `globalThis`, aus demselben Grund wie der Prisma-Client in `src/lib/db.ts`:
* Hot Reload laedt das Modul neu und baute sonst bei jedem Speichern eine
* weitere LISTEN-Verbindung auf, ohne die alte zu schliessen.
*/
const global = globalThis as typeof globalThis & { laufzettelEreignisse?: Register };
function register(): Register {
global.laufzettelEreignisse ??= {
abonnenten: new Map(),
verbindung: undefined,
aufbau: undefined,
geschlossen: false,
};
return global.laufzettelEreignisse;
}
/** Alle Abonnenten eines Laufs wecken. */
function wecken(laufId: string): void {
for (const rueckruf of register().abonnenten.get(laufId) ?? []) {
try {
rueckruf();
} catch (fehler) {
// Ein kaputter Abonnent darf die anderen nicht mitnehmen.
console.error("Abonnent eines Lauf-Ereignisses hat geworfen:", fehler);
}
}
}
/** Alle Abonnenten wecken, egal welcher Lauf. Nach einem Verbindungsabbruch. */
function alleWecken(): void {
for (const laufId of [...register().abonnenten.keys()]) wecken(laufId);
}
async function verbindungAufbauen(): Promise<void> {
const eigen = register();
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt. Siehe .env.example.");
const client = new Client({ connectionString: url });
/*
* **Erst der Fehlerbehandler, dann verbinden.** Ein `error` auf einem Client
* ohne Behandler beendet in Node den ganzen Prozess — und eine Datenbank, die
* neu startet, ist der Normalfall, nicht die Ausnahme.
*/
client.on("error", (fehler) => {
console.error("LISTEN-Verbindung verloren:", fehler.message);
eigen.verbindung = undefined;
void wiederverbinden();
});
client.on("end", () => {
if (eigen.verbindung === client) {
eigen.verbindung = undefined;
void wiederverbinden();
}
});
client.on("notification", (nachricht) => {
if (nachricht.channel === KANAL && nachricht.payload) wecken(nachricht.payload);
});
await client.connect();
await client.query(`LISTEN ${KANAL}`);
eigen.verbindung = client;
/*
* **Nach einem Aufbau alle wecken.** Zwischen Abbruch und Wiederverbindung
* sind Meldungen verloren gegangen — NOTIFY hat keinen Zwischenspeicher. Wer
* jetzt nicht nachfragt, sieht den Haken des Kollegen nie. Lieber ein Abruf
* zu viel als ein stiller Stand, der falsch ist.
*/
alleWecken();
}
/**
* Nach einem Abbruch neu verbinden, mit wachsendem Abstand.
*
* Ohne Abstand liefe bei einer laenger weg gebliebenen Datenbank eine
* Endlosschleife, die nichts tut als Verbindungen zu versuchen.
*/
async function wiederverbinden(versuch = 1): Promise<void> {
const eigen = register();
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
const wartezeit = Math.min(30_000, 500 * 2 ** (versuch - 1));
await new Promise((fertig) => setTimeout(fertig, wartezeit));
if (eigen.geschlossen || eigen.abonnenten.size === 0) return;
try {
await verbindungAufbauen();
} catch {
await wiederverbinden(versuch + 1);
}
}
/** Sicherstellen, dass die eine Verbindung steht. Mehrfachaufrufe teilen sich den Aufbau. */
async function verbindungSicherstellen(): Promise<void> {
const eigen = register();
eigen.geschlossen = false;
if (eigen.verbindung) return;
// Laufender Aufbau: anhaengen statt einen zweiten starten.
eigen.aufbau ??= verbindungAufbauen().finally(() => {
eigen.aufbau = undefined;
});
await eigen.aufbau;
}
/**
* Auf Aenderungen eines Laufs hoeren.
*
* Gibt die Funktion zurueck, mit der das Abonnement endet. Sie **muss** gerufen
* werden, wenn der Strom schliesst — sonst waechst das Register mit jeder
* geschlossenen Lasche weiter, und der Zaehler fuer die Obergrenze erzaehlt
* Unsinn.
*/
export async function aufLaufHoeren(laufId: string, rueckruf: Rueckruf): Promise<() => void> {
const eigen = register();
const menge = eigen.abonnenten.get(laufId) ?? new Set<Rueckruf>();
menge.add(rueckruf);
eigen.abonnenten.set(laufId, menge);
try {
await verbindungSicherstellen();
} catch (fehler) {
// Aufraeumen, sonst zaehlt ein nie aufgebauter Strom weiter mit.
menge.delete(rueckruf);
if (menge.size === 0) eigen.abonnenten.delete(laufId);
throw fehler;
}
let beendet = false;
return () => {
if (beendet) return;
beendet = true;
menge.delete(rueckruf);
if (menge.size === 0) eigen.abonnenten.delete(laufId);
};
}
/**
* Zahl der offenen Stroeme in diesem Prozess.
*
* Grundlage der Obergrenze. Bewusst die Summe der Abonnenten und nicht die Zahl
* der Laeufe: zwei Techniker an einem Lauf sind zwei Verbindungen.
*/
export function stroemeOffen(): number {
let summe = 0;
for (const menge of register().abonnenten.values()) summe += menge.size;
return summe;
}
/**
* Die Verbindung schliessen und alle Abonnements verwerfen.
*
* Fuer den geordneten Abbau und fuer Tests, die nicht auf einer offenen
* Verbindung sitzen bleiben sollen.
*/
export async function ereignisseSchliessen(): Promise<void> {
const eigen = register();
eigen.geschlossen = true;
eigen.abonnenten.clear();
const client = eigen.verbindung;
eigen.verbindung = undefined;
if (client) await client.end().catch(() => undefined);
}
+12 -1
View File
@@ -456,6 +456,16 @@ export type LaufZustand = {
* (Reviewbefund zu #013).
*/
readonly anzahlKommentare: number;
/**
* Zahl der Anhaenge (#033).
*
* Aus demselben Grund wie die Kommentare: ein Foto, das der Kollege gerade an
* einen Schritt gehaengt hat, blieb sonst unsichtbar. Seit #032 gibt es
* Anhaenge, und seit #033 meldet die Datenbank ihre Aenderung — ein Ereignis
* ohne Entsprechung im Zustand loeste aber nichts aus, weil der Client
* vergleicht, bevor er neu laedt.
*/
readonly anzahlAnhaenge: number;
readonly geaendertAm: Date;
readonly schritte: readonly {
readonly schrittId: string;
@@ -487,7 +497,7 @@ export async function laufZustand(
select: {
status: true,
geaendertAm: true,
_count: { select: { kommentare: true } },
_count: { select: { kommentare: true, anhaenge: true } },
ergebnisse: {
select: {
vorlagenSchrittId: true,
@@ -504,6 +514,7 @@ export async function laufZustand(
return {
status: lauf.status,
anzahlKommentare: lauf._count.kommentare,
anzahlAnhaenge: lauf._count.anhaenge,
geaendertAm: lauf.geaendertAm,
anzahlPflichtOffen: lauf.ergebnisse.filter(
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),