diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 4b4cd01..25083e6 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -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 1–3 der Reihenfolge +`docs/live-editing-proposal.md`) ist in Arbeit: Schritte 1–4 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. diff --git a/backend/README.md b/backend/README.md index 1900591..6b72fed 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md index 2c0c53a..fadc465 100644 --- a/backend/docs/live-editing-proposal.md +++ b/backend/docs/live-editing-proposal.md @@ -1,8 +1,9 @@ # Live-Editing über HTTP (Variante „Simpel") -Status: **Konzept entschieden** (D76), **Schritte 1–3 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 1–4 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) diff --git a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt index 3584855..b526f20 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/DocumentsController.kt @@ -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 { + 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> = 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, diff --git a/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt b/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt new file mode 100644 index 0000000..b7de326 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/domain/ChangeFeed.kt @@ -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?, + val content: String?, + val events: List, +) diff --git a/backend/src/main/kotlin/de/werkbaum/persistence/JpaDocumentHistoryRepository.kt b/backend/src/main/kotlin/de/werkbaum/persistence/JpaDocumentHistoryRepository.kt index 03c4117..10f8650 100644 --- a/backend/src/main/kotlin/de/werkbaum/persistence/JpaDocumentHistoryRepository.kt +++ b/backend/src/main/kotlin/de/werkbaum/persistence/JpaDocumentHistoryRepository.kt @@ -28,6 +28,9 @@ class JpaDocumentHistoryRepository( override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId) + override fun findAfterVersion(documentId: UUID, version: Long): List = + jpa.findByDocumentIdAndVersionGreaterThanOrderByIdAsc(documentId, version).map { it.toDomain() } + override fun findMilestones(documentId: UUID): List = jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() } diff --git a/backend/src/main/kotlin/de/werkbaum/persistence/SpringDataRepositories.kt b/backend/src/main/kotlin/de/werkbaum/persistence/SpringDataRepositories.kt index a1000c1..5d53a3c 100644 --- a/backend/src/main/kotlin/de/werkbaum/persistence/SpringDataRepositories.kt +++ b/backend/src/main/kotlin/de/werkbaum/persistence/SpringDataRepositories.kt @@ -24,6 +24,11 @@ interface DocumentHistoryJpaRepository : JpaRepository + fun findByDocumentIdAndVersionGreaterThanOrderByIdAsc( + documentId: UUID, + version: Long, + ): List + @Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId") fun maxVersion(@Param("documentId") documentId: UUID): Long? diff --git a/backend/src/main/kotlin/de/werkbaum/repository/DocumentHistoryRepository.kt b/backend/src/main/kotlin/de/werkbaum/repository/DocumentHistoryRepository.kt index 97fe36b..bc3939d 100644 --- a/backend/src/main/kotlin/de/werkbaum/repository/DocumentHistoryRepository.kt +++ b/backend/src/main/kotlin/de/werkbaum/repository/DocumentHistoryRepository.kt @@ -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 + /** Die nutzersichtbare Historie, älteste zuerst. */ fun findMilestones(documentId: UUID): List diff --git a/backend/src/main/kotlin/de/werkbaum/service/ChangeNotifier.kt b/backend/src/main/kotlin/de/werkbaum/service/ChangeNotifier.kt new file mode 100644 index 0000000..201b285 --- /dev/null +++ b/backend/src/main/kotlin/de/werkbaum/service/ChangeNotifier.kt @@ -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() + + 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 + } +} diff --git a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt index e8006e7..b36bdd4 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/DocumentService.kt @@ -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 = 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. */ diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt index f4cc43d..c400154 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt @@ -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), ) diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt index 58b0cf9..1c810bb 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingService.kt @@ -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)] diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 05a0344..ffc9bad 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -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 diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 88f3cdf..b08ba54 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -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) diff --git a/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt index 9e95eda..f9f7cc2 100644 --- a/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt +++ b/backend/src/test/kotlin/de/werkbaum/bdd/LiveEditingStepDefinitions.kt @@ -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? = null private var lastRequestBody: String? = null + /** Ein Feed-Abruf, der im Hintergrund wartet – für Long Polling. */ + private var pendingFeed: CompletableFuture>? = 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 = @@ -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().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() + 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() + feed.fromVersion shouldBe null + feed.content shouldBe erwartet + } + + @Und("der Feed meldet das Ereignis {string}") + fun `der Feed meldet das Ereignis`(typ: String) { + lastBody().events.map { it.changeType.value } shouldContain typ + } + + @Und("der Feed nennt als Absender {string}") + fun `der Feed nennt als Absender`(name: String) { + lastBody().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 = + 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}") diff --git a/backend/src/test/kotlin/de/werkbaum/service/ChangeNotifierTest.kt b/backend/src/test/kotlin/de/werkbaum/service/ChangeNotifierTest.kt new file mode 100644 index 0000000..a711a8b --- /dev/null +++ b/backend/src/test/kotlin/de/werkbaum/service/ChangeNotifierTest.kt @@ -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 + } +} diff --git a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt index c782e05..f95d91a 100644 --- a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt @@ -38,7 +38,9 @@ class DocumentServiceTest { private val repository = mockk() private val historyRepository = mockk(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(), diff --git a/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt index 143a107..7938a64 100644 --- a/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/service/LiveEditingServiceTest.kt @@ -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() private val history = mockk(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 { + 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, diff --git a/backend/src/test/resources/application.yaml b/backend/src/test/resources/application.yaml index 5077933..acee53e 100644 --- a/backend/src/test/resources/application.yaml +++ b/backend/src/test/resources/application.yaml @@ -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 diff --git a/backend/src/test/resources/features/aenderungsfeed.feature b/backend/src/test/resources/features/aenderungsfeed.feature new file mode 100644 index 0000000..f96a845 --- /dev/null +++ b/backend/src/test/resources/features/aenderungsfeed.feature @@ -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 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 8110e4a..800d4c9 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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`). 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.