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

9.3 KiB

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 — 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 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:

{ "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.