Files
rustification-server/docs/realtime/issues/08-fan-out-architektur.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

12 KiB

Fan-out-Architektur

Type: grilling Status: resolved Blocked by: — Parent: ../map.md

Question

Wie kommt ein Änderungs-Event von „Nutzer A hat geschrieben" zu allen betroffenen offenen Verbindungen — und wie sieht die Naht aus, hinter der später LISTEN/NOTIFY steckt?

Entschieden ist bereits: Start mit In-Process-Fan-out, aber hinter einem Trait; NOTIFY als zweite Implementierung; kein Redis (harte Regel: eine Binary + Postgres).

Zu entscheiden

  • Registry-Struktur. conversation_id → Menge von Sendern, user_id → Menge von Verbindungen, oder beides? Ein Nutzer kann in vielen Conversations sein und mehrere Geräte haben — welche Indizierung hält beides aus, ohne bei jedem Event über alles zu iterieren?
  • Kanaltyp. tokio::sync::broadcast pro Conversation, mpsc pro Verbindung, oder ein globaler Broadcast mit clientseitigem Filter. Bei broadcast ist zu klären, wie RecvError::Lagged behandelt wird — es bedeutet hier „Resync", nicht „Fehler", und das muss explizit im Design stehen (Council-Hinweis des Engineer).
  • Auf- und Abräumen. Wann wird ein Eintrag entfernt? Verbindungsabbrüche sind der Normalfall, nicht die Ausnahme — Leaks in der Registry sind der klassische Fehler.
  • Trait-Zuschnitt. Wie schmal muss die Schnittstelle sein, damit die NOTIFY-Implementierung sie wirklich erfüllen kann? NOTIFY ist fire and forget ohne Zustellgarantie und ohne Rückkanal — ein Trait, das mehr verspricht als das, ist eine Lüge, die beim Wechsel auffliegt.
  • NOTIFY-Pfad konkret genug, um ihn zu bewerten. Ein Channel pro Conversation (Postgres hat ein Limit an Channels pro Verbindung), oder ein Channel mit Conversation-ID im Payload? Wer hält die LISTEN-Verbindung, und was passiert, wenn sie abreißt?
  • Wo wird die conversation_events-Zeile geschrieben? Monotoner Cursor hat die Tabelle festgelegt, diese Frage aber bewusst hierher verschoben, weil sie mit NOTIFY zusammenfällt. Also: im Handler in derselben Transaktion, oder per Datenbank-Trigger? Ein Trigger feuert garantiert bei jedem Schreibweg, ein Handler-Aufruf ist einfacher zu verstehen. Bei Trigger + NOTIFY fällt die zweite Implementierung fast von selbst ab — das ist ein Argument, das geprüft gehört.

Faktenlage aus der Auth-Recherche (14.08.2026)

Aus ../research/03-ws-auth-reconnect.md §6, belegt:

  • tokio::sync::watch passt auffällig gut zum Dumb Pipe. Es „retains only the last sent value" — was exakt die Semantik von „der Cursor der Conversation X ist jetzt N, hol dir den Rest per REST" ist. Kein Lag-Fehler möglich, weil Zwischenwerte per Konstruktion entbehrlich sind. Das ist ein ernstzunehmender Kandidat gegen broadcast und sollte hier gegen es antreten, nicht stillschweigend übergangen werden.

  • RecvError::Lagged(n) präzise: n = Anzahl übersprungener Nachrichten, der Receiver bleibt brauchbar, sein Cursor springt auf die älteste noch vorhandene Nachricht. ⚠️ „Lagged = Resync" ist nirgends als offizielles Muster dokumentiert — gängige Community-Praxis ohne Primärquelle. Belegbar sind nur die Bausteine: resubscribe() (verwirft alles Alte), Receiver::len() als Frühwarnung vor dem Datenverlust, und mpsc::try_send pro Verbindung, um die Drop-Entscheidung pro Client statt global zu treffen.

  • broadcast speichert einmal und klont pro Receiver ⇒ der Event-Typ sollte billig klonbar sein (Arc<…>, bytes::Bytes). Kapazität wird auf die nächste Zweierpotenz aufgerundet.

  • async_broadcast scheidet vermutlich aus: es bietet echte Backpressure, womit ein langsamer WS-Client den gesamten Producer bremst (Head-of-Line über alle Clients) — das widerspricht der Map-Entscheidung „Best-effort + ?since-Backfill" frontal. flume scheidet sicher aus: MPMC heißt dort Work-Queue, jede Nachricht geht an genau einen Receiver — kein Broadcast.

  • PgListener (sqlx) reconnected selbst und re-subscribed alle Channels, belegt aber eine dedizierte Postgres-Connection außerhalb des PgPool (heute max_connections = 10) — beim Sizing einplanen. NOTIFY ist transaktional, feuert also erst bei COMMIT, was gut zur DB-als-Queue-Semantik passt. Neue Gefahr: wird die Notify-Queue voll (Listener hängen), schlagen Transaktionen beim Commit fehl. Und NOTIFY liefert keine Historie — ein verpasstes Signal ist weg, also dieselbe Resync-Frage eine Ebene tiefer.

  • Reihenfolge. Garantiert das Fan-out die Reihenfolge der Events pro Conversation? Falls nein: ist das egal, weil der Cursor monoton ist und der Client ohnehin nachlädt? (Vermutlich ja — aber das explizit festhalten.)


Answer

Entschieden am 14.08.2026.

Vorbemerkung, die das ganze Ticket entschärft

Weil der Event {conversation, seq} ist und der Backfill über den Cursor läuft, zählt nur der jeweils neueste seq je Conversation. 5, dann 6, dann 7 zuzustellen ist gleichwertig damit, nur 7 zuzustellen. Das Fan-out darf zusammenfassen und sogar verlieren, ohne dass Daten verlorengehen — schlimmstenfalls ist ein Client kurz veraltet, und das Manifest beim nächsten Reconnect fängt alles auf.

Damit löst sich die RecvError::Lagged-Sorge aus Recherche: WS-Auth, Heartbeat und Reconnect weitgehend auf: Lag ist hier kein Datenverlust, sondern Latenz.

1. Registry: conversation_id → Verbindungen, ein Kanal je Verbindung

Eine HashMap<ConversationId, Set<Verbindung>> im Serverzustand. Beim Verbindungsaufbau werden die Mitgliedschaften einmal geladen und die Verbindung unter jeder eingetragen, beim Trennen entfernt. Der Publisher schlägt einmal nach und stellt gezielt zu.

Entscheidend: jede Verbindung hat genau einen Empfänger. Die Verbindungsschleife bleibt ein select! mit zwei Zweigen, statt über vierzig Kanäle gleichzeitig warten zu müssen. Die Mitgliedschaftsliste braucht die Verbindung ohnehin zur Autorisierung — die Registry ist also kein zusätzlicher Zustand.

Verworfen: ein Kanal je Conversation (eine Verbindung müsste auf N Empfängern warten, SelectAll plus dynamisches An- und Abmelden während des Wartens) und ein globaler Kanal mit Filter (zehn Zeilen Code, aber jede Verbindung wird bei jedem Event serverweit geweckt — bei tausend Verbindungen und hundert Events pro Sekunde hunderttausend Aufwachvorgänge, die fast alle nichts tun).

2. Zwei Registries, weil Mitgliedschaftswechsel anders adressiert werden

Ein Beitritt kann nicht über die Conversation-Registry laufen: der Nutzer ist dort noch nicht eingetragen, das ist ja gerade der Punkt. Es gibt daher eine zweite Karte user_id → Verbindungen. Mitgliedschafts-Events werden pro Nutzer zugestellt; die Instanz, die diese Verbindung hält, trägt sie daraufhin in die Conversation-Registry ein oder aus.

Damit funktioniert es auch über Instanzgrenzen: die Mitgliedschaftsänderung reist als NOTIFY mit der user_id im Payload, und jede Instanz aktualisiert nur ihre eigenen Verbindungen.

3. Puffer je Verbindung: Dirty-Set mit Notify

Je Verbindung eine HashMap<ConversationId, seq> hinter einem Mutex plus ein Notify. Der Publisher trägt ein und weckt; die Schreibtask leert die Karte und schickt einen Event je Conversation.

Das kodiert „nur der neueste zählt" direkt im Datentyp: perfektes Zusammenfassen, kein Verlust, Speicher begrenzt durch die Anzahl der Conversations statt durch die Event-Rate. Kein Überlauf, also kein Überlauf-Fehlerpfad.

⚠️ Wichtiges Detail: der Eintrag ist max(vorhandener, neuer), kein blindes insert. NOTIFY garantiert Reihenfolge nur je Listener-Verbindung; ein blindes Überschreiben würde sonst im ungünstigen Fall einen neueren seq durch einen älteren ersetzen.

Verworfen: mpsc + try_send. Bei vollem Puffer verwirft es den neuen Event und behält die alten — also ausgerechnet den einzigen, auf den es ankommt, und im Log nicht von Normalbetrieb unterscheidbar. Die Variante „bei Überlauf trennen" wäre korrekt, macht die Puffergröße aber zur Stellschraube zwischen „trennt zu früh" und „merkt Blockade zu spät", mit Pendelrisiko bei langsamen Mobilclients.

4. Log-Zeile im Handler, NOTIFY per Trigger auf conversation_events

Die Fachlogik bleibt in Rust: der Handler zieht den seq (UPDATE conversations … RETURNING, was laut Monotoner Cursor ohnehin in seiner Transaktion passieren muss) und schreibt die Log-Zeile mit passendem kind.

Auf conversation_events sitzt ein Trigger, der nichts tut außer pg_notify zu feuern — drei Zeilen PL/pgSQL ohne Fachlogik. Damit ist der Benachrichtigungspfad unvergesslich: wer immer eine Log-Zeile schreibt, löst das Signal aus, auch künftige Handler und manuelle Eingriffe. Und weil NOTIFY transaktional ist, feuert es erst beim Commit — genau dann, wenn die Zeile sichtbar wird.

Verworfen: alles im Handler (jeder künftige Schreibweg müsste daran denken — die Sorte Vergessen, die sich als „manchmal aktualisiert sich der Client nicht" äußert) und alles per Trigger auf den Fachtabellen (die Kernlogik des Realtime-Layers läge dann in PL/pgSQL, verteilt über drei Trigger, schlecht testbar und schwer zu lesen).

5. Kein Trait — immer über NOTIFY, auch bei einer Instanz

Das revidiert eine Randbedingung der Map. Sie sah „In-Process hinter einem Trait, NOTIFY als zweite Implementierung" vor. Das wurde formuliert, bevor feststand, dass der Event 60 Byte groß ist und der Trigger NOTIFY ohnehin feuert.

Der Server hört sich per PgListener selbst zu und speist die Registry. Ein Pfad für Ein- und Mehr-Instanz-Betrieb: kein Trait, keine zweite Implementierung, und die Mehr-Instanz-Fähigkeit ist ab Tag eins da statt nur vorgesehen. Der getestete Pfad ist automatisch der Produktionspfad — der eigentliche Gewinn, denn beim Trait wäre ausgerechnet der NOTIFY-Pfad beim ersten Skalierungsschritt der ungetestete gewesen.

Ein einziger Channel mit der Conversation-ID im Payload, nicht einer je Conversation: dynamisches LISTEN/UNLISTEN über alle Conversations wäre unbegrenzt, und 60 Byte liegen weit unter der 8-kB-Grenze.

Betriebliche Kosten, bewusst akzeptiert:

  • Jeder Event läuft durch Postgres — auf demselben Host im Sub-Millisekunden-Bereich.
  • PgListener belegt eine dedizierte Verbindung außerhalb des Pools (heute max_connections = 10) — beim Sizing einplanen.
  • Hängt ein Listener und die Notify-Queue läuft voll, scheitern Transaktionen beim Commit. Das braucht Überwachung und gehört in die Fog-Notiz zur Observability.

⚠️ Lücke, die diese Entscheidung einführt: PgListener reconnected laut sqlx-Doku selbst, aber Events während der Unterbrechung sind weg — NOTIFY hat keine Historie. Regel daher: nach jedem Listener-Reconnect schickt der Server allen Verbindungen einen Resync-Hinweis, woraufhin die Clients das Manifest neu ziehen. Das ist billig, selten, und schließt die Lücke vollständig.

6. Auf- und Abräumen

  • Verbindungsaufbau: Mitgliedschaften laden, in beide Registries eintragen.
  • Verbindungsabbau: aus beiden entfernen. Abbrüche sind der Normalfall, nicht die Ausnahme — Leaks in der Registry sind hier der klassische Fehler.
  • Mitgliedschaftswechsel während der Verbindung: über die user_id-Registry, siehe §2.
  • Eine leere Conversation-Menge wird aus der Karte entfernt, damit sie nicht unbegrenzt wächst.

7. Reihenfolge

Das Fan-out garantiert keine Reihenfolge über Instanzen hinweg und braucht sie auch nicht: der Cursor ist monoton, der Client lädt ohnehin den aktuellen Zustand, und das Dirty-Set nimmt das Maximum. Ein zu früh oder zu spät zugestellter Event kann nichts beschädigen — er kann nur überflüssig sein.