LiveEditingService.kt
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
/**
* Die eingereichte Änderung ist ungültig oder überschreitet eine
* serverseitige Grenze (400). Ohne solche Grenzen ist ein einzelner Request
* ein Ausfall-Vektor – auch versehentlich, durch einen Client-Bug.
*/
class InvalidPatchException(message: String) : RuntimeException(message)
/**
* Das Live-Editing: Zeilen-Diffs einreichen (D76).
*
* Bewusst **ohne** `@Transactional`. Die Änderung eines Dokuments muss strikt
* sequenziell laufen – prüfen, rebasen und anwenden gehören zusammen –, und
* die Sperre dafür liegt **außerhalb** der Transaktion: Läge sie innen, gäbe
* der Proxy sie vor dem Commit wieder frei, und der nächste Schreiber läse
* einen Stand, der noch nicht steht. Geschrieben wird deshalb über die
* transaktionalen Methoden von [DocumentService].
*/
@Service
class LiveEditingService(
private val documents: DocumentService,
private val history: DocumentHistoryRepository,
private val properties: LiveEditingProperties,
private val notifier: ChangeNotifier,
) {
/**
* Feste Zahl von Sperren, verteilt über die UUID. Zwei Dokumente können
* sich eine teilen – das kostet nur Zeit, nie Richtigkeit – und die Menge
* wächst nie: eine Sperre je Dokument müsste beim Löschen aufgeräumt
* werden und wäre sonst ein langsames Leck.
*/
private val stripes = Array(64) { ReentrantLock() }
/** Wiederholte Einreichungen erkennen (Idempotenz). */
private val patchLog = PatchLog()
fun patchContent(documentId: UUID, patch: ContentPatch): ContentPatchOutcome =
lockFor(documentId).withLock { applyPatch(documentId, patch) }
/**
* Umbenennen unter derselben Sperre wie die Inhalts-Patches (D85): Auch
* der Titel bumpt die Version, und prüfen und schreiben gehören zusammen.
* Titel-Regeln liegen hier (400), die Versionsprüfung im [DocumentService].
*/
fun renameDocument(documentId: UUID, title: String, expectedVersion: Long) =
lockFor(documentId).withLock {
val bereinigt = title.trim()
if (bereinigt.isEmpty()) throw InvalidPatchException("Titel darf nicht leer sein")
if (bereinigt.length > 255) {
throw InvalidPatchException("Titel zu lang: ${bereinigt.length} (erlaubt: 255)")
}
documents.rename(documentId, bereinigt, expectedVersion)
}
private fun applyPatch(documentId: UUID, patch: ContentPatch): ContentPatchOutcome {
if (patch.ops.size > properties.maxOps) {
throw InvalidPatchException(
"Zu viele Operationen: ${patch.ops.size} (erlaubt: ${properties.maxOps})"
)
}
patchLog.outcomeOf(documentId, patch.author.clientId, patch.seq)?.let { return it }
val current = documents.findByIdOrNull(documentId)
?: if (history.exists(documentId)) throw DocumentDeletedException(documentId)
else throw DocumentNotFoundException(documentId)
val baseContent = baseContentOf(documentId, current.content, current.version, patch.baseVersion)
if (LineDiff.checksum(baseContent) != patch.checksum) {
throw DiffNotApplicableException(
"Prüfsumme passt nicht zur Basisversion ${patch.baseVersion} – " +
"beide Seiten lesen denselben Stand verschieden"
)
}
// Veraltete Basis: selbst rebasen, statt abzulehnen. Reines Ablehnen
// führte zu Starvation - ein Client mit hoher Latenz käme bei
// fleißigen Mitschreibern womöglich nie durch.
val opsSinceBase =
if (patch.baseVersion == current.version) emptyList()
else LineDiff.compute(LineDiff.lines(baseContent), LineDiff.lines(current.content))
val ops = LineDiff.rebase(patch.ops, opsSinceBase)
?: throw ContentConflictException(current.version, opsSinceBase)
val newContent = LineDiff.text(LineDiff.apply(LineDiff.lines(current.content), ops))
if (newContent.length > properties.maxContentLength) {
throw InvalidPatchException(
"Dokument würde ${newContent.length} Zeichen lang " +
"(erlaubt: ${properties.maxContentLength})"
)
}
val updated = documents.update(
id = documentId,
title = current.title,
content = newContent,
milestone = patch.milestone,
author = patch.author,
)
return ContentPatchOutcome(updated.version, opsSinceBase)
.also { patchLog.record(documentId, patch.author.clientId, patch.seq, it) }
}
/**
* Der Text, gegen den das Diff gebildet wurde. Der Normalfall ist die
* aktuelle Version; sonst der Snapshot aus der Historie. Ist der bereits
* verdichtet, lässt sich nicht mehr rebasen – dann bleibt nur einmal neu
* laden (422), und das ist ehrlicher als ein geratenes Diff.
*/
private fun baseContentOf(
documentId: UUID,
currentContent: String,
currentVersion: Long,
baseVersion: Long,
): String = when {
baseVersion == currentVersion -> currentContent
baseVersion > currentVersion -> throw DiffNotApplicableException(
"Basisversion $baseVersion liegt vor der aktuellen Version $currentVersion"
)
else -> history.findVersion(documentId, baseVersion)?.content
?: throw DiffNotApplicableException(
"Basisversion $baseVersion ist nicht mehr verfügbar (verdichtet)"
)
}
// -----------------------------------------------------------------------
// Ä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,
title = title.takeIf { changeType == de.werkbaum.domain.ChangeType.RENAMED },
)
private fun lockFor(documentId: UUID): ReentrantLock =
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
}