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
+3 -3
View File
@@ -7,10 +7,10 @@ Status-Sync), später Tenzu-Adapter.
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
Kommandos in README.md hier. Live-Editing (D76,
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 13 der Reihenfolge
`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 14 der Reihenfolge
dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen,
`PATCH /content` im `LiveEditingService`), ab Schritt 4 (`GET /changes` per
Long Polling) steht es aus.
`PATCH /content` und der Änderungsfeed im `LiveEditingService`); offen sind
`PATCH /title`, das Master-Passwort und der Client.
## Konventionen
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
+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.
+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)
@@ -2,6 +2,8 @@ package de.werkbaum.api
import de.werkbaum.generated.api.DocumentsApi
import de.werkbaum.generated.model.Document as ApiDocument
import de.werkbaum.generated.model.ChangeEvent as ApiChangeEvent
import de.werkbaum.generated.model.ChangeFeed as ApiChangeFeed
import de.werkbaum.generated.model.ContentPatchRequest
import de.werkbaum.generated.model.ContentPatchResult
import de.werkbaum.generated.model.DocumentCreateRequest
@@ -9,15 +11,19 @@ import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
import de.werkbaum.generated.model.DocumentUpdateRequest
import de.werkbaum.generated.model.RestoreRequest
import de.werkbaum.domain.ChangeAuthor
import de.werkbaum.domain.ChangeEvent
import de.werkbaum.domain.ChangeFeed
import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.Document
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.service.DocumentService
import de.werkbaum.service.LiveEditingService
import org.springframework.http.CacheControl
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Duration
import java.util.UUID
/**
@@ -89,6 +95,24 @@ class DocumentsController(
)
}
/**
* Long Polling: Der Server haelt die Anfrage offen, bis sich etwas tut.
*
* `no-store` ist Pflicht ein Proxy duerfte sonst eine 204
* zwischenspeichern, und der Feed stuende still.
*/
override fun getDocumentChanges(
documentId: UUID,
since: Long,
wait: Int,
): ResponseEntity<ApiChangeFeed> {
val feed = liveEditing.changesSince(documentId, since, Duration.ofSeconds(wait.toLong()))
return ResponseEntity
.status(if (feed == null) HttpStatus.NO_CONTENT else HttpStatus.OK)
.cacheControl(CacheControl.noStore())
.body(feed?.toApi())
}
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
@@ -109,6 +133,21 @@ class DocumentsController(
updatedAt = updatedAt,
)
private fun ChangeFeed.toApi(): ApiChangeFeed = ApiChangeFeed(
currentVersion = currentVersion,
events = events.map { it.toApi() },
fromVersion = fromVersion,
ops = ops?.toApi(),
content = content,
)
private fun ChangeEvent.toApi(): ApiChangeEvent = ApiChangeEvent(
version = version,
changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name),
clientId = author?.clientId,
displayName = author?.displayName,
)
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
documentId = documentId,
version = version,
@@ -0,0 +1,28 @@
package de.werkbaum.domain
import de.werkbaum.diff.LineOp
/** Was an einer Version geschehen ist und wer sie eingereicht hat. */
data class ChangeEvent(
val version: Long,
val changeType: ChangeType,
val author: ChangeAuthor? = null,
)
/**
* Alles, was seit einer bekannten Version geschehen ist.
*
* Entweder [ops] (der Normalfall der Client wendet sie an und behält Cursor
* und Scrollposition) **oder** [content] als Volltext. Letzteres, wenn die
* Basis bereits verdichtet ist oder der Client noch gar nichts hat: Dann kann
* kein exaktes Diff mehr entstehen, und über hunderte Versionen hinweg wäre
* der Cursor ohnehin nicht zu retten. Ein Roundtrip und ein Sonderzustand
* weniger als ein eigener Fehlerpfad (D76).
*/
data class ChangeFeed(
val fromVersion: Long?,
val currentVersion: Long,
val ops: List<LineOp>?,
val content: String?,
val events: List<ChangeEvent>,
)
@@ -28,6 +28,9 @@ class JpaDocumentHistoryRepository(
override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId)
override fun findAfterVersion(documentId: UUID, version: Long): List<DocumentHistoryEntry> =
jpa.findByDocumentIdAndVersionGreaterThanOrderByIdAsc(documentId, version).map { it.toDomain() }
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
@@ -24,6 +24,11 @@ interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Lo
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
fun findByDocumentIdAndVersionGreaterThanOrderByIdAsc(
documentId: UUID,
version: Long,
): List<DocumentHistoryEntity>
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
fun maxVersion(@Param("documentId") documentId: UUID): Long?
@@ -29,6 +29,9 @@ interface DocumentHistoryRepository {
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
fun maxVersion(documentId: UUID): Long?
/** Alle Einträge mit einer Version größer [version], älteste zuerst. */
fun findAfterVersion(documentId: UUID, version: Long): List<DocumentHistoryEntry>
/** Die nutzersichtbare Historie, älteste zuerst. */
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
@@ -0,0 +1,55 @@
package de.werkbaum.service
import org.springframework.stereotype.Component
import java.time.Duration
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* Weckt wartende Beobachter, wenn sich an einem Dokument etwas getan hat —
* der Kern der „Echtzeit ohne WebSocket"-Lösung (D76).
*
* **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 zehn Beobachter angemessen.
*
* Gezählt wird je Dokument ein **Stempel**, nicht die Versionsnummer: Der
* Aufrufer liest ihn, **bevor** er in der Datenbank nachsieht. Ändert sich in
* der Lücke dazwischen etwas, kehrt das Warten sofort zurück, statt das Signal
* zu verpassen und die volle Wartezeit abzusitzen.
*
* Gewartet wird mit `ReentrantLock`/`Condition`, nicht mit `synchronized`
* plus `wait()`: Der Feed blockiert seinen Thread, und das soll ein
* **virtueller** Thread sein dürfen (siehe `spring.threads.virtual.enabled`) —
* ein Monitor würde dessen Träger festnageln.
*/
@Component
class ChangeNotifier {
private val lock = ReentrantLock()
private val changed = lock.newCondition()
private val stamps = ConcurrentHashMap<UUID, Long>()
fun stampOf(documentId: UUID): Long = stamps[documentId] ?: 0L
/** Meldet eine Änderung aufzurufen **nach** dem Commit, nie davor. */
fun published(documentId: UUID) = lock.withLock {
stamps.merge(documentId, 1L) { old, one -> old + one }
changed.signalAll()
}
/**
* Wartet, bis sich der Stempel des Dokuments von [since] unterscheidet.
* `true` heißt: es hat sich etwas getan. `false` heißt: Zeit abgelaufen.
*/
fun awaitChange(documentId: UUID, since: Long, timeout: Duration): Boolean = lock.withLock {
var remaining = timeout.toNanos()
while (stampOf(documentId) == since) {
if (remaining <= 0) return false
remaining = changed.awaitNanos(remaining)
}
true
}
}
@@ -8,6 +8,8 @@ import de.werkbaum.repository.DocumentHistoryRepository
import de.werkbaum.repository.DocumentRepository
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import java.time.Clock
import java.time.Duration
import java.time.OffsetDateTime
@@ -20,6 +22,7 @@ class DocumentService(
private val historyRepository: DocumentHistoryRepository,
private val clock: Clock,
private val properties: LiveEditingProperties,
private val notifier: ChangeNotifier,
) {
fun findAll(): List<Document> = repository.findAll()
@@ -203,6 +206,26 @@ class DocumentService(
document.id,
document.updatedAt.minus(properties.syncRetention),
)
publishAfterCommit(document.id)
}
/**
* Weckt die Beobachter am Änderungsfeed **nach** dem Commit. Vorher
* geweckt läse ein Beobachter einen Stand, der noch nicht steht, und
* bekäme das Ereignis nie wieder. Ohne laufende Transaktion (Tests) wird
* sofort gemeldet.
*/
private fun publishAfterCommit(documentId: UUID) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
notifier.published(documentId)
return
}
TransactionSynchronizationManager.registerSynchronization(
object : TransactionSynchronization {
override fun afterCommit() = notifier.published(documentId)
}
)
}
/** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
@@ -31,4 +31,12 @@ data class LiveEditingProperties(
/** Höchstlänge des Dokuments in Zeichen; der mitgelieferte Plan hat ~40 000. */
val maxContentLength: Int = 2_000_000,
/**
* Obergrenze für das Warten am Änderungsfeed. Ein Client darf keine
* beliebig lange Verbindung binden. 25 s sind gemessen: Apache auf der
* Zielumgebung hält einen Long-Poll nachweislich 30 s durch, seine
* Zeitgrenzen liegen bei 300 s (D76-Nachtrag 1/2).
*/
val maxWait: Duration = Duration.ofSeconds(25),
)
@@ -2,10 +2,14 @@ package de.werkbaum.service
import de.werkbaum.diff.DiffNotApplicableException
import de.werkbaum.diff.LineDiff
import de.werkbaum.domain.ChangeEvent
import de.werkbaum.domain.ChangeFeed
import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.ContentPatchOutcome
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import org.springframework.stereotype.Service
import java.time.Duration
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -32,6 +36,7 @@ class LiveEditingService(
private val documents: DocumentService,
private val history: DocumentHistoryRepository,
private val properties: LiveEditingProperties,
private val notifier: ChangeNotifier,
) {
/**
@@ -122,6 +127,66 @@ class LiveEditingService(
)
}
// -----------------------------------------------------------------------
// Änderungsfeed
// -----------------------------------------------------------------------
/**
* Alles seit [since] oder `null`, wenn innerhalb von [wait] nichts
* passiert (im Protokoll: 204).
*
* Der Feed arbeitet auf der **Historie**, nicht am Dokument: `delete()`
* entfernt das Dokument und lässt nur den Tombstone stehen ein Feed am
* Dokument müsste danach 404 liefern, ausgerechnet für das
* DELETED-Ereignis, das er zustellen soll. 404 gibt es deshalb nur bei
* gänzlich unbekannter UUID.
*/
fun changesSince(documentId: UUID, since: Long, wait: Duration): ChangeFeed? {
if (!history.exists(documentId)) throw DocumentNotFoundException(documentId)
// Den Stempel VOR dem Nachsehen lesen: Ändert sich in der Lücke
// dazwischen etwas, kehrt das Warten unten sofort zurück.
val stamp = notifier.stampOf(documentId)
feedSince(documentId, since)?.let { return it }
val timeout = minOf(wait, properties.maxWait)
if (timeout.isNegative || timeout.isZero) return null
if (!notifier.awaitChange(documentId, stamp, timeout)) return null
return feedSince(documentId, since)
}
private fun feedSince(documentId: UUID, since: Long): ChangeFeed? {
val latest = history.findLatest(documentId) ?: return null
if (latest.version <= since) return null
val events = history.findAfterVersion(documentId, since).map { it.toEvent() }
val base = history.findVersion(documentId, since)?.content
// Ohne Basis kein exaktes Diff - dann der Volltext. Das deckt den
// Nachzügler nach dem Verdichten ebenso ab wie den Erstkontakt
// (since = 0) und die PWA nach langer Offline-Zeit.
return if (base == null) {
ChangeFeed(
fromVersion = null,
currentVersion = latest.version,
ops = null,
content = latest.content,
events = events,
)
} else {
ChangeFeed(
fromVersion = since,
currentVersion = latest.version,
ops = LineDiff.compute(LineDiff.lines(base), LineDiff.lines(latest.content)),
content = null,
events = events,
)
}
}
private fun DocumentHistoryEntry.toEvent() = ChangeEvent(version, changeType, author)
private fun lockFor(documentId: UUID): ReentrantLock =
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
@@ -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)
@@ -1,6 +1,7 @@
package de.werkbaum.bdd
import de.werkbaum.diff.LineDiff
import de.werkbaum.generated.model.ChangeFeed
import de.werkbaum.generated.model.ContentConflict
import de.werkbaum.generated.model.ContentPatchResult
import de.werkbaum.generated.model.Document as ApiDocument
@@ -12,11 +13,14 @@ import io.cucumber.java.de.Und
import io.cucumber.java.de.Wenn
import io.kotest.assertions.withClue
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.collections.shouldContain
import io.kotest.matchers.shouldBe
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.client.EntityExchangeResult
import org.springframework.test.web.servlet.client.RestTestClient
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
/**
* Behavior-Tests des Live-Editings (D76) gegen die laufende Anwendung.
@@ -38,6 +42,10 @@ class LiveEditingStepDefinitions {
private var lastResponse: EntityExchangeResult<String>? = null
private var lastRequestBody: String? = null
/** Ein Feed-Abruf, der im Hintergrund wartet für Long Polling. */
private var pendingFeed: CompletableFuture<EntityExchangeResult<String>>? = null
private var feedStartedAt: Long = 0
private fun status(): Int? = lastResponse?.status?.value()
private fun currentDocument(): ApiDocument =
@@ -79,7 +87,8 @@ class LiveEditingStepDefinitions {
seq: Long = 1,
) = """
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
"clientId":${json(clientId)},"seq":$seq,"ops":$ops}
"clientId":${json(clientId)},"displayName":${json(clientId)},
"seq":$seq,"ops":$ops}
""".trimIndent()
private fun baseOf(clientId: String): Pair<Long, String> =
@@ -115,6 +124,17 @@ class LiveEditingStepDefinitions {
.status.value() shouldBe 204
}
@Angenommen("dieses Dokument wiederhergestellt wird")
fun `dieses Dokument wird wiederhergestellt`() {
client.post()
.uri("/api/v1/documents/$documentId/restore")
.contentType(MediaType.APPLICATION_JSON)
.body("{}")
.exchange()
.returnResult(String::class.java)
.status.value() shouldBe 200
}
// ---------------- Wenn ----------------
@Wenn("Client {string} folgendes Diff einreicht:")
@@ -134,6 +154,86 @@ class LiveEditingStepDefinitions {
sendPatch(lastRequestBody.shouldNotBeNull())
}
// ---------------- Änderungsfeed ----------------
@Wenn("ich die Änderungen seit Version {long} abrufe")
fun `ich rufe die Aenderungen ab`(since: Long) {
lastResponse = feedRequest(since, wait = 0)
}
@Wenn("ich höchstens {int} Sekunden auf Änderungen seit Version {long} warte")
fun `ich warte auf Aenderungen`(sekunden: Int, since: Long) {
lastResponse = feedRequest(since, wait = sekunden)
}
@Wenn("im Hintergrund auf Änderungen seit Version {long} gewartet wird")
fun `im Hintergrund wird gewartet`(since: Long) {
feedStartedAt = System.nanoTime()
pendingFeed = CompletableFuture.supplyAsync { feedRequest(since, wait = 5) }
// Dem Abruf einen Moment geben, damit er wirklich wartet, statt die
// Aenderung schon vorzufinden - sonst prueft das Szenario nichts.
Thread.sleep(300)
}
@Dann("hat der wartende Abruf die Änderung erhalten")
fun `der wartende Abruf hat die Aenderung erhalten`() {
val response = pendingFeed.shouldNotBeNull().get(10, TimeUnit.SECONDS)
val dauer = (System.nanoTime() - feedStartedAt) / 1_000_000
withClue("Antwort: ${response.responseBody}") { response.status.value() shouldBe 200 }
withClue("Der Abruf hat $dauer ms gebraucht - er wurde nicht geweckt, sondern lief ab") {
(dauer < 4_000) shouldBe true
}
lastResponse = response
}
@Und("der Feed meldet die Version {long}")
fun `der Feed meldet die Version`(erwartet: Long) {
lastBody<ChangeFeed>().currentVersion shouldBe erwartet
}
@Und("der Feed liefert {int} Operationen ab Version {long}")
fun `der Feed liefert n Operationen`(anzahl: Int, from: Long) {
val feed = lastBody<ChangeFeed>()
feed.fromVersion shouldBe from
feed.ops.shouldNotBeNull().size shouldBe anzahl
}
@Und("der Feed liefert den Volltext:")
fun `der Feed liefert den Volltext`(erwartet: String) {
val feed = lastBody<ChangeFeed>()
feed.fromVersion shouldBe null
feed.content shouldBe erwartet
}
@Und("der Feed meldet das Ereignis {string}")
fun `der Feed meldet das Ereignis`(typ: String) {
lastBody<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
}
@Und("der Feed nennt als Absender {string}")
fun `der Feed nennt als Absender`(name: String) {
lastBody<ChangeFeed>().events.mapNotNull { it.displayName } shouldContain name
}
@Und("die Antwort verbietet das Zwischenspeichern")
fun `die Antwort verbietet das Zwischenspeichern`() {
lastResponse?.responseHeaders?.cacheControl shouldBe "no-store"
}
@Wenn("ich die Änderungen eines unbekannten Dokuments abrufe")
fun `ich rufe die Aenderungen eines unbekannten Dokuments ab`() {
lastResponse = client.get()
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000000/changes?since=0&wait=0")
.exchange()
.returnResult(String::class.java)
}
private fun feedRequest(since: Long, wait: Int): EntityExchangeResult<String> =
client.get()
.uri("/api/v1/documents/$documentId/changes?since=$since&wait=$wait")
.exchange()
.returnResult(String::class.java)
// ---------------- Dann / Und ----------------
@Dann("erhalte ich für das Diff den Status {int}")
@@ -0,0 +1,50 @@
package de.werkbaum.service
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import java.time.Duration
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.TimeUnit
class ChangeNotifierTest {
private val notifier = ChangeNotifier()
private val id = UUID.randomUUID()
@Test
fun `ohne Aenderung laeuft die Wartezeit ab`() {
notifier.awaitChange(id, notifier.stampOf(id), Duration.ofMillis(50)) shouldBe false
}
@Test
fun `eine Aenderung weckt den Wartenden`() {
val stamp = notifier.stampOf(id)
val wartend = CompletableFuture.supplyAsync {
notifier.awaitChange(id, stamp, Duration.ofSeconds(5))
}
Thread.sleep(100)
notifier.published(id)
wartend.get(5, TimeUnit.SECONDS) shouldBe true
}
@Test
fun `eine Aenderung in der Luecke geht nicht verloren`() {
// Genau dafuer ist der Stempel da: Der Aufrufer liest ihn, bevor er in
// der Datenbank nachsieht. Passiert dazwischen etwas, kehrt das Warten
// sofort zurueck, statt die volle Zeit abzusitzen.
val stamp = notifier.stampOf(id)
notifier.published(id)
notifier.awaitChange(id, stamp, Duration.ofMillis(50)) shouldBe true
}
@Test
fun `ein anderes Dokument weckt nicht`() {
val stamp = notifier.stampOf(id)
notifier.published(UUID.randomUUID())
notifier.awaitChange(id, stamp, Duration.ofMillis(50)) shouldBe false
}
}
@@ -38,7 +38,9 @@ class DocumentServiceTest {
private val repository = mockk<DocumentRepository>()
private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
private val service = DocumentService(repository, historyRepository, clock, properties)
private val notifier = ChangeNotifier()
private val service =
DocumentService(repository, historyRepository, clock, properties, notifier)
private fun sampleDocument(
id: UUID = UUID.randomUUID(),
@@ -8,6 +8,7 @@ import de.werkbaum.domain.ChangeType
import de.werkbaum.domain.ContentPatch
import de.werkbaum.domain.ContentPatchOutcome
import de.werkbaum.domain.Document
import de.werkbaum.domain.ChangeEvent
import de.werkbaum.domain.DocumentHistoryEntry
import de.werkbaum.repository.DocumentHistoryRepository
import io.kotest.assertions.throwables.shouldThrow
@@ -17,6 +18,7 @@ import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import org.junit.jupiter.api.Test
import java.time.Duration
import java.time.OffsetDateTime
import java.util.UUID
@@ -25,8 +27,13 @@ class LiveEditingServiceTest {
private val id = UUID.randomUUID()
private val documents = mockk<DocumentService>()
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
private val properties = LiveEditingProperties(maxOps = 3, maxContentLength = 40)
private val service = LiveEditingService(documents, history, properties)
private val properties = LiveEditingProperties(
maxOps = 3,
maxContentLength = 40,
maxWait = Duration.ofMillis(200),
)
private val notifier = ChangeNotifier()
private val service = LiveEditingService(documents, history, properties, notifier)
private val basis = "eins\nzwei\ndrei"
@@ -242,6 +249,104 @@ class LiveEditingServiceTest {
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
}
// -----------------------------------------------------------------------
// Änderungsfeed
// -----------------------------------------------------------------------
@Test
fun `ohne Aenderung liefert der Feed nichts`() {
every { history.exists(id) } returns true
every { history.findLatest(id) } returns historyEntry(basis, 7)
service.changesSince(id, since = 7, wait = Duration.ZERO) shouldBe null
}
@Test
fun `der Feed liefert das kumulierte Diff seit der bekannten Version`() {
every { history.exists(id) } returns true
every { history.findLatest(id) } returns historyEntry("eins\nZWEI\ndrei", 9)
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
every { history.findAfterVersion(id, 7) } returns listOf(
historyEntry("eins\nzwischendrin\ndrei", 8),
historyEntry("eins\nZWEI\ndrei", 9),
)
val feed = service.changesSince(id, since = 7, wait = Duration.ZERO)!!
feed.fromVersion shouldBe 7
feed.currentVersion shouldBe 9
feed.ops shouldBe listOf(LineOp.Replace(1, 1, listOf("ZWEI")))
feed.content shouldBe null
feed.events.map { it.version } shouldBe listOf(8L, 9L)
}
@Test
fun `eine verdichtete Basis liefert den Volltext statt eines Diffs`() {
every { history.exists(id) } returns true
every { history.findLatest(id) } returns historyEntry("neu", 9)
every { history.findVersion(id, 2) } returns null
every { history.findAfterVersion(id, 2) } returns emptyList()
val feed = service.changesSince(id, since = 2, wait = Duration.ZERO)!!
feed.fromVersion shouldBe null
feed.ops shouldBe null
feed.content shouldBe "neu"
}
@Test
fun `der Feed nennt den Absender jeder Aenderung`() {
every { history.exists(id) } returns true
every { history.findLatest(id) } returns historyEntry("neu", 8)
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
every { history.findAfterVersion(id, 7) } returns listOf(
historyEntry("neu", 8).copy(author = ChangeAuthor("c-1", "Anna")),
)
service.changesSince(id, since = 7, wait = Duration.ZERO)!!.events shouldBe listOf(
ChangeEvent(8, ChangeType.UPDATED, ChangeAuthor("c-1", "Anna")),
)
}
@Test
fun `ein geloeschtes Dokument hat weiterhin einen Feed`() {
// Sonst käme ausgerechnet das DELETED-Ereignis nie an.
every { history.exists(id) } returns true
every { history.findLatest(id) } returns
historyEntry(basis, 8).copy(changeType = ChangeType.DELETED)
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
every { history.findAfterVersion(id, 7) } returns listOf(
historyEntry(basis, 8).copy(changeType = ChangeType.DELETED),
)
val feed = service.changesSince(id, since = 7, wait = Duration.ZERO)!!
feed.events.single().changeType shouldBe ChangeType.DELETED
}
@Test
fun `eine gaenzlich unbekannte UUID hat keinen Feed`() {
every { history.exists(id) } returns false
shouldThrow<DocumentNotFoundException> {
service.changesSince(id, since = 0, wait = Duration.ZERO)
}
}
@Test
fun `die Wartezeit wird serverseitig geklemmt`() {
// maxWait steht in diesem Test auf 200 ms; ein Client darf keine
// beliebig lange Verbindung binden.
every { history.exists(id) } returns true
every { history.findLatest(id) } returns historyEntry(basis, 7)
val start = System.nanoTime()
service.changesSince(id, since = 7, wait = Duration.ofSeconds(30)) shouldBe null
val dauer = Duration.ofNanos(System.nanoTime() - start)
(dauer < Duration.ofSeconds(5)) shouldBe true
}
private fun historyEntry(content: String, version: Long) = DocumentHistoryEntry(
documentId = id,
version = version,
@@ -10,3 +10,12 @@ spring:
open-in-view: false
liquibase:
change-log: classpath:db/changelog/db.changelog-master.sql
threads:
virtual:
enabled: true
werkbaum:
live-editing:
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
max-wait: 5s
@@ -0,0 +1,94 @@
# language: de
Funktionalität: Änderungen mitbekommen, ohne zu pollen
Als Betrachter eines geteilten Plans
möchte ich Änderungen sofort sehen,
ohne dass mein Browser dauernd nachfragt.
Der Server hält die Anfrage offen und antwortet, sobald sich etwas tut
(Long Polling). Kommt in der Wartezeit nichts, antwortet er mit 204 und der
Client fragt erneut.
Szenario: Wer zurückliegt, bekommt sofort das Diff
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
"""
Und Client "anna" kennt den aktuellen Stand
Und Client "ben" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
"""
Wenn ich die Änderungen seit Version 1 abrufe
Dann erhalte ich für das Diff den Status 200
Und der Feed meldet die Version 2
Und der Feed liefert 1 Operationen ab Version 1
Und der Feed meldet das Ereignis "UPDATED"
Und die Antwort verbietet das Zwischenspeichern
Szenario: Wer auf dem neuesten Stand ist, bekommt nichts
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Wenn ich höchstens 1 Sekunden auf Änderungen seit Version 1 warte
Dann erhalte ich für das Diff den Status 204
Szenario: Ein Wartender wird geweckt, sobald eine Änderung eintrifft
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Und im Hintergrund auf Änderungen seit Version 1 gewartet wird
Wenn Client "ben" folgendes Diff einreicht:
"""
[{"op":"insert","index":1,"lines":["- [ ] Zwei"]}]
"""
Dann hat der wartende Abruf die Änderung erhalten
Und der Feed meldet die Version 2
Und der Feed liefert 1 Operationen ab Version 1
Szenario: Wer noch gar nichts hat, bekommt den Volltext
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
- [ ] Zwei
"""
Wenn ich die Änderungen seit Version 0 abrufe
Dann erhalte ich für das Diff den Status 200
Und der Feed liefert den Volltext:
"""
- [ ] Eins
- [ ] Zwei
"""
Szenario: Der Feed nennt den Absender einer Änderung
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Und Client "anna" folgendes Diff einreicht:
"""
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
"""
Wenn ich die Änderungen seit Version 1 abrufe
Dann erhalte ich für das Diff den Status 200
Und der Feed nennt als Absender "anna"
Szenario: Der Feed meldet das Löschen und die Wiederherstellung
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
"""
- [ ] Eins
"""
Und dieses Dokument gelöscht wird
Wenn ich die Änderungen seit Version 1 abrufe
Dann erhalte ich für das Diff den Status 200
Und der Feed meldet das Ereignis "DELETED"
Wenn dieses Dokument wiederhergestellt wird
Und ich die Änderungen seit Version 2 abrufe
Dann erhalte ich für das Diff den Status 200
Und der Feed meldet das Ereignis "RESTORED"
Szenario: Eine gänzlich unbekannte UUID hat keinen Feed
Wenn ich die Änderungen eines unbekannten Dokuments abrufe
Dann erhalte ich für das Diff den Status 404
+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.