From 627f6feca83e3cb873325e4b0c42d48c6146f018 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Wed, 26 Aug 2026 20:53:02 +0200 Subject: [PATCH] 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. --- README.de.md | 2 +- README.md | 2 +- backend/docs/live-editing-proposal.md | 5 +- .../werkbaum/service/LiveEditingProperties.kt | 10 ++- backend/src/main/resources/application.yaml | 7 +- .../werkbaum/service/DocumentServiceTest.kt | 32 ++++++++ docs/CHANGELOG.md | 2 + docs/DECISIONS.md | 75 +++++++++++++++++++ docs/SPEC.md | 2 +- frontend/CLAUDE.md | 3 +- frontend/src/app.js | 13 +++- 11 files changed, 140 insertions(+), 13 deletions(-) diff --git a/README.de.md b/README.de.md index fbd8f46..fc867e2 100644 --- a/README.de.md +++ b/README.de.md @@ -102,7 +102,7 @@ Dokumenten-Menü: Er lädt den aktiven Plan hoch, schaltet dorthin um und schrei den Link in die Adresszeile und in die Zwischenablage. Diesen Link weitergeben — die Adresse ist die Einladung, und wer sie kennt, kommt hinein. -**Das Textfeld bleibt beschreibbar.** Nach 1,5 s Ruhe schickt der Editor die +**Das Textfeld bleibt beschreibbar.** Nach 0,6 s Ruhe schickt der Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung bereit und spielt fremde Änderungen ein. **Die Schreibmarke wandert mit** — fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. diff --git a/README.md b/README.md index c9fc084..3316809 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ server"**: it uploads the active plan, switches to it, and puts the link in the address bar and on your clipboard. Share that link — the address is the invitation, and knowing it is what grants access. -The text area stays **writable**. After 1.5 s of quiet the editor sends the +The text area stays **writable**. After 0.6 s of quiet the editor sends the change as a line diff; an open request holds the other direction ready and plays foreign changes in. **The caret travels with them** — if someone inserts lines above you, it stays where it was in the text. diff --git a/backend/docs/live-editing-proposal.md b/backend/docs/live-editing-proposal.md index 9f4435c..8062dd4 100644 --- a/backend/docs/live-editing-proposal.md +++ b/backend/docs/live-editing-proposal.md @@ -301,8 +301,9 @@ Diff-Format und Konfliktlogik bleiben identisch. ### Historie in zwei Ebenen -Mit 1,5 s Debounce wird die Historie sonst zum Transaktionslog: hunderte -Volltext-Snapshots eines 40-kB-Dokuments je Sitzung. Getrennt werden deshalb: +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 diff --git a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt index c400154..7b81c01 100644 --- a/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt +++ b/backend/src/main/kotlin/de/werkbaum/service/LiveEditingProperties.kt @@ -20,8 +20,16 @@ data class LiveEditingProperties( /** * Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed * ein so altes `since` mit Volltext statt mit einem Diff. + * + * **Das ist die einzige Aufgabe des Werts** – nutzersichtbar ist die + * Historie der Meilensteine, und die wird nie verdichtet. Wer einen + * offenen Feed hat, fällt gar nicht zurück; zurückfallen kann nur, wessen + * Feed **ruht** (Hintergrund-Tab, D76-Nachtrag 1) oder gerade neu + * ansetzt. Fünf Minuten decken die kurze Abwesenheit ab, alles darüber + * bekommt anstandslos den Volltext. Länger aufzuheben kostet dagegen + * echten Platz: Jede Version speichert den **ganzen** Text (D79). */ - val syncRetention: Duration = Duration.ofHours(1), + val syncRetention: Duration = Duration.ofMinutes(5), /** * Höchstzahl der Operationen je Anfrage. Ohne Grenze ist ein einzelner diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 7a84dec..569b35d 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -35,8 +35,11 @@ werkbaum: # Schreibpause, nach der die letzte Version zum Meilenstein wird. milestone-pause: 30s # Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so - # altes "since" dann mit Volltext statt mit einem Diff. - sync-retention: 1h + # altes "since" dann mit Volltext statt mit einem Diff. Nutzersichtbar ist + # die Historie der Meilensteine - die bleibt unberuehrt. Zurueckfallen kann + # nur ein ruhender Feed (Hintergrund-Tab); fuenf Minuten decken die kurze + # Abwesenheit ab, und jede Version kostet den ganzen Text (D79). + sync-retention: 5m # 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 diff --git a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt index f95d91a..d038502 100644 --- a/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/service/DocumentServiceTest.kt @@ -12,6 +12,7 @@ import io.mockk.mockk import io.mockk.CapturingSlot import io.mockk.slot import io.mockk.verify +import io.mockk.verifyOrder import org.junit.jupiter.api.Test import java.time.Clock import java.time.Duration @@ -215,6 +216,37 @@ class DocumentServiceTest { verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) } } + /** + * Eine Schreibpause, die **länger ist als die Aufbewahrungsfrist**, war mit + * einer Stunde Frist der Ausnahmefall und ist mit fünf Minuten der + * Normalfall (D79). Dass die letzte Sync-Version davor trotzdem nicht + * verlorengeht, hängt an einer einzigen Sache: `recordHistory` **befördert + * zuerst und verdichtet danach**. In der anderen Reihenfolge löschte die + * Verdichtung genau den Stand, den die Beförderung gleich zum Meilenstein + * gemacht hätte — ein nutzersichtbarer Stand wäre still weg. + */ + @Test + fun `nach einer Pause laenger als die Frist wird zuerst befoerdert, dann verdichtet`() { + val kurz = LiveEditingProperties( + milestonePause = Duration.ofSeconds(30), + syncRetention = Duration.ofMinutes(5), + ) + val dienst = DocumentService(repository, historyRepository, clock, kurz, notifier) + val doc = sampleDocument(version = 5) + every { repository.findById(doc.id) } returns doc + every { repository.save(any()) } answers { firstArg() } + every { historyRepository.findLatest(doc.id) } returns + historyEntry(doc.id, 5, ChangeType.UPDATED, milestone = false) + + clock.moment = clock.moment.plusSeconds(600) // zehn Minuten Pause + dienst.update(doc.id, "T", "C", milestone = false) + + verifyOrder { + historyRepository.promoteToMilestone(doc.id, 5) + historyRepository.compact(doc.id, OffsetDateTime.now(clock).minusMinutes(5)) + } + } + @Test fun `nach jeder Aenderung wird jenseits der Aufbewahrungsfrist verdichtet`() { val doc = sampleDocument(version = 1) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f7d477a..290e0db 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,8 @@ reverse. ## 2026-08-26 +- Changes in a shared document now reach the others after 0.6 s instead of 1.5 s — the wait before sending was almost the whole delay + - The Etherpad integration is gone: collaboration now runs through a Werkbaum backend, and an old `?etherpad=` link says so instead of doing nothing - Plans can live on a Werkbaum backend now: open `?live=` and everyone edits the same text, seeing each other's changes without reloading - When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 112e959..a573729 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6744,3 +6744,78 @@ Textfeld liegt mit dem Zahlenstreifen bündig (1151 + 20 px), die Zeilennummern sitzen auf ihren Höhen, Pfad und Stationen werden gezeichnet; der Legenden-Splitter teilt wie zuvor. 501 Tests (die 24 Pad-Adress-Tests sind mit `remote.js` gegangen, `padGone` ist dazugekommen). + +## D79 — Debounce auf 600 ms, Sync-Versionen nur noch fünf Minuten +Gemeldet: „der Delay beim Live-Editing zwischen zwei Browsern ist ca. 3 s, das +ist zu träge." Nachgemessen und zerlegt, statt am Gefühl zu drehen. + +**Die Wartezeit vor dem Senden IST die Verzögerung.** Der Weg A → B in Zahlen +(lokal, Wanduhr beider Tabs): + +| Abschnitt | gemessen | +|---|---| +| Tippen → PATCH raus | 1666 ms | +| PATCH-Rundlauf | 48 ms | +| Feed-Antwort bei B | 9 ms danach | +| Text steht bei B | 11 ms | + +Alles außer dem Debounce sind zusammen rund 70 ms. Zwei Verdächtige sind +ausdrücklich **freigesprochen**: Der Server weckt den wartenden Feed **39 ms** +nach dem PATCH (isoliert per curl gemessen, ohne Browser), und der Apache der +produktiven Instanz hält den Long-Poll die vollen **25 s** durch und schließt +sauber mit 204 — es gibt also kein Fenster ohne offenen Feed und keinen +5-Sekunden-Fehlerpfad (`LIVE_RETRY_MS`). Produktiv kommen ~130 ms Rundlauf je +Anfrage dazu (gemessen, TLS eingeschlossen), macht ≈ 1,8 s. + +Die gemeldeten 3 s liegen darüber, und der Rest steckt in der Wahrnehmung — +das ist keine Ausrede, sondern eine **Eigenschaft des Debounce**: Die Uhr +startet bei jedem Tastendruck neu. Gefühlt beginnt die Wartezeit, wenn der +Gedanke fertig ist; gerechnet beim letzten Anschlag. + +**Entschieden (Nutzer): 600 ms, und es bleibt ein Debounce.** Erwogen war, aus +dem Debounce eine **Drossel** zu machen (regelmäßig senden statt nur in der +Pause) — verworfen: Wer durchtippt, soll weiterhin keine Version erzeugen. Der +Grund, aus dem D76 bei 1,5 s blieb, trägt ohnehin nicht mehr: Er stammte aus +der Rate-Limit-Disziplin des Etherpad-Konzepts, und Etherpad ist ausgebaut +(D78). + +**Die zweite Hälfte ist die Aufbewahrung — und sie zahlt die erste.** Jede +Version speichert den **ganzen** Text; Sync-Versionen lagen eine Stunde. Wofür +ist die Frist überhaupt da? Für genau eines: ob ein zurückgefallener Client ein +**Diff** bekommt oder den **Volltext**. Nutzersichtbar ist die Historie der +**Meilensteine**, und die wird nie verdichtet. Wer einen offenen Feed hat, +fällt gar nicht zurück — zurückfallen kann nur, wessen Feed **ruht** +(Hintergrund-Tab, D76-Nachtrag 1). Fünf Minuten decken die kurze Abwesenheit +ab, alles darüber bekommt anstandslos den Volltext. + +Zusammen **sinkt** der Platzbedarf, obwohl öfter gesendet wird — gerechnet mit +dem mitgelieferten Plan (49 kB), Dauertippen als Spitze: + +| | Versionen/min | Frist | Spitze je Dokument | +|---|---|---|---| +| vorher | 40 | 60 min | **115 MB** | +| nachher | 100 | 5 min | **24 MB** | + +Auf einem Host mit rund 300 MB frei (D76-Nachtrag 3) ist das der Unterschied, +der zählt. + +**Dabei gefunden, und erst durch die kurze Frist gefährlich:** Eine Schreibpause +**länger als die Aufbewahrungsfrist** war mit einer Stunde der Ausnahmefall und +ist mit fünf Minuten der Normalfall. Dass die letzte Sync-Version davor +trotzdem nicht verlorengeht, hängt an einer einzigen Sache — `recordHistory()` +**befördert zuerst und verdichtet danach**. In der anderen Reihenfolge löschte +die Verdichtung genau den Stand, den die Beförderung gleich zum Meilenstein +gemacht hätte: ein nutzersichtbarer Stand wäre still weg. Die Reihenfolge war +bisher nur eine Anordnung von Anweisungen; sie hat jetzt eine Zusicherung +(`verifyOrder`). Gegenprobe: vertauscht fällt genau der danach benannte Test. + +**Werkzeuggrenze, die diese Messung fast verdorben hätte.** Der +Automatisierungs-Browser zeigt seine Fläche nicht an, die Seite ist damit +wirklich verborgen — und Chrome drosselt Timer verborgener Seiten auf **1 Hz**. +Der `document.hidden`-Stub belügt die App, nicht den Scheduler. Nachgemessen: +ein blanker `setTimeout(…, 600)` feuert dort nach 999–1053 ms. Ein +Sub-Sekunden-Debounce ist in dieser Umgebung **grundsätzlich nicht messbar**; +die 600 ms sind gesetzt, und was daneben liegt (Server 39 ms, Rundlauf 130 ms, +PATCH → sichtbar 46 ms) ist einzeln gemessen. Dieselbe Lehre wie D25 +(synthetische `TouchEvent`s), D17-Nachtrag 4 (Bildschirmtastatur) und D53 +(synthetisches Strg+Z): Was die Umgebung stellt, stellt der Emulator nicht. diff --git a/docs/SPEC.md b/docs/SPEC.md index 9ef2dbe..9307fac 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -783,7 +783,7 @@ Dokument-Adresse: `…?live=https://example.org/api/v1/documents/`. Geschrieben wird **im Editor selbst**, und alle sehen die Änderungen der anderen, ohne neu zu laden. -- **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (1,5 s) schickt der +- **Das Textfeld bleibt beschreibbar.** Nach kurzer Ruhe (0,6 s) schickt der Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung bereit und spielt fremde Änderungen ein. Die **Schreibmarke wandert mit** — fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 1c2e5f6..d5dda0d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -117,7 +117,8 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der - **Server-Dokumente (`?live=`, D76):** `live.js` hält die entscheidbare Hälfte (Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die Verdrahtung: `loadLive()` holt Text und Version und merkt beides als - **Schattenkopie**, `scheduleLivePush()` schickt nach 1,5 s Ruhe das Diff, + **Schattenkopie**, `scheduleLivePush()` schickt nach 0,6 s Ruhe das Diff + (D79 — die Wartezeit IST die gefuehlte Verzoegerung), `runFeed()` hält einen Abruf offen (nur im sichtbaren Tab), `putOnServer()` legt das aktive Dokument an („Auf den Server legen"). Die Basis-Adresse bestimmt `serverBase()` (live.js): `?server=` vor dem offenen Dokument vor diff --git a/frontend/src/app.js b/frontend/src/app.js index b9bb66d..931db8a 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -4316,7 +4316,7 @@ async function loadRemoteSource(){ wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere verschiebt der Server selbst. */ const LIVE_PARAM = 'live'; -const LIVE_DEBOUNCE_MS = 1500; /* Ruhe vor dem Senden; D76 */ +const LIVE_DEBOUNCE_MS = 600; /* Ruhe vor dem Senden; D76, D79 */ const LIVE_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */ const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */ @@ -4423,9 +4423,14 @@ function adoptLive(doc){ /* ---------- Hinschicken ---------- */ -/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn 1,5 s Ruhe ist. - Ohne den Takt entstünde je Tastendruck eine Version — die Historie wäre ein - Transaktionslog, und das Netz hätte zu tun. */ +/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn es 600 ms ruhig + ist. Ohne den Takt entstünde je Tastendruck eine Version — die Historie wäre + ein Transaktionslog, und das Netz hätte zu tun. + + Die Wartezeit **ist** die gefühlte Verzögerung: Gemessen braucht der Weg vom + Tastendruck bis zum Text des anderen 1,73 s, davon 1,67 s hier — der Server + weckt den wartenden Feed 39 ms nach dem PATCH (D79). Sie bleibt ein + Debounce, kein Takt: Wer durchtippt, erzeugt weiterhin keine Version. */ function scheduleLivePush(){ if(!liveActive() || liveConflict) return; if(liveState.pushTimer) clearTimeout(liveState.pushTimer);