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.
This commit is contained in:
mhoennig
2026-08-26 20:53:02 +02:00
parent bfacbe96e5
commit 627f6feca8
11 changed files with 140 additions and 13 deletions
+1 -1
View File
@@ -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 — den Link in die Adresszeile und in die Zwischenablage. Diesen Link weitergeben —
die Adresse ist die Einladung, und wer sie kennt, kommt hinein. 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 Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung bereit und
spielt fremde Änderungen ein. **Die Schreibmarke wandert mit** — fügt jemand spielt fremde Änderungen ein. **Die Schreibmarke wandert mit** — fügt jemand
oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
+1 -1
View File
@@ -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 address bar and on your clipboard. Share that link — the address is the
invitation, and knowing it is what grants access. 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 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 foreign changes in. **The caret travels with them** — if someone inserts lines
above you, it stays where it was in the text. above you, it stays where it was in the text.
+3 -2
View File
@@ -301,8 +301,9 @@ Diff-Format und Konfliktlogik bleiben identisch.
### Historie in zwei Ebenen ### Historie in zwei Ebenen
Mit 1,5 s Debounce wird die Historie sonst zum Transaktionslog: hunderte Ohne Trennung wird die Historie zum Transaktionslog: hunderte
Volltext-Snapshots eines 40-kB-Dokuments je Sitzung. Getrennt werden deshalb: 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 - **Sync-Versionen** — tragen das Protokoll (Diffs zwischen beliebigen
Versionen), kurzlebig, werden nach einer Weile verdichtet. Danach Versionen), kurzlebig, werden nach einer Weile verdichtet. Danach
@@ -20,8 +20,16 @@ data class LiveEditingProperties(
/** /**
* Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed * Wie lange Sync-Versionen aufgehoben werden. Danach beantwortet der Feed
* ein so altes `since` mit Volltext statt mit einem Diff. * 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 * Höchstzahl der Operationen je Anfrage. Ohne Grenze ist ein einzelner
+5 -2
View File
@@ -35,8 +35,11 @@ werkbaum:
# Schreibpause, nach der die letzte Version zum Meilenstein wird. # Schreibpause, nach der die letzte Version zum Meilenstein wird.
milestone-pause: 30s milestone-pause: 30s
# Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so # Danach wird eine Sync-Version verdichtet; der Feed antwortet auf ein so
# altes "since" dann mit Volltext statt mit einem Diff. # altes "since" dann mit Volltext statt mit einem Diff. Nutzersichtbar ist
sync-retention: 1h # 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 # Obergrenze fuers Warten am Feed. Gemessen: Apache auf der Zielumgebung
# haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s. # haelt einen Long-Poll 30 s durch, seine Zeitgrenzen liegen bei 300 s.
max-wait: 25s max-wait: 25s
@@ -12,6 +12,7 @@ import io.mockk.mockk
import io.mockk.CapturingSlot import io.mockk.CapturingSlot
import io.mockk.slot import io.mockk.slot
import io.mockk.verify import io.mockk.verify
import io.mockk.verifyOrder
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.time.Clock import java.time.Clock
import java.time.Duration import java.time.Duration
@@ -215,6 +216,37 @@ class DocumentServiceTest {
verify(exactly = 0) { historyRepository.promoteToMilestone(any(), any()) } 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 @Test
fun `nach jeder Aenderung wird jenseits der Aufbewahrungsfrist verdichtet`() { fun `nach jeder Aenderung wird jenseits der Aufbewahrungsfrist verdichtet`() {
val doc = sampleDocument(version = 1) val doc = sampleDocument(version = 1)
+2
View File
@@ -19,6 +19,8 @@ reverse.
## 2026-08-26 ## 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 - 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=<document URL>` and everyone edits the same text, seeing each other's changes without reloading - Plans can live on a Werkbaum backend now: open `?live=<document URL>` 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 - When two people change the same lines, a bar asks whose version should win — everything else the server merges by itself
+75
View File
@@ -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 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 Legenden-Splitter teilt wie zuvor. 501 Tests (die 24 Pad-Adress-Tests sind mit
`remote.js` gegangen, `padGone` ist dazugekommen). `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 9991053 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.
+1 -1
View File
@@ -783,7 +783,7 @@ Dokument-Adresse: `…?live=https://example.org/api/v1/documents/<uuid>`.
Geschrieben wird **im Editor selbst**, und alle sehen die Änderungen der Geschrieben wird **im Editor selbst**, und alle sehen die Änderungen der
anderen, ohne neu zu laden. 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 Editor die Änderung als Zeilen-Diff; ein offener Abruf hält die Gegenrichtung
bereit und spielt fremde Änderungen ein. Die **Schreibmarke wandert mit** bereit und spielt fremde Änderungen ein. Die **Schreibmarke wandert mit**
fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text. fügt jemand oberhalb Zeilen ein, bleibt sie an ihrer Stelle im Text.
+2 -1
View File
@@ -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 - **Server-Dokumente (`?live=`, D76):** `live.js` hält die entscheidbare Hälfte
(Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die (Adressen, Zeilen-Diff, Rebasen, Cursor-Rechnung, Feed-Regel), `app.js` die
Verdrahtung: `loadLive()` holt Text und Version und merkt beides als 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()` `runFeed()` hält einen Abruf offen (nur im sichtbaren Tab), `putOnServer()`
legt das aktive Dokument an („Auf den Server legen"). Die Basis-Adresse legt das aktive Dokument an („Auf den Server legen"). Die Basis-Adresse
bestimmt `serverBase()` (live.js): `?server=` vor dem offenen Dokument vor bestimmt `serverBase()` (live.js): `?server=` vor dem offenen Dokument vor
+9 -4
View File
@@ -4316,7 +4316,7 @@ async function loadRemoteSource(){
wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere wirklich, entscheidet der Mensch (Konflikt-Band unten). Alles andere
verschiebt der Server selbst. */ verschiebt der Server selbst. */
const LIVE_PARAM = 'live'; 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_WAIT_S = 25; /* Wartezeit des Feeds; der Server klemmt sie ohnehin */
const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */ const LIVE_RETRY_MS = 5000; /* nach einem Netzfehler, bevor der Feed erneut fragt */
@@ -4423,9 +4423,14 @@ function adoptLive(doc){
/* ---------- Hinschicken ---------- */ /* ---------- Hinschicken ---------- */
/* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn 1,5 s Ruhe ist. /* Nach jeder Eingabe neu gestartet: Gesendet wird erst, wenn es 600 ms ruhig
Ohne den Takt entstünde je Tastendruck eine Version die Historie wäre ein ist. Ohne den Takt entstünde je Tastendruck eine Version die Historie wäre
Transaktionslog, und das Netz hätte zu tun. */ 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(){ function scheduleLivePush(){
if(!liveActive() || liveConflict) return; if(!liveActive() || liveConflict) return;
if(liveState.pushTimer) clearTimeout(liveState.pushTimer); if(liveState.pushTimer) clearTimeout(liveState.pushTimer);