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
+3 -2
View File
@@ -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
@@ -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
+5 -2
View File
@@ -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
@@ -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)