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,
|
||||
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.
|
||||
|
||||
+33
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.generated.api.DocumentsApi
|
||||
import de.werkbaum.generated.model.Document as ApiDocument
|
||||
import de.werkbaum.generated.model.ChangeEvent as ApiChangeEvent
|
||||
import de.werkbaum.generated.model.ChangeFeed as ApiChangeFeed
|
||||
import de.werkbaum.generated.model.ContentPatchRequest
|
||||
import de.werkbaum.generated.model.ContentPatchResult
|
||||
import de.werkbaum.generated.model.DocumentCreateRequest
|
||||
@@ -9,15 +11,19 @@ import de.werkbaum.generated.model.DocumentHistoryEntry as ApiHistoryEntry
|
||||
import de.werkbaum.generated.model.DocumentUpdateRequest
|
||||
import de.werkbaum.generated.model.RestoreRequest
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeEvent
|
||||
import de.werkbaum.domain.ChangeFeed
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.service.DocumentService
|
||||
import de.werkbaum.service.LiveEditingService
|
||||
import org.springframework.http.CacheControl
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
@@ -89,6 +95,24 @@ class DocumentsController(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Long Polling: Der Server haelt die Anfrage offen, bis sich etwas tut.
|
||||
*
|
||||
* `no-store` ist Pflicht – ein Proxy duerfte sonst eine 204
|
||||
* zwischenspeichern, und der Feed stuende still.
|
||||
*/
|
||||
override fun getDocumentChanges(
|
||||
documentId: UUID,
|
||||
since: Long,
|
||||
wait: Int,
|
||||
): ResponseEntity<ApiChangeFeed> {
|
||||
val feed = liveEditing.changesSince(documentId, since, Duration.ofSeconds(wait.toLong()))
|
||||
return ResponseEntity
|
||||
.status(if (feed == null) HttpStatus.NO_CONTENT else HttpStatus.OK)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.body(feed?.toApi())
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||
|
||||
@@ -109,6 +133,21 @@ class DocumentsController(
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
|
||||
private fun ChangeFeed.toApi(): ApiChangeFeed = ApiChangeFeed(
|
||||
currentVersion = currentVersion,
|
||||
events = events.map { it.toApi() },
|
||||
fromVersion = fromVersion,
|
||||
ops = ops?.toApi(),
|
||||
content = content,
|
||||
)
|
||||
|
||||
private fun ChangeEvent.toApi(): ApiChangeEvent = ApiChangeEvent(
|
||||
version = version,
|
||||
changeType = ApiChangeEvent.ChangeType.valueOf(changeType.name),
|
||||
clientId = author?.clientId,
|
||||
displayName = author?.displayName,
|
||||
)
|
||||
|
||||
private fun DocumentHistoryEntry.toApi(): ApiHistoryEntry = ApiHistoryEntry(
|
||||
documentId = documentId,
|
||||
version = version,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.werkbaum.domain
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
|
||||
/** Was an einer Version geschehen ist und wer sie eingereicht hat. */
|
||||
data class ChangeEvent(
|
||||
val version: Long,
|
||||
val changeType: ChangeType,
|
||||
val author: ChangeAuthor? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Alles, was seit einer bekannten Version geschehen ist.
|
||||
*
|
||||
* Entweder [ops] (der Normalfall – der Client wendet sie an und behält Cursor
|
||||
* und Scrollposition) **oder** [content] als Volltext. Letzteres, wenn die
|
||||
* Basis bereits verdichtet ist oder der Client noch gar nichts hat: Dann kann
|
||||
* kein exaktes Diff mehr entstehen, und über hunderte Versionen hinweg wäre
|
||||
* der Cursor ohnehin nicht zu retten. Ein Roundtrip und ein Sonderzustand
|
||||
* weniger als ein eigener Fehlerpfad (D76).
|
||||
*/
|
||||
data class ChangeFeed(
|
||||
val fromVersion: Long?,
|
||||
val currentVersion: Long,
|
||||
val ops: List<LineOp>?,
|
||||
val content: String?,
|
||||
val events: List<ChangeEvent>,
|
||||
)
|
||||
@@ -28,6 +28,9 @@ class JpaDocumentHistoryRepository(
|
||||
|
||||
override fun maxVersion(documentId: UUID): Long? = jpa.maxVersion(documentId)
|
||||
|
||||
override fun findAfterVersion(documentId: UUID, version: Long): List<DocumentHistoryEntry> =
|
||||
jpa.findByDocumentIdAndVersionGreaterThanOrderByIdAsc(documentId, version).map { it.toDomain() }
|
||||
|
||||
override fun findMilestones(documentId: UUID): List<DocumentHistoryEntry> =
|
||||
jpa.findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId).map { it.toDomain() }
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ interface DocumentHistoryJpaRepository : JpaRepository<DocumentHistoryEntity, Lo
|
||||
|
||||
fun findByDocumentIdAndMilestoneTrueOrderByIdAsc(documentId: UUID): List<DocumentHistoryEntity>
|
||||
|
||||
fun findByDocumentIdAndVersionGreaterThanOrderByIdAsc(
|
||||
documentId: UUID,
|
||||
version: Long,
|
||||
): List<DocumentHistoryEntity>
|
||||
|
||||
@Query("select max(e.version) from DocumentHistoryEntity e where e.documentId = :documentId")
|
||||
fun maxVersion(@Param("documentId") documentId: UUID): Long?
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ interface DocumentHistoryRepository {
|
||||
/** Höchste vergebene Versionsnummer, auch wenn deren Eintrag verdichtet wurde. */
|
||||
fun maxVersion(documentId: UUID): Long?
|
||||
|
||||
/** Alle Einträge mit einer Version größer [version], älteste zuerst. */
|
||||
fun findAfterVersion(documentId: UUID, version: Long): List<DocumentHistoryEntry>
|
||||
|
||||
/** Die nutzersichtbare Historie, älteste zuerst. */
|
||||
fun findMilestones(documentId: UUID): List<DocumentHistoryEntry>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import org.springframework.stereotype.Component
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
|
||||
/**
|
||||
* Weckt wartende Beobachter, wenn sich an einem Dokument etwas getan hat —
|
||||
* der Kern der „Echtzeit ohne WebSocket"-Lösung (D76).
|
||||
*
|
||||
* **Setzt eine Einzelinstanz voraus.** Hinter einem Load Balancer erführe ein
|
||||
* Beobachter auf der zweiten Instanz nichts und liefe in den Timeout. Bewusste
|
||||
* Annahme, für zehn Beobachter angemessen.
|
||||
*
|
||||
* Gezählt wird je Dokument ein **Stempel**, nicht die Versionsnummer: Der
|
||||
* Aufrufer liest ihn, **bevor** er in der Datenbank nachsieht. Ändert sich in
|
||||
* der Lücke dazwischen etwas, kehrt das Warten sofort zurück, statt das Signal
|
||||
* zu verpassen und die volle Wartezeit abzusitzen.
|
||||
*
|
||||
* Gewartet wird mit `ReentrantLock`/`Condition`, nicht mit `synchronized`
|
||||
* plus `wait()`: Der Feed blockiert seinen Thread, und das soll ein
|
||||
* **virtueller** Thread sein dürfen (siehe `spring.threads.virtual.enabled`) —
|
||||
* ein Monitor würde dessen Träger festnageln.
|
||||
*/
|
||||
@Component
|
||||
class ChangeNotifier {
|
||||
|
||||
private val lock = ReentrantLock()
|
||||
private val changed = lock.newCondition()
|
||||
private val stamps = ConcurrentHashMap<UUID, Long>()
|
||||
|
||||
fun stampOf(documentId: UUID): Long = stamps[documentId] ?: 0L
|
||||
|
||||
/** Meldet eine Änderung – aufzurufen **nach** dem Commit, nie davor. */
|
||||
fun published(documentId: UUID) = lock.withLock {
|
||||
stamps.merge(documentId, 1L) { old, one -> old + one }
|
||||
changed.signalAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Wartet, bis sich der Stempel des Dokuments von [since] unterscheidet.
|
||||
* `true` heißt: es hat sich etwas getan. `false` heißt: Zeit abgelaufen.
|
||||
*/
|
||||
fun awaitChange(documentId: UUID, since: Long, timeout: Duration): Boolean = lock.withLock {
|
||||
var remaining = timeout.toNanos()
|
||||
while (stampOf(documentId) == since) {
|
||||
if (remaining <= 0) return false
|
||||
remaining = changed.awaitNanos(remaining)
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import de.werkbaum.repository.DocumentRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import org.springframework.transaction.support.TransactionSynchronization
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager
|
||||
import java.time.Clock
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
@@ -20,6 +22,7 @@ class DocumentService(
|
||||
private val historyRepository: DocumentHistoryRepository,
|
||||
private val clock: Clock,
|
||||
private val properties: LiveEditingProperties,
|
||||
private val notifier: ChangeNotifier,
|
||||
) {
|
||||
|
||||
fun findAll(): List<Document> = repository.findAll()
|
||||
@@ -203,6 +206,26 @@ class DocumentService(
|
||||
document.id,
|
||||
document.updatedAt.minus(properties.syncRetention),
|
||||
)
|
||||
|
||||
publishAfterCommit(document.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Weckt die Beobachter am Änderungsfeed – **nach** dem Commit. Vorher
|
||||
* geweckt läse ein Beobachter einen Stand, der noch nicht steht, und
|
||||
* bekäme das Ereignis nie wieder. Ohne laufende Transaktion (Tests) wird
|
||||
* sofort gemeldet.
|
||||
*/
|
||||
private fun publishAfterCommit(documentId: UUID) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
notifier.published(documentId)
|
||||
return
|
||||
}
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
object : TransactionSynchronization {
|
||||
override fun afterCommit() = notifier.published(documentId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Anlegen, Löschen, Wiederherstellen und Rückfall sind nie bloß Sync-Versionen. */
|
||||
|
||||
@@ -31,4 +31,12 @@ data class LiveEditingProperties(
|
||||
|
||||
/** Höchstlänge des Dokuments in Zeichen; der mitgelieferte Plan hat ~40 000. */
|
||||
val maxContentLength: Int = 2_000_000,
|
||||
|
||||
/**
|
||||
* Obergrenze für das Warten am Änderungsfeed. Ein Client darf keine
|
||||
* beliebig lange Verbindung binden. 25 s sind gemessen: Apache auf der
|
||||
* Zielumgebung hält einen Long-Poll nachweislich 30 s durch, seine
|
||||
* Zeitgrenzen liegen bei 300 s (D76-Nachtrag 1/2).
|
||||
*/
|
||||
val maxWait: Duration = Duration.ofSeconds(25),
|
||||
)
|
||||
|
||||
@@ -2,10 +2,14 @@ package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.domain.ChangeEvent
|
||||
import de.werkbaum.domain.ChangeFeed
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
@@ -32,6 +36,7 @@ class LiveEditingService(
|
||||
private val documents: DocumentService,
|
||||
private val history: DocumentHistoryRepository,
|
||||
private val properties: LiveEditingProperties,
|
||||
private val notifier: ChangeNotifier,
|
||||
) {
|
||||
|
||||
/**
|
||||
@@ -122,6 +127,66 @@ class LiveEditingService(
|
||||
)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Änderungsfeed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alles seit [since] – oder `null`, wenn innerhalb von [wait] nichts
|
||||
* passiert (im Protokoll: 204).
|
||||
*
|
||||
* Der Feed arbeitet auf der **Historie**, nicht am Dokument: `delete()`
|
||||
* entfernt das Dokument und lässt nur den Tombstone stehen – ein Feed am
|
||||
* Dokument müsste danach 404 liefern, ausgerechnet für das
|
||||
* DELETED-Ereignis, das er zustellen soll. 404 gibt es deshalb nur bei
|
||||
* gänzlich unbekannter UUID.
|
||||
*/
|
||||
fun changesSince(documentId: UUID, since: Long, wait: Duration): ChangeFeed? {
|
||||
if (!history.exists(documentId)) throw DocumentNotFoundException(documentId)
|
||||
|
||||
// Den Stempel VOR dem Nachsehen lesen: Ändert sich in der Lücke
|
||||
// dazwischen etwas, kehrt das Warten unten sofort zurück.
|
||||
val stamp = notifier.stampOf(documentId)
|
||||
feedSince(documentId, since)?.let { return it }
|
||||
|
||||
val timeout = minOf(wait, properties.maxWait)
|
||||
if (timeout.isNegative || timeout.isZero) return null
|
||||
if (!notifier.awaitChange(documentId, stamp, timeout)) return null
|
||||
|
||||
return feedSince(documentId, since)
|
||||
}
|
||||
|
||||
private fun feedSince(documentId: UUID, since: Long): ChangeFeed? {
|
||||
val latest = history.findLatest(documentId) ?: return null
|
||||
if (latest.version <= since) return null
|
||||
|
||||
val events = history.findAfterVersion(documentId, since).map { it.toEvent() }
|
||||
val base = history.findVersion(documentId, since)?.content
|
||||
|
||||
// Ohne Basis kein exaktes Diff - dann der Volltext. Das deckt den
|
||||
// Nachzügler nach dem Verdichten ebenso ab wie den Erstkontakt
|
||||
// (since = 0) und die PWA nach langer Offline-Zeit.
|
||||
return if (base == null) {
|
||||
ChangeFeed(
|
||||
fromVersion = null,
|
||||
currentVersion = latest.version,
|
||||
ops = null,
|
||||
content = latest.content,
|
||||
events = events,
|
||||
)
|
||||
} else {
|
||||
ChangeFeed(
|
||||
fromVersion = since,
|
||||
currentVersion = latest.version,
|
||||
ops = LineDiff.compute(LineDiff.lines(base), LineDiff.lines(latest.content)),
|
||||
content = null,
|
||||
events = events,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun DocumentHistoryEntry.toEvent() = ChangeEvent(version, changeType, author)
|
||||
|
||||
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ spring:
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
||||
|
||||
threads:
|
||||
virtual:
|
||||
# Der Aenderungsfeed blockiert seinen Thread, bis sich etwas tut
|
||||
# (Long Polling). Auf einem virtuellen Thread kostet das Warten fast
|
||||
# nichts - ein Plattform-Thread je Beobachter waere ein Megabyte Stack
|
||||
# auf einem Host, dessen knappe Groesse der Speicher ist (D76).
|
||||
enabled: true
|
||||
|
||||
werkbaum:
|
||||
live-editing:
|
||||
# Schreibpause, nach der die letzte Version zum Meilenstein wird.
|
||||
@@ -25,6 +33,9 @@ werkbaum:
|
||||
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
|
||||
# altes "since" dann mit Volltext statt mit einem Diff.
|
||||
sync-retention: 1h
|
||||
# Obergrenze fuers Warten am Feed. Gemessen: Apache auf der Zielumgebung
|
||||
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
|
||||
max-wait: 25s
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
@@ -244,6 +244,64 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/documents/{documentId}/changes:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
get:
|
||||
tags: [Documents]
|
||||
operationId: getDocumentChanges
|
||||
summary: Aenderungsfeed (Long Polling)
|
||||
description: >
|
||||
Liefert alles, was seit `since` geschehen ist. Gibt es nichts, haelt
|
||||
der Server die Anfrage bis zu `wait` Sekunden offen und antwortet
|
||||
sofort, sobald eine Aenderung eintrifft; sonst 204.
|
||||
|
||||
|
||||
Der Feed arbeitet auf der **Historie**, nicht am Dokument: Ein
|
||||
geloeschtes Dokument muss sein DELETED-Ereignis noch zustellen koennen.
|
||||
404 gibt es deshalb nur bei gaenzlich unbekannter UUID.
|
||||
|
||||
|
||||
Ist `since` bereits verdichtet, kann kein exaktes Diff mehr geliefert
|
||||
werden - dann enthaelt die Antwort statt `ops` den **Volltext**, und
|
||||
`fromVersion` fehlt. Der Client ersetzt seinen Stand.
|
||||
parameters:
|
||||
- name: since
|
||||
in: query
|
||||
required: true
|
||||
description: Zuletzt gesehene Version; 0 fuer "noch nichts".
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
- name: wait
|
||||
in: query
|
||||
required: false
|
||||
description: >
|
||||
Wartezeit in Sekunden. Serverseitig geklemmt - ein Client darf
|
||||
keine beliebig lange Verbindung binden.
|
||||
schema:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
default: 25
|
||||
responses:
|
||||
"200":
|
||||
description: Aenderungen seit `since`
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChangeFeed"
|
||||
"204":
|
||||
description: Nichts Neues innerhalb der Wartezeit
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
|
||||
components:
|
||||
responses:
|
||||
NotFound:
|
||||
@@ -448,6 +506,54 @@ components:
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ChangeEvent:
|
||||
description: Was an einer Version geschehen ist und wer sie eingereicht hat.
|
||||
type: object
|
||||
required: [version, changeType]
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
changeType:
|
||||
type: string
|
||||
enum: [CREATED, UPDATED, DELETED, RESTORED, ROLLED_BACK]
|
||||
clientId:
|
||||
type: string
|
||||
description: Fehlt bei Aenderungen ohne Absender (etwa ueber PUT).
|
||||
displayName:
|
||||
type: string
|
||||
description: >
|
||||
Selbstgewaehlt und ohne Anmeldung eine Behauptung - in der
|
||||
Oberflaeche nicht wie ein Nachweis darstellen.
|
||||
|
||||
ChangeFeed:
|
||||
type: object
|
||||
required: [currentVersion, events]
|
||||
properties:
|
||||
fromVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
description: >
|
||||
Basis des mitgelieferten Diffs. Fehlt, wenn stattdessen `content`
|
||||
geliefert wird.
|
||||
currentVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
ops:
|
||||
type: array
|
||||
description: Kumuliertes Diff von `fromVersion` bis `currentVersion`.
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
content:
|
||||
type: string
|
||||
description: >
|
||||
Volltext statt Diff - wenn `since` bereits verdichtet ist oder der
|
||||
Client noch gar nichts hat (`since=0`).
|
||||
events:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/ChangeEvent"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.werkbaum.bdd
|
||||
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.generated.model.ChangeFeed
|
||||
import de.werkbaum.generated.model.ContentConflict
|
||||
import de.werkbaum.generated.model.ContentPatchResult
|
||||
import de.werkbaum.generated.model.Document as ApiDocument
|
||||
@@ -12,11 +13,14 @@ import io.cucumber.java.de.Und
|
||||
import io.cucumber.java.de.Wenn
|
||||
import io.kotest.assertions.withClue
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.collections.shouldContain
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.test.web.servlet.client.EntityExchangeResult
|
||||
import org.springframework.test.web.servlet.client.RestTestClient
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Behavior-Tests des Live-Editings (D76) gegen die laufende Anwendung.
|
||||
@@ -38,6 +42,10 @@ class LiveEditingStepDefinitions {
|
||||
private var lastResponse: EntityExchangeResult<String>? = null
|
||||
private var lastRequestBody: String? = null
|
||||
|
||||
/** Ein Feed-Abruf, der im Hintergrund wartet – für Long Polling. */
|
||||
private var pendingFeed: CompletableFuture<EntityExchangeResult<String>>? = null
|
||||
private var feedStartedAt: Long = 0
|
||||
|
||||
private fun status(): Int? = lastResponse?.status?.value()
|
||||
|
||||
private fun currentDocument(): ApiDocument =
|
||||
@@ -79,7 +87,8 @@ class LiveEditingStepDefinitions {
|
||||
seq: Long = 1,
|
||||
) = """
|
||||
{"baseVersion":$baseVersion,"checksum":${json(checksum)},
|
||||
"clientId":${json(clientId)},"seq":$seq,"ops":$ops}
|
||||
"clientId":${json(clientId)},"displayName":${json(clientId)},
|
||||
"seq":$seq,"ops":$ops}
|
||||
""".trimIndent()
|
||||
|
||||
private fun baseOf(clientId: String): Pair<Long, String> =
|
||||
@@ -115,6 +124,17 @@ class LiveEditingStepDefinitions {
|
||||
.status.value() shouldBe 204
|
||||
}
|
||||
|
||||
@Angenommen("dieses Dokument wiederhergestellt wird")
|
||||
fun `dieses Dokument wird wiederhergestellt`() {
|
||||
client.post()
|
||||
.uri("/api/v1/documents/$documentId/restore")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body("{}")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
.status.value() shouldBe 200
|
||||
}
|
||||
|
||||
// ---------------- Wenn ----------------
|
||||
|
||||
@Wenn("Client {string} folgendes Diff einreicht:")
|
||||
@@ -134,6 +154,86 @@ class LiveEditingStepDefinitions {
|
||||
sendPatch(lastRequestBody.shouldNotBeNull())
|
||||
}
|
||||
|
||||
// ---------------- Änderungsfeed ----------------
|
||||
|
||||
@Wenn("ich die Änderungen seit Version {long} abrufe")
|
||||
fun `ich rufe die Aenderungen ab`(since: Long) {
|
||||
lastResponse = feedRequest(since, wait = 0)
|
||||
}
|
||||
|
||||
@Wenn("ich höchstens {int} Sekunden auf Änderungen seit Version {long} warte")
|
||||
fun `ich warte auf Aenderungen`(sekunden: Int, since: Long) {
|
||||
lastResponse = feedRequest(since, wait = sekunden)
|
||||
}
|
||||
|
||||
@Wenn("im Hintergrund auf Änderungen seit Version {long} gewartet wird")
|
||||
fun `im Hintergrund wird gewartet`(since: Long) {
|
||||
feedStartedAt = System.nanoTime()
|
||||
pendingFeed = CompletableFuture.supplyAsync { feedRequest(since, wait = 5) }
|
||||
// Dem Abruf einen Moment geben, damit er wirklich wartet, statt die
|
||||
// Aenderung schon vorzufinden - sonst prueft das Szenario nichts.
|
||||
Thread.sleep(300)
|
||||
}
|
||||
|
||||
@Dann("hat der wartende Abruf die Änderung erhalten")
|
||||
fun `der wartende Abruf hat die Aenderung erhalten`() {
|
||||
val response = pendingFeed.shouldNotBeNull().get(10, TimeUnit.SECONDS)
|
||||
val dauer = (System.nanoTime() - feedStartedAt) / 1_000_000
|
||||
withClue("Antwort: ${response.responseBody}") { response.status.value() shouldBe 200 }
|
||||
withClue("Der Abruf hat $dauer ms gebraucht - er wurde nicht geweckt, sondern lief ab") {
|
||||
(dauer < 4_000) shouldBe true
|
||||
}
|
||||
lastResponse = response
|
||||
}
|
||||
|
||||
@Und("der Feed meldet die Version {long}")
|
||||
fun `der Feed meldet die Version`(erwartet: Long) {
|
||||
lastBody<ChangeFeed>().currentVersion shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("der Feed liefert {int} Operationen ab Version {long}")
|
||||
fun `der Feed liefert n Operationen`(anzahl: Int, from: Long) {
|
||||
val feed = lastBody<ChangeFeed>()
|
||||
feed.fromVersion shouldBe from
|
||||
feed.ops.shouldNotBeNull().size shouldBe anzahl
|
||||
}
|
||||
|
||||
@Und("der Feed liefert den Volltext:")
|
||||
fun `der Feed liefert den Volltext`(erwartet: String) {
|
||||
val feed = lastBody<ChangeFeed>()
|
||||
feed.fromVersion shouldBe null
|
||||
feed.content shouldBe erwartet
|
||||
}
|
||||
|
||||
@Und("der Feed meldet das Ereignis {string}")
|
||||
fun `der Feed meldet das Ereignis`(typ: String) {
|
||||
lastBody<ChangeFeed>().events.map { it.changeType.value } shouldContain typ
|
||||
}
|
||||
|
||||
@Und("der Feed nennt als Absender {string}")
|
||||
fun `der Feed nennt als Absender`(name: String) {
|
||||
lastBody<ChangeFeed>().events.mapNotNull { it.displayName } shouldContain name
|
||||
}
|
||||
|
||||
@Und("die Antwort verbietet das Zwischenspeichern")
|
||||
fun `die Antwort verbietet das Zwischenspeichern`() {
|
||||
lastResponse?.responseHeaders?.cacheControl shouldBe "no-store"
|
||||
}
|
||||
|
||||
@Wenn("ich die Änderungen eines unbekannten Dokuments abrufe")
|
||||
fun `ich rufe die Aenderungen eines unbekannten Dokuments ab`() {
|
||||
lastResponse = client.get()
|
||||
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000000/changes?since=0&wait=0")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
}
|
||||
|
||||
private fun feedRequest(since: Long, wait: Int): EntityExchangeResult<String> =
|
||||
client.get()
|
||||
.uri("/api/v1/documents/$documentId/changes?since=$since&wait=$wait")
|
||||
.exchange()
|
||||
.returnResult(String::class.java)
|
||||
|
||||
// ---------------- Dann / Und ----------------
|
||||
|
||||
@Dann("erhalte ich für das Diff den Status {int}")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ChangeNotifierTest {
|
||||
|
||||
private val notifier = ChangeNotifier()
|
||||
private val id = UUID.randomUUID()
|
||||
|
||||
@Test
|
||||
fun `ohne Aenderung laeuft die Wartezeit ab`() {
|
||||
notifier.awaitChange(id, notifier.stampOf(id), Duration.ofMillis(50)) shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Aenderung weckt den Wartenden`() {
|
||||
val stamp = notifier.stampOf(id)
|
||||
val wartend = CompletableFuture.supplyAsync {
|
||||
notifier.awaitChange(id, stamp, Duration.ofSeconds(5))
|
||||
}
|
||||
Thread.sleep(100)
|
||||
notifier.published(id)
|
||||
|
||||
wartend.get(5, TimeUnit.SECONDS) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine Aenderung in der Luecke geht nicht verloren`() {
|
||||
// Genau dafuer ist der Stempel da: Der Aufrufer liest ihn, bevor er in
|
||||
// der Datenbank nachsieht. Passiert dazwischen etwas, kehrt das Warten
|
||||
// sofort zurueck, statt die volle Zeit abzusitzen.
|
||||
val stamp = notifier.stampOf(id)
|
||||
notifier.published(id)
|
||||
|
||||
notifier.awaitChange(id, stamp, Duration.ofMillis(50)) shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein anderes Dokument weckt nicht`() {
|
||||
val stamp = notifier.stampOf(id)
|
||||
notifier.published(UUID.randomUUID())
|
||||
|
||||
notifier.awaitChange(id, stamp, Duration.ofMillis(50)) shouldBe false
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,9 @@ class DocumentServiceTest {
|
||||
|
||||
private val repository = mockk<DocumentRepository>()
|
||||
private val historyRepository = mockk<DocumentHistoryRepository>(relaxed = true)
|
||||
private val service = DocumentService(repository, historyRepository, clock, properties)
|
||||
private val notifier = ChangeNotifier()
|
||||
private val service =
|
||||
DocumentService(repository, historyRepository, clock, properties, notifier)
|
||||
|
||||
private fun sampleDocument(
|
||||
id: UUID = UUID.randomUUID(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.ChangeEvent
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
@@ -17,6 +18,7 @@ import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.time.Duration
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@@ -25,8 +27,13 @@ class LiveEditingServiceTest {
|
||||
private val id = UUID.randomUUID()
|
||||
private val documents = mockk<DocumentService>()
|
||||
private val history = mockk<DocumentHistoryRepository>(relaxed = true)
|
||||
private val properties = LiveEditingProperties(maxOps = 3, maxContentLength = 40)
|
||||
private val service = LiveEditingService(documents, history, properties)
|
||||
private val properties = LiveEditingProperties(
|
||||
maxOps = 3,
|
||||
maxContentLength = 40,
|
||||
maxWait = Duration.ofMillis(200),
|
||||
)
|
||||
private val notifier = ChangeNotifier()
|
||||
private val service = LiveEditingService(documents, history, properties, notifier)
|
||||
|
||||
private val basis = "eins\nzwei\ndrei"
|
||||
|
||||
@@ -242,6 +249,104 @@ class LiveEditingServiceTest {
|
||||
verify(exactly = 0) { documents.update(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Änderungsfeed
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `ohne Aenderung liefert der Feed nichts`() {
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns historyEntry(basis, 7)
|
||||
|
||||
service.changesSince(id, since = 7, wait = Duration.ZERO) shouldBe null
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `der Feed liefert das kumulierte Diff seit der bekannten Version`() {
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns historyEntry("eins\nZWEI\ndrei", 9)
|
||||
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
|
||||
every { history.findAfterVersion(id, 7) } returns listOf(
|
||||
historyEntry("eins\nzwischendrin\ndrei", 8),
|
||||
historyEntry("eins\nZWEI\ndrei", 9),
|
||||
)
|
||||
|
||||
val feed = service.changesSince(id, since = 7, wait = Duration.ZERO)!!
|
||||
|
||||
feed.fromVersion shouldBe 7
|
||||
feed.currentVersion shouldBe 9
|
||||
feed.ops shouldBe listOf(LineOp.Replace(1, 1, listOf("ZWEI")))
|
||||
feed.content shouldBe null
|
||||
feed.events.map { it.version } shouldBe listOf(8L, 9L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine verdichtete Basis liefert den Volltext statt eines Diffs`() {
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns historyEntry("neu", 9)
|
||||
every { history.findVersion(id, 2) } returns null
|
||||
every { history.findAfterVersion(id, 2) } returns emptyList()
|
||||
|
||||
val feed = service.changesSince(id, since = 2, wait = Duration.ZERO)!!
|
||||
|
||||
feed.fromVersion shouldBe null
|
||||
feed.ops shouldBe null
|
||||
feed.content shouldBe "neu"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `der Feed nennt den Absender jeder Aenderung`() {
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns historyEntry("neu", 8)
|
||||
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
|
||||
every { history.findAfterVersion(id, 7) } returns listOf(
|
||||
historyEntry("neu", 8).copy(author = ChangeAuthor("c-1", "Anna")),
|
||||
)
|
||||
|
||||
service.changesSince(id, since = 7, wait = Duration.ZERO)!!.events shouldBe listOf(
|
||||
ChangeEvent(8, ChangeType.UPDATED, ChangeAuthor("c-1", "Anna")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ein geloeschtes Dokument hat weiterhin einen Feed`() {
|
||||
// Sonst käme ausgerechnet das DELETED-Ereignis nie an.
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns
|
||||
historyEntry(basis, 8).copy(changeType = ChangeType.DELETED)
|
||||
every { history.findVersion(id, 7) } returns historyEntry(basis, 7)
|
||||
every { history.findAfterVersion(id, 7) } returns listOf(
|
||||
historyEntry(basis, 8).copy(changeType = ChangeType.DELETED),
|
||||
)
|
||||
|
||||
val feed = service.changesSince(id, since = 7, wait = Duration.ZERO)!!
|
||||
|
||||
feed.events.single().changeType shouldBe ChangeType.DELETED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eine gaenzlich unbekannte UUID hat keinen Feed`() {
|
||||
every { history.exists(id) } returns false
|
||||
|
||||
shouldThrow<DocumentNotFoundException> {
|
||||
service.changesSince(id, since = 0, wait = Duration.ZERO)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `die Wartezeit wird serverseitig geklemmt`() {
|
||||
// maxWait steht in diesem Test auf 200 ms; ein Client darf keine
|
||||
// beliebig lange Verbindung binden.
|
||||
every { history.exists(id) } returns true
|
||||
every { history.findLatest(id) } returns historyEntry(basis, 7)
|
||||
|
||||
val start = System.nanoTime()
|
||||
service.changesSince(id, since = 7, wait = Duration.ofSeconds(30)) shouldBe null
|
||||
val dauer = Duration.ofNanos(System.nanoTime() - start)
|
||||
|
||||
(dauer < Duration.ofSeconds(5)) shouldBe true
|
||||
}
|
||||
|
||||
private fun historyEntry(content: String, version: Long) = DocumentHistoryEntry(
|
||||
documentId = id,
|
||||
version = version,
|
||||
|
||||
@@ -10,3 +10,12 @@ spring:
|
||||
open-in-view: false
|
||||
liquibase:
|
||||
change-log: classpath:db/changelog/db.changelog-master.sql
|
||||
|
||||
threads:
|
||||
virtual:
|
||||
enabled: true
|
||||
|
||||
werkbaum:
|
||||
live-editing:
|
||||
# Kurz, damit die Behavior-Tests nicht auf die Produktionswerte warten.
|
||||
max-wait: 5s
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# language: de
|
||||
Funktionalität: Änderungen mitbekommen, ohne zu pollen
|
||||
Als Betrachter eines geteilten Plans
|
||||
möchte ich Änderungen sofort sehen,
|
||||
ohne dass mein Browser dauernd nachfragt.
|
||||
|
||||
Der Server hält die Anfrage offen und antwortet, sobald sich etwas tut
|
||||
(Long Polling). Kommt in der Wartezeit nichts, antwortet er mit 204 und der
|
||||
Client fragt erneut.
|
||||
|
||||
Szenario: Wer zurückliegt, bekommt sofort das Diff
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
Und Client "anna" kennt den aktuellen Stand
|
||||
Und Client "ben" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
|
||||
"""
|
||||
Wenn ich die Änderungen seit Version 1 abrufe
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und der Feed meldet die Version 2
|
||||
Und der Feed liefert 1 Operationen ab Version 1
|
||||
Und der Feed meldet das Ereignis "UPDATED"
|
||||
Und die Antwort verbietet das Zwischenspeichern
|
||||
|
||||
Szenario: Wer auf dem neuesten Stand ist, bekommt nichts
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Wenn ich höchstens 1 Sekunden auf Änderungen seit Version 1 warte
|
||||
Dann erhalte ich für das Diff den Status 204
|
||||
|
||||
Szenario: Ein Wartender wird geweckt, sobald eine Änderung eintrifft
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Und im Hintergrund auf Änderungen seit Version 1 gewartet wird
|
||||
Wenn Client "ben" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"insert","index":1,"lines":["- [ ] Zwei"]}]
|
||||
"""
|
||||
Dann hat der wartende Abruf die Änderung erhalten
|
||||
Und der Feed meldet die Version 2
|
||||
Und der Feed liefert 1 Operationen ab Version 1
|
||||
|
||||
Szenario: Wer noch gar nichts hat, bekommt den Volltext
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
Wenn ich die Änderungen seit Version 0 abrufe
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und der Feed liefert den Volltext:
|
||||
"""
|
||||
- [ ] Eins
|
||||
- [ ] Zwei
|
||||
"""
|
||||
|
||||
Szenario: Der Feed nennt den Absender einer Änderung
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Und Client "anna" folgendes Diff einreicht:
|
||||
"""
|
||||
[{"op":"replace","index":0,"count":1,"lines":["- [x] Eins"]}]
|
||||
"""
|
||||
Wenn ich die Änderungen seit Version 1 abrufe
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und der Feed nennt als Absender "anna"
|
||||
|
||||
Szenario: Der Feed meldet das Löschen und die Wiederherstellung
|
||||
Angenommen es existiert ein Dokument "Plan" mit den Zeilen:
|
||||
"""
|
||||
- [ ] Eins
|
||||
"""
|
||||
Und dieses Dokument gelöscht wird
|
||||
Wenn ich die Änderungen seit Version 1 abrufe
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und der Feed meldet das Ereignis "DELETED"
|
||||
Wenn dieses Dokument wiederhergestellt wird
|
||||
Und ich die Änderungen seit Version 2 abrufe
|
||||
Dann erhalte ich für das Diff den Status 200
|
||||
Und der Feed meldet das Ereignis "RESTORED"
|
||||
|
||||
Szenario: Eine gänzlich unbekannte UUID hat keinen Feed
|
||||
Wenn ich die Änderungen eines unbekannten Dokuments abrufe
|
||||
Dann erhalte ich für das Diff den Status 404
|
||||
@@ -6114,3 +6114,49 @@ Zahlen: 104 Tests. Gegenproben je Regel — Prüfsumme nicht geprüft, Idempoten
|
||||
entfernt, veraltete Basis abgelehnt statt verschoben, Schreibpause ignoriert,
|
||||
Rückfall wieder als `RESTORED`, jüngster Stand aus der Historie genommen: Es
|
||||
fallen jeweils genau die danach benannten Zusicherungen.
|
||||
|
||||
**Nachtrag 5 — Long Polling blockiert, aber auf einem virtuellen Thread
|
||||
(2026-08-26).** Der Haupttext sah `DeferredResult` vor, damit ein Wartender
|
||||
keinen Server-Thread bindet. Beim Bauen stellte sich das als teurer heraus,
|
||||
als es klingt: Der Endpunkt steht in der OpenAPI-Spezifikation, und der
|
||||
Generator erzeugt daraus eine **synchrone** Signatur
|
||||
(`ResponseEntity<ChangeFeed>`). Ein `DeferredResult` verlangt eine andere —
|
||||
also entweder die Operation aus der Generierung herausnehmen (dann prüft
|
||||
niemand mehr, ob Vertrag und Code zusammenpassen; genau die Zusage, für die
|
||||
API-First in diesem Projekt gebaut ist) oder den Generator umstellen (WebFlux
|
||||
für alles).
|
||||
|
||||
**Gewählt: blockieren, und `spring.threads.virtual.enabled=true`.** Auf JDK 21
|
||||
kostet ein wartender virtueller Thread praktisch nichts — kein Stack von einem
|
||||
Megabyte, keine Poolgrenze. Das Argument gegen das Blockieren war der
|
||||
Speicher, und der ist auf der Zielumgebung tatsächlich die knappe Größe
|
||||
(D76-Nachtrag 3); genau dort löst der virtuelle Thread es auf, statt es zu
|
||||
verschieben. Der Endpunkt behält die generierte Signatur, und für ihn gilt
|
||||
dieselbe Regel wie für alle anderen: Weicht die Implementierung vom Vertrag
|
||||
ab, bricht der Compile.
|
||||
|
||||
**Zwei Dinge, die daran hängen und leicht zu übersehen sind.** Erstens darf
|
||||
das Warten nicht mit `synchronized`/`wait()` gebaut sein — ein Monitor nagelt
|
||||
den virtuellen Thread an seinen Träger (JDK 21). Der `ChangeNotifier` benutzt
|
||||
deshalb `ReentrantLock`/`Condition`. Zweitens darf **während** des Wartens
|
||||
keine Transaktion und keine Datenbankverbindung offen sein; der
|
||||
`LiveEditingService` ist ohnehin nicht transaktional und liest über je eigene
|
||||
Aufrufe.
|
||||
|
||||
**Geweckt wird nach dem Commit, nicht davor** (`afterCommit` der
|
||||
Transaktions-Synchronisation). Davor geweckt läse ein Beobachter einen Stand,
|
||||
der noch nicht steht — und bekäme das Ereignis nie wieder, denn er zieht
|
||||
danach mit der neuen Version weiter.
|
||||
|
||||
**Der Stempel statt der Versionsnummer.** Der Aufrufer liest den Stempel des
|
||||
Dokuments, **bevor** er in der Datenbank nachsieht. Ändert sich etwas in der
|
||||
Lücke dazwischen, kehrt das Warten sofort zurück. Ohne diesen Griff ginge das
|
||||
Signal verloren und der Client bekäme seine Änderung erst nach Ablauf der
|
||||
vollen Wartezeit — ein Fehler, der im Test nur auffällt, wenn man die **Dauer**
|
||||
misst. Das Cucumber-Szenario tut das (< 4 s bei 5 s Wartezeit); gegengeprüft
|
||||
durch Entfernen der Benachrichtigung, dann fällt genau dieses Szenario.
|
||||
|
||||
**`RENAMED` ist noch nicht vergeben.** Der Feed kennt `CREATED`, `UPDATED`,
|
||||
`DELETED`, `RESTORED` und `ROLLED_BACK`. Das Umbenennen bekommt seinen eigenen
|
||||
Weg (`PATCH /title` mit `expectedVersion`) und erst damit den Typ — ihn vorher
|
||||
zu deklarieren wäre eine Zusage ohne Deckung.
|
||||
|
||||
Reference in New Issue
Block a user