Files
rustification-server/docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

184 lines
9.3 KiB
Markdown

# Dumb-Pipe-Event & Backfill-Endpunkt
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Wie sieht der Änderungs-Event genau aus, und welche Semantik hat der
`?since`-Backfill-Endpunkt, der ihn einlöst?
Die Protokollform ist entschieden (Dumb Pipe: der Socket trägt „hier hat sich etwas
geändert", der Client lädt per REST nach). Dieses Ticket macht sie präzise.
**Entblockt am 14.08.2026** durch [*Monotoner Cursor*](02-monotoner-cursor.md) — das hat
mehrere Fragen dieses Tickets bereits beantwortet. Was dort feststeht und hier **nicht**
neu aufgemacht wird:
- Ein Zähler `seq` pro Conversation, vergeben unter Zeilen-Lock. Der Cursor ist ein `bigint`.
- Änderungsachse ist die Tabelle `conversation_events`; **eine Log-Zeile ist der Event**.
Damit sind Edits, Soft-Deletes, Reactions und Attachments abgedeckt — sie brauchen hier
keine eigene Achse mehr.
- Der Backfill liefert **aufgelösten Zustand inkl. Tombstones**, nicht rohe Events.
- Initialer Zustand ohne Cursor, zu alter Cursor und Antwortlimit laufen alle über
**denselben `resync`-Pfad**.
- Sortierung erfolgt über `messages.seq`, nicht über `created_at`.
### Zu entscheiden
- **Event-Format.** `{"conversation": "<uuid>", "cursor": <n>}` ist die Arbeitsannahme.
Reicht das? Braucht es einen Ressourcen-Diskriminator (`messages` vs. `reactions` vs.
`members`), oder ist ein Cursor pro Conversation, der alle Änderungen umfasst,
einfacher und ausreichend? Ein Diskriminator spart Fetches, kostet aber die Einfachheit,
die den Dumb Pipe überhaupt rechtfertigt.
- **Serialisierung.** JSON oder etwas Kompakteres. Bei ~40 Byte Nutzlast ist der
Unterschied vermutlich irrelevant — dann ist JSON die faule, richtige Antwort. Prüfen,
nicht annehmen.
- **Versionierung.** Braucht ein Protokoll mit einem Event-Typ überhaupt eine Version?
Was ist die Regel für die Zukunft, wenn Presence und Typing dazukommen (siehe Fog)?
- **Backfill-Endpunkt.** Erweiterung von `GET /conversations/{id}/messages` um `?since`,
oder ein neuer Endpunkt? Paginierung, Obergrenze pro Antwort, Verhalten bei sehr
großem Rückstand (Client war zwei Wochen offline).
- **Genaue Form der `resync`-Antwort.** Eigener HTTP-Status, ein Feld in der normalen
Antwort, oder ein eigener Endpunkt? Und wie groß ist das Antwortlimit tatsächlich?
- **`kind`-Taxonomie im Log.** Welche Werte gibt es, und wie fein? `message_created` vs.
`message_edited` vs. `message_deleted` einzeln, oder ein gemeinsames `message_changed`?
Der Backfill löst ohnehin auf — feinere Werte nützen also nur der Server-Seite und
späteren Konsumenten.
- **Endpunkt-Zuschnitt.** Ein Backfill pro Conversation, oder einer über alle Conversations
eines Servers (der Client hat womöglich 40 offene)? Letzteres spart Requests beim
Wiederverbinden, verkompliziert aber Limit und `resync`, weil beides dann pro Conversation
unterschiedlich ausfallen kann.
- **Was bleibt im Klartext.** Auch wenn der Inhalt später ein Blob ist: Conversation-ID,
Cursor, Absender und Zeitstempel muss der Server sehen, um zu routen und zu ordnen.
Diese Grenze hier explizit festhalten — sie ist die Zusage an den E2EE-Folge-Effort.
### Randbedingung
Der Event muss unter dem **8-kB-Payload-Limit von Postgres `NOTIFY`** bleiben. Das ist
kein Zufall, sondern der Grund, warum diese Protokollform gewählt wurde — siehe
*Fan-out-Architektur*.
Flüchtiges (SDP/ICE, später Typing) läuft **nicht** über diesen Event. Die Naht verläuft
entlang persistiert vs. flüchtig; das flüchtige Format entsteht in
*Voice-Signaling-Protokoll*.
---
## Answer
Entschieden am 14.08.2026.
### 1. Der Event ist minimal: `{conversation, seq}`
Rund 60 Byte. Die Server-Identität steckt nicht drin — sie ergibt sich aus dem Socket, über
den der Event kam. Der `seq` ist kein Luxus: der Empfänger verwirft damit sofort, was er
schon hat, statt blind nachzuladen.
**Verworfen wurden `kind` und `target_id` im Event.** Das entscheidende Argument war die
Umkehrbarkeit: ein zusätzliches Feld lässt sich später **additiv** ergänzen, weil ein
unbekanntes Feld ignorierbar ist. Es gibt also keinen Grund, heute dafür zu bezahlen. Sollte
sich der vorhergesehene Fall einstellen — Read-Receipts aus der Fog bumpen den Zähler in
einem aktiven Channel und lösen bei allen Clients Backfills aus, die nichts Sichtbares
ändern — wird `kind` nachgerüstet, ohne dass Bestehendes bricht.
Damit bleibt `conversation_events.kind` **rein serverintern**. Die Taxonomie ist ein
Implementierungsdetail, kein Protokollbestandteil, und muss nicht versioniert werden.
### 2. Aufholen nach Reconnect: Manifest, dann gezielte Backfills
Ein leichter Endpunkt liefert für alle Conversations, in denen der Nutzer Mitglied ist, den
aktuellen `seq`-Stand — eine Liste `{conversation_id, seq}`. Das ist **eine** günstige
Abfrage, weil `conversations.seq` seit [*Monotoner Cursor*](02-monotoner-cursor.md) als
Spalte existiert: ein Join über `conversation_members`, kein Scan über Nachrichten.
Der Client vergleicht mit seinen eigenen Cursorn und holt nur die tatsächlich geänderten.
Bei vierzig Conversations und drei geänderten sind das 1 + 3 Requests statt 40 — pro Server,
und der Client hält mehrere gleichzeitig.
Der Endpunkt beantwortet zwei weitere Fragen gratis: die Ungelesen-Anzeige über alle
Conversations, und den Erstverbindungsfall ohne jeden Cursor.
Verworfen: ein Sammel-Backfill mit Cursor-Map (müsste ein POST mit Body sein, Limit und
`resync` würden pro Conversation unterschiedlich, und eine große Antwort ist bei Abbruch
komplett verloren) sowie N parallele Backfills (vierzig Requests pro Reconnect, davon
siebenunddreißig leer — genau das Verhalten, das beim Server-Neustart einen Thundering Herd
erzeugt).
### 3. `resync` ist ein unterschiedener Antworttyp, kein Fehlerstatus
Der Backfill antwortet immer mit 200:
```json
{ "kind": "delta", "cursor": 4711, "messages": [...], "reactions": [...], "attachments": [...] }
{ "kind": "resync", "cursor": 4711 }
```
Der Grund ist praktisch, nicht ästhetisch: `resync` ist kein Fehler, sondern ein erwarteter
Ausgang — er tritt bei jeder Erstverbindung auf. Als 4xx liefe er durch
`error_for_status()`-Reflexe, landete in Fehler-Logs und Alarmen, und die Reconnect-Logik
könnte ihn mit einem echten Netzfehler verwechseln. Ein unterschiedener Antworttyp zwingt
den Client, beide Fälle explizit zu behandeln — was er ohnehin muss.
**Antwortlimit: 500 Entitäten als Startwert.** Eine Stellschraube, nachträglich änderbar
ohne Protokolländerung. Wird es überschritten, ist die Antwort `resync`.
Der `delta`-Fall liefert **Tombstones mit** — soft-gelöschte Zeilen müssen im Ergebnis
erscheinen, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
### 4. Die Klartext-Grenze, konkret gezogen
Bindend für den E2EE-Folge-Effort. **Klartext bleibt**, weil der Server es zum Routen,
Ordnen, Autorisieren und Durchsetzen von Eindeutigkeit braucht:
`conversation_id` · `seq` · alle IDs · `user_id` (nur der Autor darf bearbeiten) ·
`created_at` / `updated_at` / `deleted_at` · `attachments.size_bytes` · `attachments.url`
Zeitstempel zu verschlüsseln wurde erwogen und verworfen: der Betreiber sieht den Zeitpunkt
ohnehin am Socket-Verkehr, der Gewinn wäre null.
**Blob wird:**
| Feld | Begründung |
|---|---|
| `messages.content` | der eigentliche Inhalt |
| `attachments.filename` | ein Dateiname wie `Kündigung_Mueller.pdf` ist so sensibel wie der Nachrichtentext |
| `attachments.mime_type` | verrät die Art des Dokuments |
| `reactions.emoji` | **deterministisch** verschlüsselt, damit `UNIQUE (message_id, user_id, emoji)` weiter greift — der Server weist Doppelreaktionen ab, ohne sie lesen zu können |
`size_bytes` bleibt zwangsläufig sichtbar.
Der **Mechanismus** bleibt dem E2EE-Effort überlassen, die **Feldliste** nicht. Begründung:
die Migration über diese Tabellen fällt in diesem Effort an — die Grenze später zu ziehen
hieße, dieselben Tabellen ein zweites Mal zu migrieren, und bis dahin lägen Dateinamen im
Klartext auf einem Server, dessen Betreiber als Angreifer gilt.
### 5. Abgeleitet, nicht eigens entschieden
Diese drei folgen aus dem Obigen; sie sind als Vorschlag zu lesen und jederzeit umkehrbar:
- **Serialisierung: JSON.** Bei 60 Byte Nutzlast bringt ein Binärformat nichts, was den
Werkzeugverlust aufwöge.
- **Keine Protokollversion.** Ein Event-Typ, Felder werden additiv ergänzt, unbekannte
Felder ignoriert. Ein Versionsfeld wäre Zeremonie ohne Gegenwert.
- **Drei Endpunkte statt einer Erweiterung von `/messages`:**
| Endpunkt | Zweck |
|---|---|
| `GET /sync` | Manifest: `{conversation_id, seq}` für alle Mitgliedschaften |
| `GET /conversations/{id}/changes?since=N` | Delta oder `resync` |
| `GET /conversations/{id}/messages?before=<seq>&limit=` | seitenweise Historie — für `resync` und für Zurückscrollen |
`/changes` bekommt einen eigenen Endpunkt, weil seine Antwort nicht nur Nachrichten
enthält, sondern auch Reactions, Attachments und Tombstones — eine andere Form als die von
`/messages`.
### Nebenbefund
`list_messages` gibt heute **alle** Nachrichten einer Conversation unbegrenzt zurück
(`ORDER BY created_at ASC`, kein Limit). Das ist unabhängig vom Realtime-Umbau ein Problem
und wird durch die Seitenaufteilung oben mit behoben.