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
@@ -18,6 +18,14 @@ spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
threads:
virtual:
# Der Aenderungsfeed blockiert seinen Thread, bis sich etwas tut
# (Long Polling). Auf einem virtuellen Thread kostet das Warten fast
# nichts - ein Plattform-Thread je Beobachter waere ein Megabyte Stack
# auf einem Host, dessen knappe Groesse der Speicher ist (D76).
enabled: true
werkbaum:
live-editing:
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
@@ -25,6 +33,9 @@ werkbaum:
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
# altes "since" dann mit Volltext statt mit einem Diff.
sync-retention: 1h
# Obergrenze fuers Warten am Feed. Gemessen: Apache auf der Zielumgebung
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
max-wait: 25s
server:
port: 8080
+106
View File
@@ -244,6 +244,64 @@ paths:
schema:
$ref: "#/components/schemas/ProblemDetail"
/documents/{documentId}/changes:
parameters:
- name: documentId
in: path
required: true
schema:
type: string
format: uuid
get:
tags: [Documents]
operationId: getDocumentChanges
summary: Aenderungsfeed (Long Polling)
description: >
Liefert alles, was seit `since` geschehen ist. Gibt es nichts, haelt
der Server die Anfrage bis zu `wait` Sekunden offen und antwortet
sofort, sobald eine Aenderung eintrifft; sonst 204.
Der Feed arbeitet auf der **Historie**, nicht am Dokument: Ein
geloeschtes Dokument muss sein DELETED-Ereignis noch zustellen koennen.
404 gibt es deshalb nur bei gaenzlich unbekannter UUID.
Ist `since` bereits verdichtet, kann kein exaktes Diff mehr geliefert
werden - dann enthaelt die Antwort statt `ops` den **Volltext**, und
`fromVersion` fehlt. Der Client ersetzt seinen Stand.
parameters:
- name: since
in: query
required: true
description: Zuletzt gesehene Version; 0 fuer "noch nichts".
schema:
type: integer
format: int64
minimum: 0
- name: wait
in: query
required: false
description: >
Wartezeit in Sekunden. Serverseitig geklemmt - ein Client darf
keine beliebig lange Verbindung binden.
schema:
type: integer
format: int32
minimum: 0
default: 25
responses:
"200":
description: Aenderungen seit `since`
content:
application/json:
schema:
$ref: "#/components/schemas/ChangeFeed"
"204":
description: Nichts Neues innerhalb der Wartezeit
"404":
$ref: "#/components/responses/NotFound"
components:
responses:
NotFound:
@@ -448,6 +506,54 @@ components:
items:
$ref: "#/components/schemas/LineOperation"
ChangeEvent:
description: Was an einer Version geschehen ist und wer sie eingereicht hat.
type: object
required: [version, changeType]
properties:
version:
type: integer
format: int64
changeType:
type: string
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
clientId:
type: string
description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
displayName:
type: string
description: >
Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
Oberflaeche nicht wie ein Nachweis darstellen.
ChangeFeed:
type: object
required: [currentVersion, events]
properties:
fromVersion:
type: integer
format: int64
description: >
Basis des mitgelieferten Diffs. Fehlt, wenn stattdessen `content`
geliefert wird.
currentVersion:
type: integer
format: int64
ops:
type: array
description: Kumuliertes Diff von `fromVersion` bis `currentVersion`.
items:
$ref: "#/components/schemas/LineOperation"
content:
type: string
description: >
Volltext statt Diff - wenn `since` bereits verdichtet ist oder der
Client noch gar nichts hat (`since=0`).
events:
type: array
items:
$ref: "#/components/schemas/ChangeEvent"
ProblemDetail:
type: object
description: Fehlerformat nach RFC 9457 (Problem Details)