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
+33 -2
View File
@@ -133,6 +133,37 @@ Notation nicht (D14).
Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID,
**außerhalb** der Transaktion — innen gäbe der Proxy sie vor dem Commit frei).
## Live-Editing: der Änderungsfeed
`GET /api/v1/documents/{uuid}/changes?since={version}&wait={sekunden}` liefert
alles, was seit `since` geschehen ist. Gibt es nichts, hält der Server die
Anfrage offen und antwortet **sofort**, sobald eine Änderung eintrifft; sonst
**204**, und der Client fragt erneut. Kosten im Leerlauf: eine offene Anfrage
je Beobachter, rund 2,4 Requests pro Minute.
- **Der Feed arbeitet auf der Historie, nicht am Dokument.** Ein gelöschtes
Dokument muss sein `DELETED` noch zustellen können — **404** gibt es deshalb
nur bei gänzlich unbekannter UUID.
- **Ist `since` verdichtet** (oder `0`, also Erstkontakt), kommt statt `ops`
der **Volltext**; `fromVersion` fehlt dann. Ein Roundtrip und ein
Sonderzustand weniger als ein eigener Fehlerpfad — und über hunderte
Versionen hinweg wäre der Cursor ohnehin nicht zu retten.
- **Ereignisse:** `CREATED`, `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`,
je mit `clientId` und `displayName` des Absenders. (`RENAMED` kommt mit
`PATCH /title`; solange es das nicht gibt, wäre der Typ eine Zusage ohne
Deckung.)
- **`Cache-Control: no-store`** ist Pflicht: Ein Proxy dürfte sonst eine 204
zwischenspeichern, und der Feed stünde still.
- `wait` wird serverseitig geklemmt (`werkbaum.live-editing.max-wait`, 25 s).
Umgesetzt **blockierend auf virtuellen Threads** (`spring.threads.virtual`),
nicht mit `DeferredResult`: So behält der Endpunkt die aus der Spezifikation
generierte Signatur, und ein Wartender kostet trotzdem fast nichts.
Geweckt wird **nach dem Commit** — davor läse ein Beobachter einen Stand, der
noch nicht steht. Voraussetzung ist eine **Einzelinstanz**; hinter einem Load
Balancer erführe ein Beobachter auf der zweiten Instanz nichts. Begründung:
D76-Nachtrag 5.
## Vorbereitete Erweiterungen
**Autorisierung**
@@ -143,8 +174,8 @@ Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID,
(„Angenommen ich bin als … angemeldet").
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
- **Offen:** der Änderungsfeed per Long Polling (`GET /changes`),
Master-Passwort für `GET /documents`, Client-Anpassung.
- **Offen:** Umbenennen per `PATCH /title` (und damit das Ereignis
`RENAMED`), Master-Passwort für `GET /documents`, Client-Anpassung.
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
noch nicht ausgewertet.