feat(backend): PATCH /content — Diffs einreichen, rebasen, wiederholen (Schritt 3)
Der Server rebased selbst: Ist die Basis veraltet, ueberschneiden sich die Operationen aber nicht mit den zwischenzeitlichen, verschiebt er sie und akzeptiert. Reines Ablehnen fuehrte zu Starvation — ein Client mit hoher Latenz kaeme bei fleissigen Mitschreibern womoeglich nie durch. 409 gibt es nur bei echter Ueberschneidung, mit allem, was der Client zum Weiterarbeiten braucht, ohne neu zu laden. Pruefsumme ist Pflicht (422 bei Abweichung): Die Versionsnummer bestaetigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich lesen. clientId + seq machen den Aufruf wiederholbar — im Mobilnetz ist die verlorene Antwort der Normalfall. Die Sperre je Dokument liegt ausserhalb der Transaktion: innen gaebe der Proxy sie vor dem Commit frei, und der naechste Schreiber laese einen Stand, der noch nicht steht. Deshalb ist LiveEditingService nicht transaktional und schreibt ueber DocumentService. Was das Konzept offenliess, ist jetzt entschieden und in D76-Nachtrag 4 begruendet: die Randfaelle der Einfuege-Ueberschneidung, die Trennung von 400 und 422, die gedeckelte Idempotenz im Speicher. 104 Tests, davon 8 Cucumber-Szenarien fuer das Live-Editing. Gegenprobe: Pruefsumme nicht geprueft, Idempotenz entfernt, veraltete Basis abgelehnt statt verschoben -> es fallen jeweils genau die danach benannten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5cdff6058
commit
ae85503b79
@@ -2,13 +2,18 @@ package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.generated.api.DocumentsApi
|
||||
import de.werkbaum.generated.model.Document as ApiDocument
|
||||
import de.werkbaum.generated.model.ContentPatchRequest
|
||||
import de.werkbaum.generated.model.ContentPatchResult
|
||||
import de.werkbaum.generated.model.DocumentCreateRequest
|
||||
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.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.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
@@ -24,6 +29,7 @@ import java.util.UUID
|
||||
@RequestMapping("/api/v1")
|
||||
class DocumentsController(
|
||||
private val service: DocumentService,
|
||||
private val liveEditing: LiveEditingService,
|
||||
) : DocumentsApi {
|
||||
|
||||
override fun listDocuments(): ResponseEntity<List<ApiDocument>> =
|
||||
@@ -57,6 +63,32 @@ class DocumentsController(
|
||||
return ResponseEntity.noContent().build()
|
||||
}
|
||||
|
||||
override fun patchDocumentContent(
|
||||
documentId: UUID,
|
||||
contentPatchRequest: ContentPatchRequest,
|
||||
): ResponseEntity<ContentPatchResult> {
|
||||
val outcome = liveEditing.patchContent(
|
||||
documentId,
|
||||
ContentPatch(
|
||||
baseVersion = contentPatchRequest.baseVersion,
|
||||
checksum = contentPatchRequest.checksum,
|
||||
author = ChangeAuthor(
|
||||
clientId = contentPatchRequest.clientId,
|
||||
displayName = contentPatchRequest.displayName,
|
||||
),
|
||||
seq = contentPatchRequest.seq,
|
||||
ops = contentPatchRequest.ops.map { it.toDomain() },
|
||||
milestone = contentPatchRequest.milestone ?: false,
|
||||
),
|
||||
)
|
||||
return ResponseEntity.ok(
|
||||
ContentPatchResult(
|
||||
version = outcome.version,
|
||||
opsSinceBase = outcome.opsSinceBase.toApi(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun getDocumentHistory(documentId: UUID): ResponseEntity<List<ApiHistoryEntry>> =
|
||||
ResponseEntity.ok(service.history(documentId).map { it.toApi() })
|
||||
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.generated.model.ContentConflict
|
||||
import de.werkbaum.service.ContentConflictException
|
||||
import de.werkbaum.service.DocumentConflictException
|
||||
import de.werkbaum.service.DocumentDeletedException
|
||||
import de.werkbaum.service.DocumentNotFoundException
|
||||
import de.werkbaum.service.InvalidPatchException
|
||||
import de.werkbaum.service.StalePatchSequenceException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ProblemDetail
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
/**
|
||||
* Zentrale Fehlerbehandlung im Problem-Details-Format (RFC 9457),
|
||||
* passend zum ProblemDetail-Schema der OpenAPI-Spezifikation.
|
||||
*
|
||||
* Eine Ausnahme davon ist der Überschneidungs-Konflikt des Live-Editings: Er
|
||||
* ist kein bloßer Fehlertext, sondern trägt die Daten mit, die der Client zum
|
||||
* Weiterarbeiten braucht – und hat deshalb ein eigenes Schema.
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
@@ -20,9 +31,48 @@ class GlobalExceptionHandler {
|
||||
title = "Dokument nicht gefunden"
|
||||
}
|
||||
|
||||
@ExceptionHandler(DocumentDeletedException::class)
|
||||
fun handleDeleted(ex: DocumentDeletedException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.message ?: "Gelöscht").apply {
|
||||
title = "Dokument gelöscht"
|
||||
}
|
||||
|
||||
@ExceptionHandler(DocumentConflictException::class)
|
||||
fun handleConflict(ex: DocumentConflictException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.message ?: "Konflikt").apply {
|
||||
title = "Konflikt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Echte Überschneidung: 409 mit aktueller Version und dem Diff von der
|
||||
* eingereichten Basis dorthin – damit der Client entscheiden kann, ohne
|
||||
* neu zu laden.
|
||||
*/
|
||||
@ExceptionHandler(ContentConflictException::class)
|
||||
fun handleContentConflict(ex: ContentConflictException): ResponseEntity<ContentConflict> =
|
||||
ResponseEntity.status(HttpStatus.CONFLICT).body(
|
||||
ContentConflict(
|
||||
currentVersion = ex.currentVersion,
|
||||
opsSinceBase = ex.opsSinceBase.toApi(),
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* Nicht anwendbar (422): Prüfsumme, Index oder eine Basisversion, die es
|
||||
* nicht mehr gibt. Der Client lädt einmalig neu – das ist der Preis
|
||||
* dafür, dass der Text nie kaputtgeht.
|
||||
*/
|
||||
@ExceptionHandler(DiffNotApplicableException::class, StalePatchSequenceException::class)
|
||||
fun handleNotApplicable(ex: RuntimeException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
ex.message ?: "Diff nicht anwendbar",
|
||||
).apply { title = "Diff nicht anwendbar" }
|
||||
|
||||
@ExceptionHandler(InvalidPatchException::class)
|
||||
fun handleInvalidPatch(ex: InvalidPatchException): ProblemDetail =
|
||||
ProblemDetail.forStatusAndDetail(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
ex.message ?: "Ungültige Anfrage",
|
||||
).apply { title = "Ungültige Anfrage" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.werkbaum.api
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
import de.werkbaum.generated.model.LineOperation
|
||||
import de.werkbaum.service.InvalidPatchException
|
||||
|
||||
/**
|
||||
* Übersetzt zwischen dem generierten API-Modell und dem internen Diff-Modell.
|
||||
*
|
||||
* Das API-Modell hat ein Feld je Form (`count`, `lines`, beide optional), das
|
||||
* interne ist eine versiegelte Hierarchie – dort kann eine Operation gar nicht
|
||||
* erst halb ausgefüllt sein. Genau dafür ist die Trennung da.
|
||||
*/
|
||||
internal fun LineOperation.toDomain(): LineOp = when (op) {
|
||||
LineOperation.Op.INSERT -> LineOp.Insert(index, lines.orEmpty())
|
||||
LineOperation.Op.DELETE -> LineOp.Delete(index, requiredCount())
|
||||
LineOperation.Op.REPLACE -> LineOp.Replace(index, requiredCount(), lines.orEmpty())
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein fehlendes `count` bei `delete`/`replace` wird **nicht** als 0 gelesen:
|
||||
* Die Operation täte dann stillschweigend nichts bzw. würde zur Einfügung.
|
||||
* Lieber der laute Fehler (dieselbe Haltung wie in SPEC §4).
|
||||
*/
|
||||
private fun LineOperation.requiredCount(): Int =
|
||||
count ?: throw InvalidPatchException(
|
||||
"Operation '${op.value}' bei Index $index ohne 'count'"
|
||||
)
|
||||
|
||||
internal fun LineOp.toApi(): LineOperation = when (this) {
|
||||
is LineOp.Insert -> LineOperation(LineOperation.Op.INSERT, index, lines = lines)
|
||||
is LineOp.Delete -> LineOperation(LineOperation.Op.DELETE, index, count = count)
|
||||
is LineOp.Replace ->
|
||||
LineOperation(LineOperation.Op.REPLACE, index, count = count, lines = lines)
|
||||
}
|
||||
|
||||
internal fun List<LineOp>.toApi(): List<LineOperation> = map { it.toApi() }
|
||||
@@ -0,0 +1,46 @@
|
||||
package de.werkbaum.domain
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
|
||||
/**
|
||||
* Wer eine Änderung eingereicht hat. Pseudonym: [clientId] ist eine zufällige
|
||||
* Kennung, [displayName] ein selbstgewählter Name.
|
||||
*
|
||||
* Ohne Anmeldung ist der Name eine **Behauptung** und darf in der Oberfläche
|
||||
* nicht wie ein Nachweis aussehen (D76, Etherpad-Modell). Er trägt trotzdem
|
||||
* vier Dinge: Wiedererkennung beim Retry, „geändert von" in der Historie, die
|
||||
* Reihenfolge bei gleichzeitigen Einfügungen und später die Präsenz.
|
||||
*/
|
||||
data class ChangeAuthor(
|
||||
val clientId: String,
|
||||
val displayName: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Eine eingereichte Änderung: ein Zeilen-Diff gegen [baseVersion].
|
||||
*
|
||||
* [checksum] ist Pflicht – die Versionsnummer bestätigt nur, dass die Basis
|
||||
* dieselbe *Version* ist, nicht dass beide Seiten sie *gleich lesen*.
|
||||
* [clientId] und [seq] machen das Einreichen wiederholbar: Geht die Antwort
|
||||
* unterwegs verloren, weiß der Client nicht, ob seine Änderung ankam.
|
||||
*/
|
||||
data class ContentPatch(
|
||||
val baseVersion: Long,
|
||||
val checksum: String,
|
||||
val author: ChangeAuthor,
|
||||
val seq: Long,
|
||||
val ops: List<LineOp>,
|
||||
val milestone: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Ergebnis einer angenommenen Änderung.
|
||||
*
|
||||
* [opsSinceBase] ist leer, wenn die Basis noch aktuell war; sonst stehen dort
|
||||
* die fremden Operationen, um die der Server verschoben hat – damit der Client
|
||||
* seine Schattenkopie nachzieht, ohne neu zu laden.
|
||||
*/
|
||||
data class ContentPatchOutcome(
|
||||
val version: Long,
|
||||
val opsSinceBase: List<LineOp>,
|
||||
)
|
||||
@@ -39,4 +39,6 @@ data class DocumentHistoryEntry(
|
||||
val changeType: ChangeType,
|
||||
val timestamp: OffsetDateTime,
|
||||
val milestone: Boolean = true,
|
||||
/** Wer die Änderung eingereicht hat – „geändert von" (D76); `null` bei den Wegen ohne Identität. */
|
||||
val author: ChangeAuthor? = null,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.werkbaum.persistence
|
||||
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
import jakarta.persistence.Column
|
||||
@@ -42,6 +43,12 @@ class DocumentHistoryEntity(
|
||||
/** Meilenstein (nutzersichtbar, bleibt) oder Sync-Version (wird verdichtet) – D76. */
|
||||
@Column(nullable = false)
|
||||
var milestone: Boolean = true,
|
||||
|
||||
@Column(name = "client_id", length = 64)
|
||||
val clientId: String? = null,
|
||||
|
||||
@Column(name = "display_name", length = 64)
|
||||
val displayName: String? = null,
|
||||
) {
|
||||
fun toDomain() = DocumentHistoryEntry(
|
||||
documentId = documentId,
|
||||
@@ -51,6 +58,7 @@ class DocumentHistoryEntity(
|
||||
changeType = changeType,
|
||||
timestamp = changeTime,
|
||||
milestone = milestone,
|
||||
author = clientId?.let { ChangeAuthor(it, displayName) },
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -62,6 +70,8 @@ class DocumentHistoryEntity(
|
||||
changeType = entry.changeType,
|
||||
changeTime = entry.timestamp,
|
||||
milestone = entry.milestone,
|
||||
clientId = entry.author?.clientId,
|
||||
displayName = entry.author?.displayName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.LineOp
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Echte Überschneidung mit einer zwischenzeitlichen Änderung (409).
|
||||
*
|
||||
* Trägt alles mit, was der Client zum Weiterarbeiten braucht – ohne neu zu
|
||||
* laden: die aktuelle Version und das Diff von seiner Basis dorthin. Er zeigt
|
||||
* dann zwei Knöpfe (fremde Fassung übernehmen / eigene durchsetzen); an dieser
|
||||
* Stelle gewinnt einer vollständig, aber nichts geht endgültig verloren, denn
|
||||
* jede Version steht in der Historie.
|
||||
*/
|
||||
class ContentConflictException(
|
||||
val currentVersion: Long,
|
||||
val opsSinceBase: List<LineOp>,
|
||||
) : RuntimeException("Überschneidung mit Version $currentVersion")
|
||||
|
||||
/**
|
||||
* Das Dokument ist gelöscht, seine Historie gibt es noch (404 mit Hinweis auf
|
||||
* die Wiederherstellung).
|
||||
*/
|
||||
class DocumentDeletedException(val id: UUID) :
|
||||
RuntimeException(
|
||||
"Dokument $id wurde gelöscht; es lässt sich über " +
|
||||
"POST /api/v1/documents/$id/restore wiederherstellen"
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.domain.ChangeAuthor
|
||||
import de.werkbaum.domain.ChangeType
|
||||
import de.werkbaum.domain.Document
|
||||
import de.werkbaum.domain.DocumentHistoryEntry
|
||||
@@ -24,7 +25,10 @@ class DocumentService(
|
||||
fun findAll(): List<Document> = repository.findAll()
|
||||
|
||||
fun findById(id: UUID): Document =
|
||||
repository.findById(id) ?: throw DocumentNotFoundException(id)
|
||||
findByIdOrNull(id) ?: throw DocumentNotFoundException(id)
|
||||
|
||||
/** Wie [findById], nur ohne Ausnahme – wenn der Aufrufer selbst unterscheiden will. */
|
||||
fun findByIdOrNull(id: UUID): Document? = repository.findById(id)
|
||||
|
||||
fun create(title: String, content: String): Document {
|
||||
val now = OffsetDateTime.now(clock)
|
||||
@@ -50,7 +54,13 @@ class DocumentService(
|
||||
* ist dagegen eine bewusste Handlung (Import, Reparatur) und bleibt
|
||||
* Meilenstein.
|
||||
*/
|
||||
fun update(id: UUID, title: String, content: String, milestone: Boolean = true): Document {
|
||||
fun update(
|
||||
id: UUID,
|
||||
title: String,
|
||||
content: String,
|
||||
milestone: Boolean = true,
|
||||
author: ChangeAuthor? = null,
|
||||
): Document {
|
||||
val existing = findById(id)
|
||||
val updated = existing.copy(
|
||||
title = title,
|
||||
@@ -59,7 +69,7 @@ class DocumentService(
|
||||
updatedAt = OffsetDateTime.now(clock),
|
||||
)
|
||||
repository.save(updated)
|
||||
recordHistory(updated, ChangeType.UPDATED, milestone)
|
||||
recordHistory(updated, ChangeType.UPDATED, milestone, author)
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -167,6 +177,7 @@ class DocumentService(
|
||||
document: Document,
|
||||
changeType: ChangeType,
|
||||
milestone: Boolean = true,
|
||||
author: ChangeAuthor? = null,
|
||||
) {
|
||||
val previous = historyRepository.findLatest(document.id)
|
||||
if (previous != null && !previous.milestone &&
|
||||
@@ -184,6 +195,7 @@ class DocumentService(
|
||||
changeType = changeType,
|
||||
timestamp = document.updatedAt,
|
||||
milestone = milestone || changeType.isStructural,
|
||||
author = author,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -22,4 +22,13 @@ data class LiveEditingProperties(
|
||||
* ein so altes `since` mit Volltext statt mit einem Diff.
|
||||
*/
|
||||
val syncRetention: Duration = Duration.ofHours(1),
|
||||
|
||||
/**
|
||||
* Höchstzahl der Operationen je Anfrage. Ohne Grenze ist ein einzelner
|
||||
* Request ein Ausfall-Vektor – auch versehentlich, durch einen Client-Bug.
|
||||
*/
|
||||
val maxOps: Int = 1_000,
|
||||
|
||||
/** Höchstlänge des Dokuments in Zeichen; der mitgelieferte Plan hat ~40 000. */
|
||||
val maxContentLength: Int = 2_000_000,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.diff.DiffNotApplicableException
|
||||
import de.werkbaum.diff.LineDiff
|
||||
import de.werkbaum.domain.ContentPatch
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import de.werkbaum.repository.DocumentHistoryRepository
|
||||
import org.springframework.stereotype.Service
|
||||
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,
|
||||
) {
|
||||
|
||||
/**
|
||||
* 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) }
|
||||
|
||||
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)"
|
||||
)
|
||||
}
|
||||
|
||||
private fun lockFor(documentId: UUID): ReentrantLock =
|
||||
stripes[Math.floorMod(documentId.hashCode(), stripes.size)]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.werkbaum.service
|
||||
|
||||
import de.werkbaum.domain.ContentPatchOutcome
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Merkt sich je (Dokument, Client) die zuletzt verarbeitete Sequenznummer samt
|
||||
* Ergebnis – damit ein wiederholtes Einreichen nicht ein zweites Mal wirkt.
|
||||
*
|
||||
* Geht die Antwort unterwegs verloren – im Mobilnetz der Normalfall –, weiß
|
||||
* der Client nicht, ob seine Änderung ankam (D76). Kurzlebig und im Speicher:
|
||||
* Das Fenster ist Sekunden lang, und eine Einzelinstanz ist ohnehin
|
||||
* vorausgesetzt. Die Zahl der gemerkten Paare ist gedeckelt; verdrängt wird
|
||||
* das am längsten nicht benutzte.
|
||||
*
|
||||
* Alle Methoden sind synchronisiert: Aufrufe kommen aus verschiedenen
|
||||
* Dokument-Sperren und damit echt nebenläufig.
|
||||
*/
|
||||
class PatchLog(private val capacity: Int = 1_024) {
|
||||
|
||||
private data class Seen(val seq: Long, val outcome: ContentPatchOutcome)
|
||||
|
||||
private val entries = object : LinkedHashMap<Pair<UUID, String>, Seen>(64, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: Map.Entry<Pair<UUID, String>, Seen>) =
|
||||
size > capacity
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` heißt: neu, bitte anwenden. Ein Ergebnis heißt: schon erledigt,
|
||||
* das war die Antwort. Eine **kleinere** Sequenznummer als die zuletzt
|
||||
* verarbeitete ist ein Client-Fehler – das Ergebnis von damals ist nicht
|
||||
* mehr bekannt, und ein zweites Anwenden verdürbe den Text.
|
||||
*/
|
||||
@Synchronized
|
||||
fun outcomeOf(documentId: UUID, clientId: String, seq: Long): ContentPatchOutcome? {
|
||||
val seen = entries[documentId to clientId] ?: return null
|
||||
return when {
|
||||
seq > seen.seq -> null
|
||||
seq == seen.seq -> seen.outcome
|
||||
else -> throw StalePatchSequenceException(seq, seen.seq)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun record(documentId: UUID, clientId: String, seq: Long, outcome: ContentPatchOutcome) {
|
||||
entries[documentId to clientId] = Seen(seq, outcome)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun size(): Int = entries.size
|
||||
}
|
||||
|
||||
/** Eine ältere Sequenznummer als die zuletzt verarbeitete (422). */
|
||||
class StalePatchSequenceException(seq: Long, lastSeen: Long) :
|
||||
RuntimeException("Sequenznummer $seq ist veraltet (zuletzt verarbeitet: $lastSeen)")
|
||||
@@ -36,3 +36,11 @@ ALTER TABLE document_history ADD COLUMN milestone BOOLEAN DEFAULT TRUE NOT NULL;
|
||||
CREATE INDEX idx_document_history_version ON document_history (document_id, version);
|
||||
--rollback DROP INDEX idx_document_history_version;
|
||||
--rollback ALTER TABLE document_history DROP COLUMN milestone;
|
||||
|
||||
--changeset editor:005-history-author
|
||||
-- "Geaendert von" (D76): pseudonyme Kennung plus selbstgewaehlter Name. Beide
|
||||
-- optional - CRUD ohne Live-Editing kennt keinen Absender.
|
||||
ALTER TABLE document_history ADD COLUMN client_id VARCHAR(64);
|
||||
ALTER TABLE document_history ADD COLUMN display_name VARCHAR(64);
|
||||
--rollback ALTER TABLE document_history DROP COLUMN display_name;
|
||||
--rollback ALTER TABLE document_history DROP COLUMN client_id;
|
||||
|
||||
@@ -183,6 +183,67 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
/documents/{documentId}/content:
|
||||
parameters:
|
||||
- name: documentId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
patch:
|
||||
tags: [Documents]
|
||||
operationId: patchDocumentContent
|
||||
summary: Zeilen-Diff auf den Inhalt anwenden (Live-Editing)
|
||||
description: >
|
||||
Reicht eine Aenderung als Zeilen-Diff gegen eine Basisversion ein.
|
||||
|
||||
|
||||
Ist die Basis veraltet, ueberschneiden sich die Operationen aber nicht
|
||||
mit den zwischenzeitlichen, verschiebt der Server sie selbst und
|
||||
akzeptiert (200); die fremden Operationen stehen dann in
|
||||
`opsSinceBase`, damit der Client seine Schattenkopie nachzieht. Nur
|
||||
bei echter Ueberschneidung antwortet er mit 409.
|
||||
|
||||
|
||||
`clientId` und `seq` machen den Aufruf wiederholbar: Eine Wiederholung
|
||||
liefert das Ergebnis von damals, statt die Aenderung ein zweites Mal
|
||||
anzuwenden.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentPatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Aenderung angenommen (ggf. serverseitig verschoben)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentPatchResult"
|
||||
"400":
|
||||
$ref: "#/components/responses/BadRequest"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"409":
|
||||
description: >
|
||||
Echte Ueberschneidung mit einer zwischenzeitlichen Aenderung. Der
|
||||
Client entscheidet - fremde Fassung uebernehmen oder eigene
|
||||
durchsetzen -, ohne neu zu laden.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ContentConflict"
|
||||
"422":
|
||||
description: >
|
||||
Diff nicht anwendbar: Pruefsummenfehler, Index ausserhalb, oder die
|
||||
Basisversion ist nicht mehr verfuegbar. Der Client laedt einmalig neu.
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ProblemDetail"
|
||||
|
||||
components:
|
||||
responses:
|
||||
NotFound:
|
||||
@@ -293,6 +354,100 @@ components:
|
||||
Optionale Zielversion. Ohne Angabe wird der letzte inhaltliche
|
||||
Stand vor dem Loeschen wiederhergestellt.
|
||||
|
||||
LineOperation:
|
||||
description: >
|
||||
Eine Zeilen-Operation relativ zur Basisversion (0-basierter Index).
|
||||
Operationen sind aufsteigend sortiert und ueberschneiden sich nicht.
|
||||
type: object
|
||||
required: [op, index]
|
||||
properties:
|
||||
op:
|
||||
type: string
|
||||
enum: [replace, insert, delete]
|
||||
index:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
count:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 0
|
||||
description: Zahl der betroffenen Basiszeilen; bei `insert` ohne Bedeutung.
|
||||
lines:
|
||||
type: array
|
||||
description: Einzusetzende Zeilen; bei `delete` ohne Bedeutung.
|
||||
items:
|
||||
type: string
|
||||
|
||||
ContentPatchRequest:
|
||||
type: object
|
||||
required: [baseVersion, checksum, clientId, seq, ops]
|
||||
properties:
|
||||
baseVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Version, gegen die das Diff gebildet wurde.
|
||||
checksum:
|
||||
type: string
|
||||
description: >
|
||||
Pflicht. `sha256:<hex>` des vollstaendigen Basistexts mit
|
||||
LF-Zeilenenden. Die Versionsnummer bestaetigt nur, dass die Basis
|
||||
dieselbe Version ist, nicht dass beide Seiten sie gleich lesen.
|
||||
clientId:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: Zufaellige, pseudonyme Kennung des Clients.
|
||||
seq:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Laufende Nummer dieses Clients; macht den Aufruf wiederholbar.
|
||||
displayName:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: >
|
||||
Selbstgewaehlter Anzeigename. Ohne Anmeldung ist er eine
|
||||
Behauptung und darf in der Oberflaeche nicht wie ein Nachweis
|
||||
aussehen.
|
||||
milestone:
|
||||
type: boolean
|
||||
default: false
|
||||
description: >
|
||||
`true` schreibt einen nutzersichtbaren Stand (Knopfdruck).
|
||||
Der getaktete Strom laesst das Feld weg.
|
||||
ops:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ContentPatchResult:
|
||||
type: object
|
||||
required: [version, opsSinceBase]
|
||||
properties:
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Neue Version des Dokuments.
|
||||
opsSinceBase:
|
||||
type: array
|
||||
description: >
|
||||
Leer, wenn die Basis noch aktuell war. Sonst die fremden
|
||||
Operationen, um die der Server verschoben hat.
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ContentConflict:
|
||||
type: object
|
||||
required: [currentVersion, opsSinceBase]
|
||||
properties:
|
||||
currentVersion:
|
||||
type: integer
|
||||
format: int64
|
||||
opsSinceBase:
|
||||
type: array
|
||||
description: Diff von der eingereichten Basis bis zur aktuellen Version.
|
||||
items:
|
||||
$ref: "#/components/schemas/LineOperation"
|
||||
|
||||
ProblemDetail:
|
||||
type: object
|
||||
description: Fehlerformat nach RFC 9457 (Problem Details)
|
||||
|
||||
Reference in New Issue
Block a user