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.
This commit is contained in:
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
183
docs/realtime/issues/06-dumb-pipe-event-und-backfill.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user