GET /documents listet alle Dokumente und machte damit jede UUID auffindbar — das Zugriffsmodell "unerratbarer Link" waere hinfaellig. Der Endpunkt verlangt jetzt HTTP Basic; alles andere bleibt bewusst offen. Ohne konfigurierten Hash ist die Liste ausdruecklich gesperrt (denyAll), nicht offen: Ein vergessener Umgebungswert gaebe sonst jede UUID preis, und niemandem fiele es auf, weil alles funktioniert. Bewusst als Regel und nicht bloss als zufaelliges Passwort, das niemand kennt — der Unterschied ist pruefbar: Mit dem Zufallspasswort blieb die Gegenprobe stumm, mit denyAll faellt genau die danach benannte Zusicherung. Die Sperre nach Fehlversuchen ist global statt je Adresse: Es gibt genau ein Passwort, und hinter dem Reverse Proxy der Zielumgebung saehe der Server ohnehin fuer alle dieselbe Adresse. Preis benannt (D76-Nachtrag 6). 135 Tests. Gegenproben: Schutz entfernt, Sperre entfernt, Voreinstellung geoeffnet -> es faellt jeweils genau die danach benannte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
441 lines
22 KiB
Markdown
441 lines
22 KiB
Markdown
# Live-Editing über HTTP (Variante „Simpel")
|
||
|
||
Status: **Konzept entschieden** (D76), **Schritte 1–5 der Umsetzungsreihenfolge
|
||
gebaut** (Zeilen-Diff, zweistufige Historie, `PATCH /content`, Änderungsfeed,
|
||
Master-Passwort); der Client steht aus, ebenso das Umbenennen per
|
||
`PATCH /title` und damit das Ereignis `RENAMED`. 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 **2–3 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.
|
||
|
||
### 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.
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```json
|
||
{ "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:
|
||
|
||
```json
|
||
{
|
||
"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
|
||
|
||
```json
|
||
{
|
||
"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**:
|
||
|
||
```json
|
||
{ "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
|
||
|
||
Mit 1,5 s Debounce wird die Historie sonst zum Transaktionslog: hunderte
|
||
Volltext-Snapshots eines 40-kB-Dokuments je Sitzung. 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)
|
||
|
||
**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.
|