feat(backend): Aenderungsfeed per Long Polling (Schritt 4)

GET /documents/{id}/changes haelt die Anfrage offen und antwortet, sobald
sich etwas tut — kumuliertes Diff seit der bekannten Version, dazu die
Ereignisse mit ihrem Absender. Ist die Basis verdichtet oder hat der Client
noch gar nichts, kommt der Volltext statt der Operationen: ein Roundtrip und
ein Sonderzustand weniger als ein eigener Fehlerpfad.

Der Feed arbeitet auf der Historie, nicht am Dokument — ein geloeschtes
Dokument muss sein DELETED noch zustellen koennen.

Blockierend auf virtuellen Threads statt DeferredResult (D76-Nachtrag 5):
So behaelt der Endpunkt die aus der Spezifikation generierte Signatur, und
API-First bleibt fuer ihn unangetastet; ein Wartender kostet trotzdem fast
nichts. Geweckt wird nach dem Commit, nie davor, und ueber einen Stempel, den
der Aufrufer VOR dem Nachsehen liest — sonst ginge ein Signal aus der Luecke
dazwischen verloren.

122 Tests. Das Szenario "ein Wartender wird geweckt" misst die Dauer: Ohne
das bestuende es auch dann, wenn der Wartende bloss in den Timeout liefe und
danach die Aenderung vorfaende. Gegenprobe: Benachrichtigung entfernt ->
genau dieses Szenario faellt; Volltext-Rueckfall entfernt -> genau jenes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-26 17:16:13 +02:00
co-authored by Claude Opus 5
parent ae85503b79
commit 741b41ef11
21 changed files with 818 additions and 25 deletions
+26 -16
View File
@@ -1,8 +1,9 @@
# Live-Editing über HTTP (Variante „Simpel")
Status: **Konzept entschieden** (D76), **Schritte 13 der Umsetzungsreihenfolge
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`); der
Änderungsfeed, das Master-Passwort und der Client stehen aus. Die offenen
Status: **Konzept entschieden** (D76), **Schritte 14 der Umsetzungsreihenfolge
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed);
das Master-Passwort und der Client stehen aus, ebenso das Umbenennen per
`PATCH /title` und damit das Ereignis `RENAMED`. Die offenen
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in
@@ -232,8 +233,10 @@ bekommen**, bevor die Warteliste verworfen wird.
~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich,
kein WebSocket nötig.
**Änderungstypen im Feed:** `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`
und `RENAMED` (dieses mit dem neuen Titel im Klartext).
**Änderungstypen im Feed:** `CREATED`, `UPDATED`, `DELETED`, `RESTORED`,
`ROLLED_BACK` — und später `RENAMED` (mit dem neuen Titel im Klartext), sobald
es `PATCH /title` gibt; solange das fehlt, wäre der Typ eine Zusage ohne
Deckung.
- **`RESTORED` heißt ausschließlich: ein gelöschtes Dokument ist wieder da** —
der Client hebt seine Sperre auf.
@@ -277,12 +280,16 @@ Diff-Format und Konfliktlogik bleiben identisch.
Snapshots (Myers oder eine einfache LCS-Implementierung).
- **Rebase** (siehe PATCH): Ops einer veralteten Basis gegen die
zwischenzeitlichen verschieben, sofern sie sich nicht überschneiden.
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
(pro Dokument eine Warteliste; Zustellung bei akzeptiertem Update **und**
beim Löschen). Kein zusätzliches Framework nötig. **Das setzt eine
Einzelinstanz voraus** — hinter einem Load Balancer erführe ein Beobachter
auf der zweiten Instanz nichts und liefe in den Timeout. Bewusste Annahme,
für die genannte Last angemessen.
- **Long Polling**: ein In-Process-Notifier (Stempel je Dokument; Zustellung
bei akzeptiertem Update **und** beim Löschen) — die Anfrage **blockiert**
ihren Thread, und der ist ein **virtueller** (`spring.threads.virtual`).
Kein `DeferredResult`, kein zusätzliches Framework: Der Endpunkt behält
damit die synchrone Signatur, die die OpenAPI-Generierung erzeugt, und
API-First bleibt für ihn unangetastet (Begründung: D76-Nachtrag 5).
Geweckt wird **nach dem Commit**, nie davor. **Das setzt eine Einzelinstanz
voraus** — hinter einem Load Balancer erführe ein Beobachter auf der zweiten
Instanz nichts und liefe in den Timeout. Bewusste Annahme, für die genannte
Last angemessen.
- **Serialisierung**: Updates pro Dokument strikt sequenziell
(Locking pro Dokument-UUID), damit Prüfung, Rebase und Anwenden atomar sind.
@@ -402,9 +409,12 @@ verworfen.
„derselbe PATCH zweimal gesendet ändert das Dokument nur einmal",
„falsche Prüfsumme liefert 422", „Feed meldet DELETED und danach RESTORED",
„zu altes since liefert Volltext", „Umbenennen erscheint als RENAMED".
Long Polling braucht dafür **Nebenläufigkeit im Test** (zwei Threads oder
asynchrones MockMvc) und einen klein konfigurierbaren `wait`-Wert — der
heutige synchrone `TestRestTemplate`-Stil allein reicht nicht.
Long Polling braucht dafür **Nebenläufigkeit im Test** und einen klein
konfigurierbaren `wait`-Wert (in den Tests 5 s). Umgesetzt als
Hintergrund-Abruf per `CompletableFuture`; das Szenario misst zusätzlich die
**Dauer** — ohne das bestünde es auch dann, wenn der Wartende gar nicht
geweckt würde, sondern bloß in den Timeout liefe und danach die Änderung
vorfände.
- **Unit-Tests**: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument,
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung, die
Überlappungsregeln für `insert` (untereinander verträglich, mit `delete`
@@ -417,8 +427,8 @@ verworfen.
2. ~~Historie in zwei Ebenen + gezielter Repository-Zugriff~~ — gebaut
3. ~~`PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
Cucumber)~~ — gebaut, `LiveEditingService`
4. `GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)
4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
(Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService`
5. Master-Passwort für `GET /documents` (Spring Security)
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)