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
+46
View File
@@ -6114,3 +6114,49 @@ Zahlen: 104 Tests. Gegenproben je Regel — Prüfsumme nicht geprüft, Idempoten
entfernt, veraltete Basis abgelehnt statt verschoben, Schreibpause ignoriert,
Rückfall wieder als `RESTORED`, jüngster Stand aus der Historie genommen: Es
fallen jeweils genau die danach benannten Zusicherungen.
**Nachtrag 5 — Long Polling blockiert, aber auf einem virtuellen Thread
(2026-08-26).** Der Haupttext sah `DeferredResult` vor, damit ein Wartender
keinen Server-Thread bindet. Beim Bauen stellte sich das als teurer heraus,
als es klingt: Der Endpunkt steht in der OpenAPI-Spezifikation, und der
Generator erzeugt daraus eine **synchrone** Signatur
(`ResponseEntity<ChangeFeed>`). Ein `DeferredResult` verlangt eine andere —
also entweder die Operation aus der Generierung herausnehmen (dann prüft
niemand mehr, ob Vertrag und Code zusammenpassen; genau die Zusage, für die
API-First in diesem Projekt gebaut ist) oder den Generator umstellen (WebFlux
für alles).
**Gewählt: blockieren, und `spring.threads.virtual.enabled=true`.** Auf JDK 21
kostet ein wartender virtueller Thread praktisch nichts — kein Stack von einem
Megabyte, keine Poolgrenze. Das Argument gegen das Blockieren war der
Speicher, und der ist auf der Zielumgebung tatsächlich die knappe Größe
(D76-Nachtrag 3); genau dort löst der virtuelle Thread es auf, statt es zu
verschieben. Der Endpunkt behält die generierte Signatur, und für ihn gilt
dieselbe Regel wie für alle anderen: Weicht die Implementierung vom Vertrag
ab, bricht der Compile.
**Zwei Dinge, die daran hängen und leicht zu übersehen sind.** Erstens darf
das Warten nicht mit `synchronized`/`wait()` gebaut sein — ein Monitor nagelt
den virtuellen Thread an seinen Träger (JDK 21). Der `ChangeNotifier` benutzt
deshalb `ReentrantLock`/`Condition`. Zweitens darf **während** des Wartens
keine Transaktion und keine Datenbankverbindung offen sein; der
`LiveEditingService` ist ohnehin nicht transaktional und liest über je eigene
Aufrufe.
**Geweckt wird nach dem Commit, nicht davor** (`afterCommit` der
Transaktions-Synchronisation). Davor geweckt läse ein Beobachter einen Stand,
der noch nicht steht — und bekäme das Ereignis nie wieder, denn er zieht
danach mit der neuen Version weiter.
**Der Stempel statt der Versionsnummer.** Der Aufrufer liest den Stempel des
Dokuments, **bevor** er in der Datenbank nachsieht. Ändert sich etwas in der
Lücke dazwischen, kehrt das Warten sofort zurück. Ohne diesen Griff ginge das
Signal verloren und der Client bekäme seine Änderung erst nach Ablauf der
vollen Wartezeit — ein Fehler, der im Test nur auffällt, wenn man die **Dauer**
misst. Das Cucumber-Szenario tut das (< 4 s bei 5 s Wartezeit); gegengeprüft
durch Entfernen der Benachrichtigung, dann fällt genau dieses Szenario.
**`RENAMED` ist noch nicht vergeben.** Der Feed kennt `CREATED`, `UPDATED`,
`DELETED`, `RESTORED` und `ROLLED_BACK`. Das Umbenennen bekommt seinen eigenen
Weg (`PATCH /title` mit `expectedVersion`) und erst damit den Typ — ihn vorher
zu deklarieren wäre eine Zusage ohne Deckung.