Nachtrag 2 nannte "832 MB frei" - ein Schnappschuss, und dazu mehrdeutig zwischen `free` und `available`. Nachgemessen: 326-358 MB free, 978-1004 MB available von 3915 MB, dazu ~1 GB Swap belegt. Falsch war ausserdem die Annahme, ein Managed Webspace koenne den Verbrauch nicht zuordnen. `hidepid=invisible` verbirgt zwar fremde Prozesse, aber das systemd-cgroup-Accounting (`systemctl status pacs-<paket>.slice`, so auch im Hostsharing-Wiki) und die world-readable atop-Aufzeichnungen unter /var/log/atop/ geben sie her. Befund: nicht die Datenbanken. clamav-daemon 988 MB gegen mariadb 150 MB und postgresql 111 MB; alle zwoelf Webspaces zusammen 100 MB. MariaDB haelt 602 MB im Swap - die DBs sind auf je 25 % des RAM provisioniert und verlieren gegen den Virenscanner. Fuers Deployment: pacs-mih00.slice hat MemoryMax=3147M - eine Erlaubnis, keine Reservierung. `-Xmx` gehoert gegen das Freie bemessen; die JVM-Voreinstellung (~980 MB) ist genau die Groesse, die MariaDBs Puffer verdraengt hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
415 lines
20 KiB
Markdown
415 lines
20 KiB
Markdown
# Live-Editing über HTTP (Variante „Simpel")
|
||
|
||
Status: **Konzept entschieden** (D76), noch nichts implementiert. 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.
|
||
|
||
## 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** (Hash serverseitig in
|
||
einer Umgebungsvariable, geprüft über Spring Security). Ohne diesen Schutz
|
||
wäre jede UUID auflistbar und das Modell hinfällig. Der Endpunkt braucht
|
||
eine **Sperre nach Fehlversuchen**, und 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**) —
|
||
deutet auf einen Client-Bug, Client lädt einmalig neu.
|
||
|
||
**Ü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. 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.
|
||
|
||
**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).
|
||
|
||
### 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:** `UPDATED`, `DELETED`, `RESTORED`, `ROLLED_BACK`
|
||
und `RENAMED` (dieses mit dem neuen Titel im Klartext).
|
||
|
||
- **`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**: Spring MVC `DeferredResult` + ein In-Process-Notifier
|
||
(pro Dokument eine Warteliste; Zustellung bei akzeptiertem Update **und**
|
||
beim Löschen). Kein zusätzliches Framework nötig. **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** (zwei Threads oder
|
||
asynchrones MockMvc) und einen klein konfigurierbaren `wait`-Wert — der
|
||
heutige synchrone `TestRestTemplate`-Stil allein reicht nicht.
|
||
- **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)
|
||
2. Historie in zwei Ebenen + gezielter Repository-Zugriff
|
||
3. `PATCH /content` inkl. Rebase, Idempotenz, Prüfsumme und 409 (Spec +
|
||
Cucumber)
|
||
4. `GET /changes` mit Long Polling, Volltext-Fall und Ereignistypen
|
||
(Spec + Cucumber)
|
||
5. Master-Passwort für `GET /documents` (Spring Security)
|
||
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.
|