Files
werkbaum/backend/docs/live-editing-proposal.md
mhoennig 627f6feca8 perf(live): Debounce 600 ms, Sync-Versionen nur noch 5 Minuten (D79)
Gemeldet: ~3 s Verzoegerung zwischen zwei Browsern. Zerlegt statt geraten -
von 1,73 s gemessenem Weg A->B entfallen 1,67 s auf die Wartezeit vor dem
Senden. Alles andere sind zusammen ~70 ms.

Zwei Verdaechtige sind freigesprochen: Der Server weckt den wartenden Feed
39 ms nach dem PATCH (isoliert per curl, ohne Browser), und der Apache der
produktiven Instanz haelt den Long-Poll die vollen 25 s durch und schliesst
sauber mit 204 - kein Fenster ohne offenen Feed, kein 5-Sekunden-Fehlerpfad.
Produktiv kommen ~130 ms Rundlauf je Anfrage dazu.

Der Debounce bleibt ein Debounce (kein Takt): Wer durchtippt, erzeugt
weiterhin keine Version. Der Grund fuer die 1,5 s stammte aus der
Rate-Limit-Disziplin des Etherpad-Konzepts - und Etherpad ist ausgebaut (D78).

Die Aufbewahrung zahlt die haeufigeren Pushes: Jede Version speichert den
ganzen Text, und die Frist entscheidet einzig, ob ein zurueckgefallener Client
ein Diff oder den Volltext bekommt. Nutzersichtbar sind die Meilensteine, und
die werden nie verdichtet; zurueckfallen kann nur ein ruhender Feed
(Hintergrund-Tab). Zusammen sinkt die Spitze je aktiv getipptem Dokument von
115 MB auf 24 MB (49-kB-Plan, Dauertippen).

Dabei gefunden: Eine Schreibpause laenger als die Frist war mit einer Stunde
der Ausnahmefall und ist mit fuenf Minuten der Normalfall. Dass die letzte
Sync-Version davor nicht verlorengeht, haengt allein daran, dass
recordHistory() zuerst befoerdert und danach verdichtet - sonst loeschte die
Verdichtung genau den Stand, den die Befoerderung gleich zum Meilenstein
gemacht haette. Die Reihenfolge hat jetzt eine Zusicherung; vertauscht faellt
genau der danach benannte Test.

Werkzeuggrenze notiert: Der Automatisierungs-Browser zeigt seine Flaeche nicht
an, Chrome drosselt Timer verborgener Seiten auf 1 Hz (gemessen: ein blanker
setTimeout(600) feuert nach 999-1053 ms). Ein Sub-Sekunden-Debounce ist dort
grundsaetzlich nicht messbar.

501 Frontend-Tests, 139 Backend-Tests.
2026-08-26 20:53:02 +02:00

22 KiB
Raw Permalink Blame History

Live-Editing über HTTP (Variante „Simpel")

Status: umgesetzt — alle sechs Schritte der Umsetzungsreihenfolge stehen (Zeilen-Diff, zweistufige Historie, PATCH /content, Änderungsfeed, Master-Passwort, Client). Offen bleiben das Umbenennen per PATCH /title (und damit das Ereignis RENAMED), ein Eingabefeld für den Anzeigenamen und die Präsenz-Anzeige. 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 Nachtrag 4.

Ziel und Rahmenbedingungen

  • Mehrere Clients (Werkbaum-Web-App/PWA) arbeiten am selben Dokument: ca. 10 Beobachter, davon 23 gelegentliche Editoren, praktisch nie in derselben Sekunde.
  • Nur HTTP, kein WebSocket. Wenige, sparsame Requests (Lehre aus den aggressiven Rate-Limits der Etherpad-Integration, D31).
  • Kein Neuladen des Dokuments im Normalbetrieb: Clients erhalten Zeilen-Diffs und wenden sie lokal an, damit Cursor/Scrollposition erhalten bleiben. Ausnahmen sind benannt (Prüfsummenfehler, zu alter Stand).
  • Das Werkbaum-Format ist zeilenorientiert; Zeilen-IDs (#id) sind optional und identifizieren Knoten, nicht Zeilen. Das Protokoll arbeitet deshalb ausschließlich auf physischen Zeilen und braucht keine IDs.
  • Bei echtem Gleichzeitig-Konflikt: Update ablehnen, der Client entscheidet (rebase, neu laden, verwerfen). Das Dokument darf nie kaputtgehen. Der Client erkennt den Konflikt zusätzlich selbst, wenn eine fremde Änderung die Zeilen trifft, an denen gerade getippt wird — der Server kann das nicht sehen, er kennt den ungesendeten Text nicht (D76-Nachtrag 7).

Zugriff und Identität

  • Zugriff über die unerratbare Dokument-UUID, wie ein Pad-Link: kein Login, kein Rechtemodell. Echte Authentifizierung kommt später als Schicht davor; das Protokoll bleibt davon unberührt.
  • GET /documents verlangt ein Master-Passwort (HTTP Basic, Benutzer werkbaum; Hash serverseitig in einer Umgebungsvariable, geprüft über Spring Security). Ohne diesen Schutz wäre jede UUID auflistbar und das Modell hinfällig. Ohne konfigurierten Hash ist die Liste gesperrt, nicht offen — ein vergessener Konfigurationsschritt darf nichts preisgeben. Die Sperre nach Fehlversuchen ist global statt je Adresse (Begründung: D76-Nachtrag 6). Die Übertragung setzt HTTPS voraus.
  • Identität ist pseudonym: Jeder Client führt eine zufällige clientId und einen selbstgewählten Anzeigenamen (Etherpad-Modell). Ohne Anmeldung ist der Name eine Behauptung und darf in der Oberfläche nicht wie ein Nachweis aussehen. Er trägt: Wiedererkennung beim Retry, „geändert von" in der Historie, die Reihenfolge bei gleichzeitigen Einfügungen und spätere Präsenz.

Grundidee in einem Satz

Jede Dokumentänderung ist ein zeilenbasiertes Diff gegen eine Basisversion; der Server wendet es an — notfalls auf eine neuere Version verschoben — und verteilt das Ergebnis über Long Polling an alle Beobachter.

Datenmodell: das Zeilen-Diff

Ein Diff ist eine Liste von Operationen relativ zur Basisversion. Zeilen werden über ihren Index in der Basisversion adressiert (0-basiert); Operationen sind nach Index aufsteigend sortiert und überlappen nicht.

{
  "baseVersion": 41,
  "checksum": "sha256:9f2b…",
  "clientId": "c-8a41…",
  "seq": 17,
  "ops": [
    { "op": "replace", "index": 12, "count": 1,
      "lines": ["  - [~] Backend (L) @ben"] },
    { "op": "insert",  "index": 20,
      "lines": ["  + [?] Dark mode (S)"] },
    { "op": "delete",  "index": 25, "count": 2 }
  ]
}
  • replace: count Zeilen ab index werden durch lines ersetzt.
  • insert: lines werden vor index eingefügt (index == Zeilenanzahl = anhängen).
  • delete: count Zeilen ab index entfallen.

Warum Indizes statt Inhalts-Hashes reichen: Der Server kennt die Basisversion vollständig. Version + Index ist damit eindeutig — auch bei identischen Zeilen (Leerzeilen!).

checksum ist Pflicht (Hash des gesamten Basistexts). Die Versionsnummer bestätigt nur, dass die Basis dieselbe Version ist, nicht dass beide Seiten sie gleich lesen; ein Index-Versatz zerstörte Text sonst unbemerkt. Passt die Prüfsumme nicht, antwortet der Server mit 422, und der Client lädt einmalig komplett neu. Siehe D76, Begründung im Geist von D59 („lieber der laute Fehler").

clientId und seq machen den PATCH wiederholbar. Geht die Antwort unterwegs verloren — im Mobilnetz der Normalfall —, weiß der Client nicht, ob seine Änderung ankam. Der Server merkt sich je Dokument die zuletzt verarbeitete seq pro clientId und beantwortet eine Wiederholung mit dem Ergebnis von damals, statt sie erneut anzuwenden.

Zeilenenden: Der Server normalisiert beim Speichern autoritativ auf LF (SPEC §12); der Client normalisiert beim Laden ebenfalls. Nur so hashen beide Seiten denselben Text.

Werkbaum-Besonderheiten sind mit dem Zeilenmodell automatisch abgedeckt: Fortsetzungszeilen ( \), "-Beschreibungszeilen und der ----Beschreibungsteil sind schlicht physische Zeilen. Halbfertige Zwischenzustände rendert Werkbaum mit Warnung weiter — zeilenweise Updates sind hier risikoarm.

Faltmarken sind gewöhnliche Zeileninhalte. Klappt jemand im Diagramm einen Teilbaum um, schreibt Werkbaum das als >/< in den Text zurück (D38-Nachtrag 2); unter Live-Editing wird daraus ein ganz normaler PATCH, den alle Beobachter sehen. Das ist bewusst so entschieden (D76): Der Text bleibt die eine Quelle der Wahrheit (D14). Auch der Falt-Durchschalter (D75) bekommt keine Sonderbehandlung, obwohl ein Druck den ganzen Baum umbaut.

API-Erweiterung (OpenAPI-Spec)

1. PATCH /documents/{id}/content — Änderung einreichen

Request: das Diff-Objekt oben.

  • 200 OK: akzeptiert. Antwort:

    { "version": 42, "opsSinceBase": [  ] }
    

    opsSinceBase ist leer, wenn die Basis noch aktuell war. War sie es nicht und ließen sich die Ops verschieben (siehe unten), stehen hier die fremden Ops, damit der Client seine Schattenkopie nachzieht.

  • Der Server rebased selbst. Ist baseVersion veraltet, überschneiden sich die eingereichten Ops aber nicht mit den zwischenzeitlichen, verschiebt der Server sie auf die aktuelle Version und akzeptiert. Grund: Reines Ablehnen führt zu Starvation — ein Client mit höherer Latenz kommt bei fleißigen Mitschreibern womöglich nie durch, weil jeder Versuch beim Eintreffen wieder veraltet ist. Genau deshalb hat CodeMirror sein rebaseUpdates nachgerüstet. Nebengewinn: Der Client braucht seine Rebase-Logik nur noch für echte Konflikte.

  • 409 Conflict: nur bei echter Überlappung. Antwort enthält alles, was der Client zum Weiterarbeiten braucht — ohne Neuladen:

    {
      "currentVersion": 43,
      "opsSinceBase": [ ...Diff von baseVersion  currentVersion... ]
    }
    

    Der Client zeigt dann zwei Knöpfe: fremde Fassung übernehmen oder eigene durchsetzen — jeweils nur für die überlappenden Zeilen, alles übrige ist bereits rebased. An dieser Stelle gewinnt einer vollständig, aber nichts geht endgültig verloren: Jede Version steht in der Historie.

  • 404: Dokument gelöscht (Restore-Hinweis in der Problem-Detail-Antwort).

  • 422: Diff nicht anwendbar (Index außerhalb, Prüfsummenfehler, Basisversion bereits verdichtet oder aus der Zukunft, veraltete seq) — Client lädt einmalig neu. Der verdichtete Fall ist kein Client-Bug, aber das Mittel ist dasselbe; ein geratenes Diff wäre schlechter.

Überlappung, genau definiert. Der betroffene Bereich einer Op ist [index, index+count) für replace und delete. Für insert ist er ein Punkt bei index — nicht ein leeres Intervall, sonst überschnitte er sich mit nichts und Einfüge-Konflikte blieben unerkannt. Der Punkt liegt zwischen den Zeilen und kollidiert nur mit dem Inneren eines fremden Bereichs (start < index < end). Daraus folgt:

  • Zwei Einfügungen an derselben Stelle sind kein Konflikt. Beide Zeilen bleiben; die bereits bestätigte fremde steht oben.
  • Eine Einfügung in einen Bereich, den ein anderer löscht, ist einer — die neue Zeile landete sonst in einem Abschnitt, den es nicht mehr gibt.
  • An den Rändern ist sie dagegen keiner: vor bzw. hinter dem fremden Block ist die Stelle eindeutig. Das ist der häufige Fall — wer eine Zeile über einer gerade geänderten einfügt, bekommt keinen 409.

Titel: PATCH /documents/{id}/title (mit expectedVersion) ändert den Titel; er ist ein Metadatum, kein Zeileninhalt. PUT /documents/{id} bleibt als „Ganzdokument ersetzen" bestehen (Import, Reparatur) und wertet künftig expectedVersion aus.

Grenzen: Dokumentgröße und Op-Anzahl je Request sind serverseitig begrenzt (sonst ist ein einzelner Request ein Ausfall-Vektor, auch versehentlich durch einen Client-Bug); Überschreitung und ein delete/ replace ohne count sind 400. Stellschrauben: werkbaum.live-editing.max-ops (1000) und max-content-length (2 Mio. Zeichen; der mitgelieferte Plan hat ~40 000).

2. GET /documents/{id}/changes?since={version}&wait={seconds} — Änderungsfeed

Long Polling, der Kern der „Echtzeit ohne WebSocket"-Lösung.

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. Solange es Historieneinträge zur UUID gibt, antwortet der Feed also; 404 nur bei gänzlich unbekannter UUID (dieselbe Regel, die history() schon anwendet). Wartende Long-Polls müssen beim Löschen zugestellt bekommen, bevor die Warteliste verworfen wird.

  • Gibt es Änderungen nach since: sofort 200 mit

    {
      "fromVersion": 41,
      "currentVersion": 43,
      "ops": [ ...kumuliertes Diff 41  43... ],
      "events": [
        { "version": 43, "changeType": "UPDATED",
          "clientId": "c-8a41…", "displayName": "Anna" }
      ]
    }
    
  • Ist since bereits verdichtet (siehe „Zwei Ebenen" unten), kann der Server kein exaktes Diff mehr liefern. Dann enthält die Antwort statt ops den Volltext:

    { "fromVersion": null, "currentVersion": 87, "content": "…", "events": [  ] }
    

    Der Client ersetzt seinen Stand. Ein Roundtrip und ein Sonderzustand weniger als ein eigener Fehlerpfad — und der Cursor-Erhalt ist über hunderte Versionen hinweg ohnehin nicht zu retten. Deckt zugleich den PWA-Fall nach längerer Offline-Zeit ab.

  • Sonst hält der Server die Anfrage bis zu wait Sekunden offen. Kommt in der Zeit eine Änderung, antwortet er sofort; sonst 204 No Content, und der Client pollt erneut. Der wait-Wert wird serverseitig geklemmt — ein Client darf keine beliebig lange Verbindung binden. Die Obergrenze steht erst fest, wenn die Zielumgebung vermessen ist (siehe „Betrieb").

  • Die Antwort trägt Cache-Control: no-store; ein Proxy dürfte sonst eine 204 zwischenspeichern und der Feed stünde still.

  • Latenz: praktisch sofort. Kosten: 1 offene HTTP-Anfrage pro Beobachter, ~2,4 Requests/Minute im Leerlauf — rate-limit-freundlich, PWA-tauglich, kein WebSocket nötig.

Ä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.
  • ROLLED_BACK ist der Rückfall eines lebenden Dokuments auf eine alte Version (restore mit targetVersion). Für den Client ist das ein gewöhnlicher Inhaltswechsel; er hatte nie eine Sperre. Bisher trugen beide Fälle denselben Typ — ein Typ, der zwei Dinge bedeutet, ist die Unschärfe, aus der später Fehler werden.

Client-Schleife eines Beobachters:

loop:
  antwort = GET /changes?since=meineVersion&wait=25
  wenn 200 mit ops:     ops lokal anwenden, meineVersion = currentVersion
  wenn 200 mit content: Stand ersetzen, meineVersion = currentVersion
  wenn 204:             weiter

Wichtig für den Client: Eine gepufferte Feed-Antwort darf nur angewendet werden, wenn ihr fromVersion zur aktuellen Schattenkopie passt. Sonst wendet er dieselben Ops doppelt an — der Fall tritt ein, wenn Feed und 409-Antwort beide dasselbe fremde Diff liefern.

Warum Long Polling und nicht SSE?

Server-Sent Events wären die Alternative (eine dauerhafte Verbindung, Push vom Server). Long Polling gewinnt hier, weil es (a) reines Request/Response-HTTP ist — testbar mit unseren Cucumber-Tests, (b) keinerlei Sonderbehandlung in Proxies/PWA-Service-Workern braucht und (c) bei 10 Beobachtern der Effizienzunterschied irrelevant ist. Ein späterer Umstieg auf SSE oder WebSocket ändert nur den Feed-Endpunkt; Diff-Format und Konfliktlogik bleiben identisch.

Serverseitige Umsetzung

  • Diff anwenden: Snapshot der Basisversion laden (bzw. aktueller Stand, wenn baseVersion == currentVersion, der Normalfall), Prüfsumme vergleichen, Ops anwenden, als neue Version speichern.
  • Diff berechnen (für 409-Antwort und Feed): Zeilen-Diff zwischen zwei 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: 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.

Historie in zwei Ebenen

Ohne Trennung wird die Historie zum Transaktionslog: hunderte Volltext-Snapshots eines 50-kB-Dokuments je Sitzung — bei 0,6 s Debounce (D79) bis zu 100 je Minute Tippen. Getrennt werden deshalb:

  • Sync-Versionen — tragen das Protokoll (Diffs zwischen beliebigen Versionen), kurzlebig, werden nach einer Weile verdichtet. Danach beantwortet der Feed betroffene since-Werte mit Volltext (oben).
  • Meilensteine — die nutzersichtbare Historie. Sie entstehen nach einer Schreibpause und auf Knopfdruck; dasselbe Muster wie die „Früheren Stände" im Editor (D54), erprobt und den Nutzern vertraut.

DocumentHistoryRepository braucht dafür gezielten Zugriff auf eine einzelne Version statt wie heute stets alle Einträge zu laden (findByDocumentId liefert alles, restore/history filtern in Kotlin darüber — bei hunderten Versionen je Dokument untragbar).

Betrieb — Zielumgebung vermessen (2026-08-26)

Gemessen auf mih00.hostsharing.net, wo die stabile Instanz werkbaum.javagil.de liegt (D43):

Befund Wert Bedeutung für Long Polling
Apache 2.4.68 (Debian 12)
MPM event Kein Prozess je Verbindung; die Sorge aus dem ersten Entwurf entfällt
MaxRequestWorkers 1024 (ServerLimit 64 × 32) 10 offene Polls sind unkritisch
Timeout 300 s wait=25 liegt weit darunter
ProxyTimeout nicht gesetzt → 300 s dito
mod_proxy_http, mod_rewrite geladen Reverse Proxy technisch möglich
RewriteRule … [P] in .htaccess erlaubt (gemessen) So kommt der Request zum Backend
Long-Poll durch Apache 30 s gehalten (gemessen) wait=25 trägt dort nachweislich
HTTP/2 nicht angeboten siehe unten
Java nur 17 Backend verlangt 21
systemd Linger yes Ein eigener Dienst darf dauerhaft laufen
PostgreSQL lauscht auf 5432 Der in application.yaml angedachte Umstieg wäre möglich
Speicher 3,9 GB im Host, davon ~300 MB frei -Xmx ist Pflicht, siehe unten
Webspace-Grenze MemoryMax=3147M (cgroup) Erlaubnis, keine Reservierung

Long Polling trägt dort. Die Zeitgrenzen sind großzügig, und der Worker-Pool ist groß genug. Pufferung ist bei Long Polling ohnehin kein Thema — anders als bei SSE kommt genau eine Antwort am Ende des Wartens, kein Strom von Teilstücken.

Der Speicher ist die knappe Größe, nicht die Verbindungen. Der Host hat 3,9 GB und ist überbucht (Committed_AS 12,0 GB gegen CommitLimit 5,9 GB); frei sind rund 300 MB, ein weiteres GB liegt bereits im Swap. Unser Webspace darf laut cgroup 3147 MB nehmen — das ist aber eine Erlaubnis, kein reservierter Speicher. Die JVM braucht deshalb ein ausdrückliches -Xmx, bemessen am tatsächlich Freien: Ihre Voreinstellung von ¼ des physischen RAM (≈ 980 MB) ist genau die Größenordnung, die auf diesem Host schon MariaDBs Puffer in den Swap gedrängt hat. Zahlen und Herleitung: D76-Nachtrag 3.

Der einzige echte Einwand ist das fehlende HTTP/2. Damit gilt im Browser das Limit von sechs Verbindungen je Herkunft. Ein Long-Poll belegt eine davon; hat jemand denselben Plan in drei Tabs offen, sind drei Verbindungen dauerhaft gebunden und die übrigen Requests drängen sich in den Rest. Zu beheben wäre es serverseitig (HTTP/2 aktivieren) oder clientseitig, indem sich mehrere Tabs über einen SharedWorker eine Feed-Verbindung teilen.

Der Weg zum Backend steht: RewriteRule … [P] in der .htaccess. ProxyPass ist dort nicht zulässig und ~/doms/<domain>/etc/ ist leer — aber das P-Flag von mod_rewrite ist auf diesem Server erlaubt, was manche Hoster sperren. Am 26.08.2026 mit einer temporären Regel und einem lokalen Testprozess gemessen (danach vollständig zurückgebaut):

RewriteEngine On
RewriteRule ^api/(.*)$ http://127.0.0.1:<port>/api/$1 [P,L]
  • sofortige Antwort: HTTP 200 nach 0,13 s
  • absichtlich verzögerte Antwort: HTTP 200 nach 30,1 s — Apache hält die Verbindung also durch und puffert nichts weg. Damit ist Long Polling mit wait=25 auf dieser Umgebung nicht nur rechnerisch, sondern gemessen tragfähig.

Zu beachten: scripts/deploy-prod.sh spiegelt die .htaccess mit rsync --delete aus scripts/prod.htaccess. Die Proxy-Regel gehört deshalb dorthin, sonst ist sie nach dem nächsten Deploy weg.

Offen bleibt Java: installiert ist 17, build.gradle.kts verlangt JavaLanguageVersion.of(21). Entschieden ist, ein eigenes JDK 21 ins Home zu legen (kein Root nötig, Linger=yes erlaubt den dauerhaften Dienst) statt die Toolchain zu senken — Entwicklung und Produktion laufen dann auf derselben Version. Ein natives Binary via GraalVM wäre der elegantere Weg (kein Java auf dem Server, ein Bruchteil des knappen RAM), scheitert aber vorerst an dreierlei: der glibc-Differenz zwischen Ubuntu 24.04 (2.39) und Debian 12 (2.36), die einen Container-Build erzwingt; dem Umstand, dass Liquibase Metadaten aus einem Native-Agent-Lauf braucht und Hibernate das Enhancement-Plugin; und einem offenen Fehler in Spring Boot 4, der genau die Kombination JPA + Liquibase im Native-Image-Build zerlegt. Vorgemerkt, nicht verworfen.

Grenzen der simplen Variante (bewusst akzeptiert)

  • Konflikterkennung auf Dokumentebene mit serverseitigem Rebase: Zwei Editoren an verschiedenen Stellen stören einander nicht mehr; ein 409 gibt es nur bei echter Überlappung derselben Zeilen.
  • Kein Präsenz-Feature (wer ist online, fremde Cursor). Später über ein leichtgewichtiges presence-Feld im Feed nachrüstbar — clientId und Anzeigename gibt es dafür schon; das !!!-Fokusmark des Formats kann ergänzend genutzt werden.
  • Clientseitige Verschlüsselung: Das Protokoll transportiert Zeilen als opake Strings und funktioniert unverändert mit Ciphertext pro Zeile — nur das serverseitige Diff-Berechnen und Rebasen entfiele dann (Clients müssten Ops immer selbst liefern; die Struktur erlaubt das bereits).

Teststrategie

  • Cucumber: „Client B sieht die Änderung von Client A im Feed", „veraltete Basis ohne Überlappung wird serverseitig rebased und akzeptiert", „veraltete Basis mit Überlappung liefert 409 mit opsSinceBase", „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 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 nicht), Prüfsummenbildung samt Zeilenenden-Normalisierung.

Umsetzungsreihenfolge

  1. Diff-Modell + Anwenden/Berechnen/Rebasen als reine Kotlin-Funktionen (Unit-Tests) — gebaut, de.werkbaum.diff
  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) — gebaut, ChangeNotifier + LiveEditingService
  5. Master-Passwort für GET /documents (Spring Security) — gebaut
  6. Client-Anpassung (Feed-Schleife, lokales Anwenden, Konfliktdialog) — gebaut, frontend/src/live.js + app.js; Befunde in D76-Nachtrag 7

Vor Schritt 4 steht die Vermessung der Zielumgebung (siehe „Betrieb") — sie bestimmt den wait-Wert und im Extremfall, ob Long Polling dort überhaupt trägt.