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:
co-authored by
Claude Opus 5
parent
ae85503b79
commit
741b41ef11
+3
-3
@@ -7,10 +7,10 @@ Status-Sync), später Tenzu-Adapter.
|
|||||||
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
|
**Stand:** Gerüst steht — Dokumenten-CRUD mit Historie und Wiederherstellung,
|
||||||
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
|
API-First aus `src/main/resources/openapi/api.yaml`, H2 mit Liquibase.
|
||||||
Kommandos in README.md hier. Live-Editing (D76,
|
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,
|
dort sind gebaut (Zeilen-Diff in `de.werkbaum.diff`, Historie in zwei Ebenen,
|
||||||
`PATCH /content` im `LiveEditingService`), ab Schritt 4 (`GET /changes` per
|
`PATCH /content` und der Änderungsfeed im `LiveEditingService`); offen sind
|
||||||
Long Polling) steht es aus.
|
`PATCH /title`, das Master-Passwort und der Client.
|
||||||
|
|
||||||
## Konventionen
|
## Konventionen
|
||||||
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
- Kotlin, **Spring Boot 4**, Gradle (Kotlin DSL), JDK 21.
|
||||||
|
|||||||
+33
-2
@@ -133,6 +133,37 @@ Notation nicht (D14).
|
|||||||
Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID,
|
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).
|
**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
|
## Vorbereitete Erweiterungen
|
||||||
|
|
||||||
**Autorisierung**
|
**Autorisierung**
|
||||||
@@ -143,8 +174,8 @@ Die Änderung eines Dokuments läuft strikt sequenziell (Sperre je UUID,
|
|||||||
(„Angenommen ich bin als … angemeldet").
|
(„Angenommen ich bin als … angemeldet").
|
||||||
|
|
||||||
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
|
**Live-Editing** (Konzept: `docs/live-editing-proposal.md`, Entscheidung: D76)
|
||||||
- **Offen:** der Änderungsfeed per Long Polling (`GET /changes`),
|
- **Offen:** Umbenennen per `PATCH /title` (und damit das Ereignis
|
||||||
Master-Passwort für `GET /documents`, Client-Anpassung.
|
`RENAMED`), Master-Passwort für `GET /documents`, Client-Anpassung.
|
||||||
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
|
- `DocumentUpdateRequest.expectedVersion` ist im Vertrag vorgesehen, wird aber
|
||||||
noch nicht ausgewertet.
|
noch nicht ausgewertet.
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Live-Editing über HTTP (Variante „Simpel")
|
# Live-Editing über HTTP (Variante „Simpel")
|
||||||
|
|
||||||
Status: **Konzept entschieden** (D76), **Schritte 1–3 der Umsetzungsreihenfolge
|
Status: **Konzept entschieden** (D76), **Schritte 1–4 der Umsetzungsreihenfolge
|
||||||
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`); der
|
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed);
|
||||||
Änderungsfeed, das Master-Passwort und der Client stehen aus. Die offenen
|
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
|
Punkte des ersten Entwurfs sind beantwortet; die Begründungen stehen in
|
||||||
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
|
`docs/DECISIONS.md` unter D76 und werden hier nicht wiederholt, sondern nur
|
||||||
verwiesen. Was beim Bauen zusätzlich zu entscheiden war, steht dort in
|
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,
|
~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich,
|
||||||
kein WebSocket nötig.
|
kein WebSocket nötig.
|
||||||
|
|
||||||
**Änderungstypen im Feed:** `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`
|
**Änderungstypen im Feed:** `CREATED`, `UPDATED`, `DELETED`, `RESTORED`,
|
||||||
und `RENAMED` (dieses mit dem neuen Titel im Klartext).
|
`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** —
|
- **`RESTORED` heißt ausschließlich: ein gelöschtes Dokument ist wieder da** —
|
||||||
der Client hebt seine Sperre auf.
|
der Client hebt seine Sperre auf.
|
||||||
@@ -277,12 +280,16 @@ Diff-Format und Konfliktlogik bleiben identisch.
|
|||||||
Snapshots (Myers oder eine einfache LCS-Implementierung).
|
Snapshots (Myers oder eine einfache LCS-Implementierung).
|
||||||
- **Rebase** (siehe PATCH): Ops einer veralteten Basis gegen die
|
- **Rebase** (siehe PATCH): Ops einer veralteten Basis gegen die
|
||||||
zwischenzeitlichen verschieben, sofern sie sich nicht überschneiden.
|
zwischenzeitlichen verschieben, sofern sie sich nicht überschneiden.
|
||||||
- **Long Polling**: Spring MVC `DeferredResult` + ein In-Process-Notifier
|
- **Long Polling**: ein In-Process-Notifier (Stempel je Dokument; Zustellung
|
||||||
(pro Dokument eine Warteliste; Zustellung bei akzeptiertem Update **und**
|
bei akzeptiertem Update **und** beim Löschen) — die Anfrage **blockiert**
|
||||||
beim Löschen). Kein zusätzliches Framework nötig. **Das setzt eine
|
ihren Thread, und der ist ein **virtueller** (`spring.threads.virtual`).
|
||||||
Einzelinstanz voraus** — hinter einem Load Balancer erführe ein Beobachter
|
Kein `DeferredResult`, kein zusätzliches Framework: Der Endpunkt behält
|
||||||
auf der zweiten Instanz nichts und liefe in den Timeout. Bewusste Annahme,
|
damit die synchrone Signatur, die die OpenAPI-Generierung erzeugt, und
|
||||||
für die genannte Last angemessen.
|
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
|
- **Serialisierung**: Updates pro Dokument strikt sequenziell
|
||||||
(Locking pro Dokument-UUID), damit Prüfung, Rebase und Anwenden atomar sind.
|
(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",
|
„derselbe PATCH zweimal gesendet ändert das Dokument nur einmal",
|
||||||
„falsche Prüfsumme liefert 422", „Feed meldet DELETED und danach RESTORED",
|
„falsche Prüfsumme liefert 422", „Feed meldet DELETED und danach RESTORED",
|
||||||
„zu altes since liefert Volltext", „Umbenennen erscheint als RENAMED".
|
„zu altes since liefert Volltext", „Umbenennen erscheint als RENAMED".
|
||||||
Long Polling braucht dafür **Nebenläufigkeit im Test** (zwei Threads oder
|
Long Polling braucht dafür **Nebenläufigkeit im Test** und einen klein
|
||||||
asynchrones MockMvc) und einen klein konfigurierbaren `wait`-Wert — der
|
konfigurierbaren `wait`-Wert (in den Tests 5 s). Umgesetzt als
|
||||||
heutige synchrone `TestRestTemplate`-Stil allein reicht nicht.
|
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,
|
- **Unit-Tests**: Diff-Anwendung (alle drei Ops, Randfälle: leeres Dokument,
|
||||||
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung, die
|
Anhängen, letzte Zeile), Diff-Berechnung, Index-Verschiebung, die
|
||||||
Überlappungsregeln für `insert` (untereinander verträglich, mit `delete`
|
Überlappungsregeln für `insert` (untereinander verträglich, mit `delete`
|
||||||
@@ -417,8 +427,8 @@ verworfen.
|
|||||||
2. ~~Historie in zwei Ebenen + gezielter Repository-Zugriff~~ — gebaut
|
2. ~~Historie in zwei Ebenen + gezielter Repository-Zugriff~~ — gebaut
|
||||||
3. ~~`PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
|
3. ~~`PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
|
||||||
Cucumber)~~ — gebaut, `LiveEditingService`
|
Cucumber)~~ — gebaut, `LiveEditingService`
|
||||||
4. `GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
|
4. ~~`GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
|
||||||
(Spec + Cucumber)
|
(Spec + Cucumber)~~ — gebaut, `ChangeNotifier` + `LiveEditingService`
|
||||||
5. Master-Passwort für `GET /documents` (Spring Security)
|
5. Master-Passwort für `GET /documents` (Spring Security)
|
||||||
6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog)
|
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.api.DocumentsApi
|
||||||
import de.werkbaum.generated.model.Document as ApiDocument
|
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.ContentPatchRequest
|
||||||
import de.werkbaum.generated.model.ContentPatchResult
|
import de.werkbaum.generated.model.ContentPatchResult
|
||||||
import de.werkbaum.generated.model.DocumentCreateRequest
|
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.DocumentUpdateRequest
|
||||||
import de.werkbaum.generated.model.RestoreRequest
|
import de.werkbaum.generated.model.RestoreRequest
|
||||||
import de.werkbaum.domain.ChangeAuthor
|
import de.werkbaum.domain.ChangeAuthor
|
||||||
|
import de.werkbaum.domain.ChangeEvent
|
||||||
|
import de.werkbaum.domain.ChangeFeed
|
||||||
import de.werkbaum.domain.ContentPatch
|
import de.werkbaum.domain.ContentPatch
|
||||||
import de.werkbaum.domain.Document
|
import de.werkbaum.domain.Document
|
||||||
import de.werkbaum.domain.DocumentHistoryEntry
|
import de.werkbaum.domain.DocumentHistoryEntry
|
||||||
import de.werkbaum.service.DocumentService
|
import de.werkbaum.service.DocumentService
|
||||||
import de.werkbaum.service.LiveEditingService
|
import de.werkbaum.service.LiveEditingService
|
||||||
|
import org.springframework.http.CacheControl
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.http.ResponseEntity
|
import org.springframework.http.ResponseEntity
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
import java.time.Duration
|
||||||
import java.util.UUID
|
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>> =
|
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||||
|
|
||||||
@@ -109,6 +133,21 @@ class DocumentsController(
|
|||||||
updatedAt = updatedAt,
|
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(
|
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
||||||
documentId = documentId,
|
documentId = documentId,
|
||||||
version = version,
|
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 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> =
|
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
|
||||||
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
|
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Lo
|
|||||||
|
|
||||||
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
|
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")
|
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
|
||||||
fun maxVersion(@Param("documentId") documentId: UUID): Long?
|
fun maxVersion(@Param("documentId") documentId: UUID): Long?
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ interface DocumentHistoryRepository {
|
|||||||
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
|
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
|
||||||
fun maxVersion(documentId: UUID): Long?
|
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. */
|
/** Die nutzersichtbare Historie, älteste zuerst. */
|
||||||
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
|
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 de.werkbaum.repository.DocumentRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
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.Clock
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -20,6 +22,7 @@ class DocumentService(
|
|||||||
private val historyRepository: DocumentHistoryRepository,
|
private val historyRepository: DocumentHistoryRepository,
|
||||||
private val clock: Clock,
|
private val clock: Clock,
|
||||||
private val properties: LiveEditingProperties,
|
private val properties: LiveEditingProperties,
|
||||||
|
private val notifier: ChangeNotifier,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun findAll(): List<Document> = repository.findAll()
|
fun findAll(): List<Document> = repository.findAll()
|
||||||
@@ -203,6 +206,26 @@ class DocumentService(
|
|||||||
document.id,
|
document.id,
|
||||||
document.updatedAt.minus(properties.syncRetention),
|
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. */
|
/** 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. */
|
/** Höchstlänge des Dokuments in Zeichen; der mitgelieferte Plan hat ~40 000. */
|
||||||
val maxContentLength: Int = 2_000_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.DiffNotApplicableException
|
||||||
import de.werkbaum.diff.LineDiff
|
import de.werkbaum.diff.LineDiff
|
||||||
|
import de.werkbaum.domain.ChangeEvent
|
||||||
|
import de.werkbaum.domain.ChangeFeed
|
||||||
import de.werkbaum.domain.ContentPatch
|
import de.werkbaum.domain.ContentPatch
|
||||||
import de.werkbaum.domain.ContentPatchOutcome
|
import de.werkbaum.domain.ContentPatchOutcome
|
||||||
|
import de.werkbaum.domain.DocumentHistoryEntry
|
||||||
import de.werkbaum.repository.DocumentHistoryRepository
|
import de.werkbaum.repository.DocumentHistoryRepository
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
|
import java.time.Duration
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.locks.ReentrantLock
|
import java.util.concurrent.locks.ReentrantLock
|
||||||
import kotlin.concurrent.withLock
|
import kotlin.concurrent.withLock
|
||||||
@@ -32,6 +36,7 @@ class LiveEditingService(
|
|||||||
private val documents: DocumentService,
|
private val documents: DocumentService,
|
||||||
private val history: DocumentHistoryRepository,
|
private val history: DocumentHistoryRepository,
|
||||||
private val properties: LiveEditingProperties,
|
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 =
|
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ spring:
|
|||||||
liquibase:
|
liquibase:
|
||||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
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:
|
werkbaum:
|
||||||
live-editing:
|
live-editing:
|
||||||
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
|
# 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
|
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
|
||||||
# altes "since" dann mit Volltext statt mit einem Diff.
|
# altes "since" dann mit Volltext statt mit einem Diff.
|
||||||
sync-retention: 1h
|
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:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
|
|||||||
@@ -244,6 +244,64 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ProblemDetail"
|
$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:
|
components:
|
||||||
responses:
|
responses:
|
||||||
NotFound:
|
NotFound:
|
||||||
@@ -448,6 +506,54 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/LineOperation"
|
$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:
|
ProblemDetail:
|
||||||
type: object
|
type: object
|
||||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package de.werkbaum.bdd
|
package de.werkbaum.bdd
|
||||||
|
|
||||||
import de.werkbaum.diff.LineDiff
|
import de.werkbaum.diff.LineDiff
|
||||||
|
import de.werkbaum.generated.model.ChangeFeed
|
||||||
import de.werkbaum.generated.model.ContentConflict
|
import de.werkbaum.generated.model.ContentConflict
|
||||||
import de.werkbaum.generated.model.ContentPatchResult
|
import de.werkbaum.generated.model.ContentPatchResult
|
||||||
import de.werkbaum.generated.model.Document as ApiDocument
|
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.cucumber.java.de.Wenn
|
||||||
import io.kotest.assertions.withClue
|
import io.kotest.assertions.withClue
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import io.kotest.matchers.collections.shouldContain
|
||||||
import io.kotest.matchers.shouldBe
|
import io.kotest.matchers.shouldBe
|
||||||
import org.springframework.beans.factory.annotation.Autowired
|
import org.springframework.beans.factory.annotation.Autowired
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.test.web.servlet.client.EntityExchangeResult
|
import org.springframework.test.web.servlet.client.EntityExchangeResult
|
||||||
import org.springframework.test.web.servlet.client.RestTestClient
|
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.
|
* Behavior-Tests des Live-Editings (D76) gegen die laufende Anwendung.
|
||||||
@@ -38,6 +42,10 @@ class LiveEditingStepDefinitions {
|
|||||||
private var lastResponse: EntityExchangeResult<String>? = null
|
private var lastResponse: EntityExchangeResult<String>? = null
|
||||||
private var lastRequestBody: 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 status(): Int? = lastResponse?.status?.value()
|
||||||
|
|
||||||
private fun currentDocument(): ApiDocument =
|
private fun currentDocument(): ApiDocument =
|
||||||
@@ -79,7 +87,8 @@ class LiveEditingStepDefinitions {
|
|||||||
seq: Long = 1,
|
seq: Long = 1,
|
||||||
) = """
|
) = """
|
||||||
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
|
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
|
||||||
"clientId":${json(clientId)},"seq":$seq,"ops":$ops}
|
"clientId":${json(clientId)},"displayName":${json(clientId)},
|
||||||
|
"seq":$seq,"ops":$ops}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
private fun baseOf(clientId: String): Pair<Long, String> =
|
private fun baseOf(clientId: String): Pair<Long, String> =
|
||||||
@@ -115,6 +124,17 @@ class LiveEditingStepDefinitions {
|
|||||||
.status.value() shouldBe 204
|
.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 ----------------
|
||||||
|
|
||||||
@Wenn("Client {string} folgendes Diff einreicht:")
|
@Wenn("Client {string} folgendes Diff einreicht:")
|
||||||
@@ -134,6 +154,86 @@ class LiveEditingStepDefinitions {
|
|||||||
sendPatch(lastRequestBody.shouldNotBeNull())
|
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 / Und ----------------
|
||||||
|
|
||||||
@Dann("erhalte ich für das Diff den Status {int}")
|
@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 repository = mockk<DocumentRepository>()
|
||||||
private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
|
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(
|
private fun sampleDocument(
|
||||||
id: UUID = UUID.randomUUID(),
|
id: UUID = UUID.randomUUID(),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import de.werkbaum.domain.ChangeType
|
|||||||
import de.werkbaum.domain.ContentPatch
|
import de.werkbaum.domain.ContentPatch
|
||||||
import de.werkbaum.domain.ContentPatchOutcome
|
import de.werkbaum.domain.ContentPatchOutcome
|
||||||
import de.werkbaum.domain.Document
|
import de.werkbaum.domain.Document
|
||||||
|
import de.werkbaum.domain.ChangeEvent
|
||||||
import de.werkbaum.domain.DocumentHistoryEntry
|
import de.werkbaum.domain.DocumentHistoryEntry
|
||||||
import de.werkbaum.repository.DocumentHistoryRepository
|
import de.werkbaum.repository.DocumentHistoryRepository
|
||||||
import io.kotest.assertions.throwables.shouldThrow
|
import io.kotest.assertions.throwables.shouldThrow
|
||||||
@@ -17,6 +18,7 @@ import io.mockk.mockk
|
|||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import io.mockk.verify
|
import io.mockk.verify
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.time.Duration
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
@@ -25,8 +27,13 @@ class LiveEditingServiceTest {
|
|||||||
private val id = UUID.randomUUID()
|
private val id = UUID.randomUUID()
|
||||||
private val documents = mockk<DocumentService>()
|
private val documents = mockk<DocumentService>()
|
||||||
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
|
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
|
||||||
private val properties = LiveEditingProperties(maxOps = 3, maxContentLength = 40)
|
private val properties = LiveEditingProperties(
|
||||||
private val service = LiveEditingService(documents, history, properties)
|
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"
|
private val basis = "eins\nzwei\ndrei"
|
||||||
|
|
||||||
@@ -242,6 +249,104 @@ class LiveEditingServiceTest {
|
|||||||
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
|
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(
|
private fun historyEntry(content: String, version: Long) = DocumentHistoryEntry(
|
||||||
documentId = id,
|
documentId = id,
|
||||||
version = version,
|
version = version,
|
||||||
|
|||||||
@@ -10,3 +10,12 @@ spring:
|
|||||||
open-in-view: false
|
open-in-view: false
|
||||||
liquibase:
|
liquibase:
|
||||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
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
|
||||||
@@ -6114,3 +6114,49 @@ Zahlen: 104 Tests. Gegenproben je Regel — Prüfsumme nicht geprüft, Idempoten
|
|||||||
entfernt, veraltete Basis abgelehnt statt verschoben, Schreibpause ignoriert,
|
entfernt, veraltete Basis abgelehnt statt verschoben, Schreibpause ignoriert,
|
||||||
Rückfall wieder als `RESTORED`, jüngster Stand aus der Historie genommen: Es
|
Rückfall wieder als `RESTORED`, jüngster Stand aus der Historie genommen: Es
|
||||||
fallen jeweils genau die danach benannten Zusicherungen.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user