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
3cbd5026f8
commit
4aa79c93ff
@@ -181,8 +181,18 @@ werden mit Name und Zeitpunkt festgehalten und lassen sich nicht löschen.
|
||||
|
||||
### Zwei Techniker am selben Lauf
|
||||
|
||||
Die Seite fragt alle zehn Sekunden nach Änderungen und lädt sich neu, wenn sich wirklich etwas
|
||||
geändert hat. Bei verstecktem Tab pausiert die Abfrage.
|
||||
Hakt der Kollege einen Schritt ab, erscheint das bei dir **sofort** — ohne dass du etwas tun musst.
|
||||
Dasselbe gilt für Kommentare, angehängte Fotos und einen geänderten Titel. Die Seite lädt sich nur
|
||||
dann neu, wenn sich wirklich etwas geändert hat; an einem Feld, in das du gerade tippst, geht dabei
|
||||
nichts verloren.
|
||||
|
||||
Ist die Lasche im Hintergrund, pausiert das. Holst du sie zurück, steht gleich der aktuelle Stand
|
||||
da.
|
||||
|
||||
Bleibt die Seite doch einmal stehen, obwohl jemand anders arbeitet, aktualisiert sie sich spätestens
|
||||
nach zehn Sekunden von selbst — das ist der eingebaute Rückfall. Dauert es dauerhaft so lange, liegt
|
||||
es meist am Reverse Proxy; der Abschnitt „Die Seite aktualisiert sich nur langsam“ in
|
||||
[Fehlerbehebung](08-fehlerbehebung.md) sagt, was zu tun ist.
|
||||
|
||||
Steht ein erzeugtes Passwort auf dem Bildschirm, lädt sie **nicht** von selbst neu — stattdessen
|
||||
erscheint oben ein Streifen: „Jemand anderes hat an diesem Lauf etwas geändert. Beim Neuladen
|
||||
|
||||
@@ -77,6 +77,28 @@ Zurückholen wird protokolliert.
|
||||
**Vorbeugen:** archiviere erst, wenn wirklich alles drinsteht. Es gibt keinen Grund zur Eile — ein
|
||||
abgeschlossener Lauf steht auch schon im Archiv und ist durchsuchbar.
|
||||
|
||||
## Die Seite aktualisiert sich nur langsam
|
||||
|
||||
**Was du siehst:** Der Kollege hakt einen Schritt ab, und bei dir erscheint es erst nach rund zehn
|
||||
Sekunden statt sofort. Alles funktioniert, es ist nur träge.
|
||||
|
||||
**Ursache:** Die Seite hält normalerweise eine offene Verbindung zum Server, über die Änderungen
|
||||
sofort ankommen. Kommt die nicht zustande, schaltet sie von selbst auf Nachfragen im
|
||||
Zehn-Sekunden-Takt um — die Arbeit geht also weiter, nur nicht in Echtzeit. Fast immer liegt das am
|
||||
Reverse Proxy, der die Verbindung sammelt statt sie durchzulassen.
|
||||
|
||||
**Was zu tun ist** (für Administratoren): In `docs/betrieb/reverse-proxy.md` stehen die beiden
|
||||
nötigen Zeilen — `proxy_buffering off` für nginx, `flush_interval -1` für Caddy — und ein
|
||||
Mindestwert für `proxy_read_timeout`. Fällt der unter 30 Sekunden, reißt die Verbindung zwischen
|
||||
zwei Herzschlägen immer wieder ab.
|
||||
|
||||
**Zweitursache:** Sehr viele gleichzeitig offene Laschen. Ab einer Obergrenze (`SSE_MAX_STROEME`,
|
||||
voreingestellt 200 je Instanz) bekommen weitere Laschen bewusst den langsamen Weg, damit der Server
|
||||
erreichbar bleibt. Dann hilft, nicht mehr gebrauchte Laschen zu schließen.
|
||||
|
||||
**Was es nicht ist:** ein Datenverlust. Gespeichert wird beim Klick, nicht beim Aktualisieren. Was du
|
||||
siehst, kann veraltet sein; was in der Datenbank steht, ist es nie.
|
||||
|
||||
## „Die Datei zu … fehlt im Dateisystem“
|
||||
|
||||
**Ursache:** In der Datenbank steht die Zeile eines Anhangs, die Datei selbst ist nicht da. Fast
|
||||
|
||||
+42
-10
@@ -185,19 +185,51 @@ Das **generierte Passwort** entsteht erst beim Ersetzen und wird nirgends zurüc
|
||||
keinen Weg, es außerhalb des fertigen Texts zu bekommen. Gäbe es ein Feld dafür, wäre die
|
||||
Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
|
||||
|
||||
## 5d. Warum die Aktualisierung keine Befehle mitliefert
|
||||
## 5d. Wie die Aktualisierung funktioniert – und warum sie keine Befehle mitliefert
|
||||
|
||||
Die Abarbeitungsansicht fragt alle zehn Sekunden `GET /api/laeufe/[id]/zustand` — zwei Techniker
|
||||
teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu laden.
|
||||
Diese Antwort trägt **nur** Status und Schrittzustände, ausdrücklich keine fertigen Befehle.
|
||||
Die Abarbeitungsansicht hält `GET /api/laeufe/[id]/zustand` als **Ereignisstrom** offen — zwei
|
||||
Techniker teilen sich ein Onboarding und sollen die Arbeit des anderen sehen, ohne die Seite neu zu
|
||||
laden. Diese Nutzlast trägt **nur** Status, Schrittzustände und die Zahl der Kommentare und
|
||||
Anhänge, ausdrücklich keine fertigen Befehle.
|
||||
|
||||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte das Polling
|
||||
Befehle mit, stünde alle zehn Sekunden ein anderes Passwort auf dem Bildschirm als das, das der
|
||||
Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal je
|
||||
Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas geändert
|
||||
hat. Bei verstecktem Tab pausiert die Abfrage.
|
||||
Der Grund ist `{{generiertes_passwort}}`: es entsteht bei jedem Auflösen neu. Lieferte die
|
||||
Aktualisierung Befehle mit, stünde nach jeder Änderung ein anderes Passwort auf dem Bildschirm als
|
||||
das, das der Techniker gerade in die Konsole geklebt hat. Die Befehle entstehen deshalb genau einmal
|
||||
je Seitenaufbau, und der Client lädt die Seite erst neu, wenn sich am Zustand wirklich etwas
|
||||
geändert hat. Bei verstecktem Tab wird der Strom geschlossen.
|
||||
|
||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück.
|
||||
Der Route Handler prüft sein Recht selbst und gibt einen fremden Lauf als „nicht gefunden“ zurück —
|
||||
auch und gerade für den Strom, **bevor** das erste Ereignis fließt.
|
||||
|
||||
### Warum die Datenbank das Ereignis schickt, nicht der Dienst
|
||||
|
||||
Bis #033 fragte die Seite alle zehn Sekunden. Das war richtig, aber zehn Sekunden sind lang, wenn
|
||||
zwei Leute dieselbe Liste abhaken, und zwanzig offene Laschen bedeuteten zwanzig Abfragen je zehn
|
||||
Sekunden. Seit #033 melden vier Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung
|
||||
je Prozess hört zu, und der Route Handler schiebt weiter.
|
||||
|
||||
**In der Datenbank und nicht im Service**, aus demselben Grund, aus dem auch die Regeln dort stehen:
|
||||
ein Ereignis, das der Service verschickt, entsteht nur auf dem Weg durch den Service. Ein Trigger
|
||||
sieht auch den Seed, eine Migration und die Reparatur per `psql`. Und `NOTIFY` geht an **jede**
|
||||
Verbindung mit `LISTEN` — also an jede Instanz der Anwendung, ohne einen zweiten Dienst.
|
||||
|
||||
**Welche Tabellen melden, hängt an dem, was das Lesemodell liefert.** Das Polling verglich den
|
||||
ganzen Stand und sah damit auch, was kein Trigger meldet; ein Strom sieht nur, was gemeldet wird.
|
||||
Fehlte `Kommentar` oder `Anhang`, wäre die Live-Aktualisierung gegenüber dem Polling ein
|
||||
**Rückschritt** — der Kommentar des Kollegen käme nie an. `tests/datenbankregeln.test.ts` zählt die
|
||||
vier Trigger namentlich auf, `tests/zustand-route.test.ts` die Schlüssel des Zustands.
|
||||
|
||||
**Das Polling bleibt, als Rückfall an derselben Adresse.** Wer kein `EventSource` hat oder hinter
|
||||
einem Proxy sitzt, der Ströme puffert, bekommt den alten Weg — dieselbe Rechteprüfung, dasselbe
|
||||
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand das Recht
|
||||
vergessen kann. Unterschieden wird allein am `Accept`-Kopf, den `EventSource` von sich aus schickt.
|
||||
Dieser Rückfall wird gefahren, nicht behauptet: `tests/e2e/live.spec.ts` nimmt dem Browser
|
||||
`EventSource` weg.
|
||||
|
||||
Eine Obergrenze (`SSE_MAX_STROEME`, Vorgabe 200) begrenzt die offenen Ströme je Instanz. Ist sie
|
||||
erreicht, antwortet der Handler gewöhnlich mit JSON; `EventSource` meldet darauf einen Fehler und
|
||||
versucht es nicht wieder, und der Client fällt von sich aus auf Polling zurück. Das braucht keinen
|
||||
Sonderfall und keinen eigenen Fehlercode.
|
||||
Die Hülle unter `src/app/(app)/layout.tsx` deckt `src/app/api/` nicht ab.
|
||||
|
||||
## 5e. Der Aufkleber ist die Schnittstelle
|
||||
|
||||
@@ -58,6 +58,14 @@ laufzettel.firma.de {
|
||||
# gerade beim Neustart. Der Pfad braucht keine Anmeldung.
|
||||
health_uri /api/gesundheit
|
||||
health_interval 30s
|
||||
|
||||
# Die Live-Aktualisierung ist ein Ereignisstrom (#033). Caddy erkennt das
|
||||
# heute an `Content-Type: text/event-stream` und reicht von sich aus
|
||||
# sofort durch – ausdruecklich gesetzt bleibt es auch dann richtig, wenn
|
||||
# eine Fassung das einmal anders handhabt. Ohne sofortiges Durchreichen
|
||||
# sammelt der Proxy den Strom, und die Seite sieht aus, als sei sie
|
||||
# haengen geblieben.
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -102,9 +110,16 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/laufzettel.firma.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/laufzettel.firma.de/privkey.pem;
|
||||
|
||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action. nginx
|
||||
# laesst von sich aus nur 1 MB durch und waere damit die erste Wand.
|
||||
client_max_body_size 8m;
|
||||
# Der CSV-Import schickt die Datei als Text durch eine Server Action, und
|
||||
# seit #032 haengt jemand Fotos an Schritte. nginx laesst von sich aus nur
|
||||
# 1 MB durch und waere damit die erste Wand.
|
||||
#
|
||||
# **Muss ueber `ANHANG_MAX_MB` liegen.** Steht dort die Vorgabe 10, dann
|
||||
# reichen 8m nicht: ein 10-MB-Foto scheitert an nginx mit „413 Request
|
||||
# Entity Too Large“, und die Meldung der Anwendung („Erlaubt sind hoechstens
|
||||
# 10,0 MB“) kommt nie zum Zuge. Wer die Grenze in der Anwendung anhebt,
|
||||
# hebt sie hier mit an — mit etwas Luft fuer den Rahmen des Formulars.
|
||||
client_max_body_size 12m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
@@ -117,12 +132,22 @@ server {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# Der Lauf fragt seinen Zustand alle zehn Sekunden nach; 90 s sind
|
||||
# reichlich und fangen einen langsamen Verzeichnis-Abgleich mit ab.
|
||||
# Die Abarbeitungsansicht haelt seit #033 einen **offenen** Strom auf
|
||||
# /api/laeufe/<id>/zustand. Der Server schickt alle 25 s einen
|
||||
# Herzschlag, damit nginx die stille Verbindung nicht schliesst; 90 s
|
||||
# sind reichlich dafuer und fangen einen langsamen Verzeichnis-Abgleich
|
||||
# mit ab. **Unter 30 s darf dieser Wert nicht fallen**, sonst reisst der
|
||||
# Strom zwischen zwei Herzschlaegen immer wieder ab.
|
||||
proxy_read_timeout 90s;
|
||||
|
||||
# Next liefert Seiten stroemend aus. Ohne das haelt nginx die Antwort
|
||||
# zusammen, und die Seite erscheint spuerbar spaeter auf einmal.
|
||||
# Next liefert Seiten stroemend aus, und die Live-Aktualisierung ist ein
|
||||
# Ereignisstrom. Ohne das haelt nginx die Antwort zusammen: die Seite
|
||||
# erscheint spuerbar spaeter auf einmal, und vom Strom kommt gar nichts
|
||||
# an — sie sieht dann aus, als sei sie haengen geblieben.
|
||||
#
|
||||
# Die Anwendung schickt zusaetzlich `X-Accel-Buffering: no` auf der
|
||||
# Antwort des Stroms. Das wirkt auch, wenn hier `on` stuende, und deckt
|
||||
# genau diesen einen Pfad ab; die Zeile hier gilt fuer alles andere.
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
+65
-2
@@ -31,12 +31,13 @@ als Unterpunkte mit Aufgabe und Dauer.
|
||||
| #030 | M17 – Wiederverwendbare Bausteine | 2 h 25 min |
|
||||
| #031 | M18 – Abteilungsspezifische Zusatzabschnitte | 1 h 35 min |
|
||||
| #032 | M19 – Anhänge und Fotos an Schritten | 3 h 05 min |
|
||||
| | **Summe** | **48 h 25 min** |
|
||||
| #033 | M20 – Live-Aktualisierung per SSE statt Polling | 1 h 40 min |
|
||||
| | **Summe** | **50 h 05 min** |
|
||||
|
||||
| Meilenstein | Tatsächlich | Status |
|
||||
|---|---|---|
|
||||
| v0.1.0 | 35 h 20 min | veröffentlicht |
|
||||
| v0.2.0 | 13 h 05 min | in Arbeit |
|
||||
| v0.2.0 | 14 h 45 min | in Arbeit |
|
||||
|
||||
---
|
||||
|
||||
@@ -1176,3 +1177,65 @@ und nicht nach jeder einzelnen — das ist der Grund, warum er alles auf einmal
|
||||
großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
- **#027** Die Editor-Abläufe lassen ihre Vorlagen in der Entwicklungsdatenbank stehen.
|
||||
|
||||
---
|
||||
|
||||
## 2026-10-05 · Sitzung 18 · 09:00–10:40
|
||||
|
||||
**Issue:** #033 (M20)
|
||||
**Version:** 0.2.0 (unveröffentlicht)
|
||||
|
||||
### Erledigt
|
||||
|
||||
- **Die Live-Aktualisierung läuft über einen Ereignisstrom.** Vier Trigger schicken
|
||||
`NOTIFY lauf_geaendert, '<laufId>'`, eine LISTEN-Verbindung je Prozess hört zu, der Route Handler
|
||||
schiebt weiter. Gemessen: der Haken ist nach **485 ms** beim Zuschauer (Handy: 487 ms), vorher
|
||||
bis zu zehn Sekunden.
|
||||
- **Das Polling bleibt als Rückfall** — an derselben Adresse, unterschieden nur am `Accept`-Kopf.
|
||||
- 1266 Vitest-Tests (vorher 1247), dazu `tests/e2e/live.spec.ts` mit zwei Browser-Kontexten.
|
||||
- Der Herzschlag ist **echt über 70 Sekunden** gefahren: `readyState` blieb `OPEN`, ein einziges
|
||||
Ereignis, kein Neuaufbau.
|
||||
|
||||
### Entscheidungen
|
||||
|
||||
- **Das Ereignis kommt aus der Datenbank, nicht aus dem Dienst.** Ein Ereignis, das der Service
|
||||
verschickt, entsteht nur auf dem Weg durch den Service — ein Trigger sieht auch den Seed, eine
|
||||
Migration und die Reparatur per `psql`. Und `NOTIFY` erreicht jede Instanz, ohne einen zweiten
|
||||
Dienst wie Redis.
|
||||
- **Ein Handler für beide Wege.** Damit ist „das Polling bleibt als Rückfall, nicht als zweite
|
||||
Wahrheit“ keine Absichtserklärung, sondern Bauart: eine Rechteprüfung, ein Lesemodell, eine
|
||||
Nutzlast.
|
||||
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||
mit JSON; `EventSource` meldet darauf einen Fehler und versucht es nicht wieder, und der Client
|
||||
fällt von sich aus zurück. Kein eigener Fehlercode.
|
||||
- **Keine Verbindung aus dem Prisma-Pool.** Ein Pool gibt Verbindungen zurück und verteilt sie neu;
|
||||
ein `LISTEN` darauf gilt danach für eine fremde Anfrage oder für niemanden.
|
||||
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert — sonst belegte jede
|
||||
Hintergrundlasche genau die Verbindung, die die Obergrenze schützen soll.
|
||||
|
||||
### Probleme
|
||||
|
||||
- **Das Issue hätte einen Rückschritt gebaut.** Es nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||
`laufZustand` trägt aber auch die Zahl der Kommentare, und seit #032 gibt es Anhänge: der Strom
|
||||
sieht **nur**, was gemeldet wird, während das Polling den ganzen Stand verglich. Mit den zwei
|
||||
Triggern aus dem Issue wäre der Kommentar des Kollegen nie angekommen — und die Seite hätte
|
||||
ausgesehen wie immer. Das ist derselbe Fehlertyp wie in #032: eine Zusage, die niemand prüft.
|
||||
Jetzt melden vier Tabellen, `laufZustand` trägt `anzahlAnhaenge`, und beide Tests zählen namentlich
|
||||
auf, was dazugehört.
|
||||
- **Zwei Fehler in `reverse-proxy.md`, beide von vorgestern.** `client_max_body_size 8m` bei
|
||||
`ANHANG_MAX_MB=10`: ein 10-MB-Foto wäre an nginx mit 413 gescheitert, und die eigene Meldung
|
||||
(„Erlaubt sind höchstens 10,0 MB“) wäre nie erschienen. Dazu ein Kommentar, der noch das alte
|
||||
Polling beschrieb. **Gefunden beim Schreiben der Doku, nicht von einem Test** — Dateien, die ein
|
||||
Dritter liest, prüft hier niemand.
|
||||
- **`## 5e` war in `docs/architektur.md` schon belegt.** Statt alles umzunummerieren und Verweise zu
|
||||
riskieren, hängt der neue Text als `###` unter 5d.
|
||||
- **Zwei eigene Testfehler, keine Produktfehler:** `schritte` zählte die „erledigt“-Knöpfe der
|
||||
ganzen Seite (34) statt die des ersten Abschnitts (5), und ein `const` nach einem `?.`-Guard
|
||||
verlor in der Closure seine Verengung. Beide Male war die Meldung eindeutig.
|
||||
|
||||
### Nächster Schritt
|
||||
|
||||
- **M21 (#034)** Benachrichtigungen per Mail und Webhook — baut direkt auf den Ereignissen auf, die
|
||||
jetzt stehen. Danach **M22 (#035)** Selbstauskunft.
|
||||
- **M15 (#028)** Graph-Quelle und **M16 (#029)** SCIM sind die beiden großen verbliebenen.
|
||||
- Weiter offen und nicht von hier aus machbar: **#024** Proxmox-Einzeiler auf einem echten PVE-Host.
|
||||
|
||||
+57
-15
@@ -1,10 +1,10 @@
|
||||
# #033 M20 – Live-Aktualisierung per SSE statt Polling
|
||||
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Im Tracker:** [#53](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/53)
|
||||
**Meilenstein:** v0.2.0
|
||||
**Labels:** phase-2, backend, frontend
|
||||
**Geschätzt:** 6 h **Tatsächlich:** –
|
||||
**Geschätzt:** 6 h **Tatsächlich:** 1 h 40 min
|
||||
|
||||
## Ziel
|
||||
|
||||
@@ -17,36 +17,78 @@ Die Vorleistung steht: das Polling liegt in genau einem Route Handler
|
||||
|
||||
## Zu entscheiden
|
||||
|
||||
- [ ] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
|
||||
- [x] **`LISTEN`/`NOTIFY` in Postgres als Signal** — Empfehlung. Ein Trigger auf `Schrittergebnis`
|
||||
und `Lauf` schickt `NOTIFY lauf_geaendert, '<laufId>'`; der Route Handler hält eine Verbindung
|
||||
und schiebt weiter. Damit funktioniert es auch mit mehreren Instanzen der Anwendung, ohne
|
||||
einen zweiten Dienst. Die Alternative — der Server fragt selbst alle zwei Sekunden — wäre
|
||||
dasselbe Polling, nur an anderer Stelle.
|
||||
- [ ] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
|
||||
- [x] **Polling bleibt als Rückfall**, nicht als zweite Wahrheit: derselbe Handler antwortet weiter
|
||||
auf eine gewöhnliche Anfrage. Wer kein `EventSource` hat oder hinter einem Proxy sitzt, der
|
||||
Ströme puffert, bekommt den alten Weg. Ein Rückfall, der nie geprüft wird, ist keiner —
|
||||
deshalb ein Test je Weg.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
- [ ] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
|
||||
- [x] Trigger und `NOTIFY` in einer handgeschriebenen Migration, mit dem üblichen Vermerk (Prisma
|
||||
kennt Trigger nicht und will sie löschen)
|
||||
- [ ] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
|
||||
- [x] Route Handler liefert `text/event-stream`, schickt alle 25 s einen Kommentar als Herzschlag
|
||||
(sonst schließen Proxys die Verbindung) und beendet sauber bei `abort`
|
||||
- [ ] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
|
||||
- [x] **Der Handler prüft Recht und Mandant selbst** — ein offener Strom ist ein Leseweg wie jeder
|
||||
andere (CLAUDE.md: Route Handler deckt die Hülle nicht ab)
|
||||
- [ ] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
|
||||
- [x] Client: `EventSource` mit Wiederverbindung; bei versteckter Lasche pausieren wie heute; bei
|
||||
`error` auf Polling zurückfallen und das **einmal** melden, nicht bei jedem Versuch
|
||||
- [ ] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
|
||||
- [x] `docs/betrieb/reverse-proxy.md`: `proxy_buffering off` für nginx, `flush_interval -1` für
|
||||
Caddy — ohne das kommt kein Ereignis an, und die Seite sieht aus, als sei sie hängen geblieben
|
||||
- [ ] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
|
||||
- [x] Obergrenze für gleichzeitige Ströme je Instanz, damit ein Browser mit vierzig Tabs die
|
||||
Verbindungen nicht aufbraucht
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
- [ ] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
|
||||
- [x] Test: zwei Sitzungen am selben Lauf — hakt die eine ab, sieht die andere es in unter einer
|
||||
Sekunde, ohne neu zu laden
|
||||
- [ ] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
|
||||
- [ ] Test: ein Lauf eines fremden Kunden liefert keinen Strom
|
||||
- [ ] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
|
||||
- [ ] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
|
||||
- [x] Test: ohne Recht wird der Strom abgewiesen, bevor das erste Ereignis fließt
|
||||
- [x] Test: ein Lauf eines fremden Kunden liefert keinen Strom
|
||||
- [x] Test: der Rückfall auf Polling funktioniert — gefahren mit abgeschaltetem `EventSource`
|
||||
- [x] Der Herzschlag kommt an: eine Verbindung überlebt 60 s ohne Änderung
|
||||
|
||||
## Beim Umsetzen dazugekommen
|
||||
|
||||
- **Es wäre ein Rückschritt geworden.** Das Issue nannte Trigger auf `Schrittergebnis` und `Lauf`.
|
||||
`laufZustand` trägt aber auch die Zahl der **Kommentare** — das Polling verglich alle zehn
|
||||
Sekunden den ganzen Stand und sah sie damit, ein Strom sieht nur, was gemeldet wird. Mit den zwei
|
||||
Triggern aus dem Issue wäre der Kommentar des Kollegen **nie** angekommen, und niemandem wäre
|
||||
etwas aufgefallen: die Seite sieht aus wie immer. Jetzt melden vier Tabellen, und
|
||||
`tests/datenbankregeln.test.ts` zählt sie namentlich auf. Gegenprobe gefahren: ohne die Trigger auf
|
||||
`Kommentar` und `Anhang` wird `tests/lauf-ereignisse.test.ts` rot.
|
||||
- **`laufZustand` hat `anzahlAnhaenge` bekommen.** Anhänge gibt es seit #032, im Zustand standen sie
|
||||
nicht. Ein Ereignis ohne Entsprechung im Zustand löst nichts aus, weil der Client vergleicht,
|
||||
bevor er neu lädt — ein angehängtes Foto wäre also auch mit Trigger unsichtbar geblieben.
|
||||
- **Ein Handler für beide Wege, unterschieden am `Accept`-Kopf.** So steht „Polling bleibt als
|
||||
Rückfall, nicht als zweite Wahrheit“ nicht nur im Issue: es ist dieselbe Rechteprüfung, dasselbe
|
||||
Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt wäre eine zweite Stelle, an der jemand
|
||||
`rechtErforderlich` vergessen kann.
|
||||
- **Die Obergrenze braucht keinen Sonderfall.** Ist sie erreicht, antwortet der Handler gewöhnlich
|
||||
mit JSON. `EventSource` verlangt `text/event-stream`, meldet einen Fehler und versucht es **nicht
|
||||
wieder** — der Client fällt damit von sich aus auf Polling zurück. Kein eigener Fehlercode, kein
|
||||
zweiter Pfad.
|
||||
- **Eine LISTEN-Verbindung je Prozess, nicht je Strom** — und ausdrücklich **nicht** aus dem
|
||||
Prisma-Pool: ein Pool gibt Verbindungen zurück und verteilt sie neu, ein `LISTEN` darauf gilt
|
||||
danach für eine fremde Anfrage oder für niemanden.
|
||||
- **Nach einem Verbindungsabbruch werden alle Zuhörer geweckt.** `NOTIFY` hat keinen
|
||||
Zwischenspeicher; was während des Abbruchs passierte, ist verloren. Lieber ein Abruf zu viel als
|
||||
ein stiller Stand, der falsch ist.
|
||||
- **Bei versteckter Lasche wird der Strom geschlossen**, nicht bloß ignoriert. Eine offene
|
||||
Verbindung je Hintergrundlasche ist genau das, was die Obergrenze verhindern soll.
|
||||
- **Zwei Fehler in `docs/betrieb/reverse-proxy.md` gefunden, beide aus #032:**
|
||||
`client_max_body_size` stand auf **8m**, während `ANHANG_MAX_MB` 10 erlaubt — ein 10-MB-Foto wäre
|
||||
an nginx mit „413“ gescheitert, und die freundliche Meldung der Anwendung wäre nie zum Zuge
|
||||
gekommen. Jetzt 12m, mit dem Hinweis, dass beide Werte zusammengehören. Dazu beschrieb der
|
||||
Kommentar am `proxy_read_timeout` noch das alte Polling.
|
||||
- **Der Herzschlag ist echt über 70 Sekunden gefahren**, nicht nur im Test mit verkürztem Intervall:
|
||||
`readyState` blieb `OPEN`, ein einziges Zustands-Ereignis, kein Fehler, kein Neuaufbau.
|
||||
- **Gemessen statt behauptet:** der Haken war nach **485 ms** beim Zuschauer (Desktop) und nach
|
||||
**487 ms** am Handy. Mit abgeschaltetem `EventSource` dauert derselbe Ablauf rund 13 Sekunden —
|
||||
das ist zugleich der Nachweis, dass der Strom wirklich benutzt wird und nicht heimlich gepollt
|
||||
wird.
|
||||
- **Der Testname versprach `tests/e2e/live.spec.ts`**, also gibt es die Datei. Zwei Browser-Kontexte,
|
||||
und für den Rückfall wird dem Zuschauer `EventSource` vor dem Laden weggenommen.
|
||||
|
||||
@@ -37,7 +37,7 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac
|
||||
| #030 | [M17 – Wiederverwendbare Bausteine](030-bausteine.md) | erledigt | v0.2.0 | 12 h | 2 h 25 min |
|
||||
| #031 | [M18 – Abteilungsspezifische Zusatzabschnitte](031-abteilungsabschnitte.md) | erledigt | v0.2.0 | 8 h | 1 h 35 min |
|
||||
| #032 | [M19 – Anhänge und Fotos an Schritten](032-anhaenge.md) | erledigt | v0.2.0 | 10 h | 3 h 05 min |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | offen | v0.2.0 | 6 h | – |
|
||||
| #033 | [M20 – Live-Aktualisierung per SSE statt Polling](033-sse-live.md) | erledigt | v0.2.0 | 6 h | 1 h 40 min |
|
||||
| #034 | [M21 – Benachrichtigungen per Mail und Webhook](034-benachrichtigungen.md) | offen | v0.2.0 | 10 h | – |
|
||||
| #035 | [M22 – Mitarbeiter-Selbstauskunft](035-selbstauskunft.md) | offen | v0.2.0 | 8 h | – |
|
||||
| | **Summe** | | | **229 h** | **48 h 25 min** |
|
||||
| | **Summe** | | | **229 h** | **50 h 05 min** |
|
||||
|
||||
Reference in New Issue
Block a user