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.
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::broadcastpro Conversation,mpscpro Verbindung, oder ein globaler Broadcast mit clientseitigem Filter. Beibroadcastist zu klären, wieRecvError::Laggedbehandelt 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?NOTIFYist 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 dieLISTEN-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 mitNOTIFYzusammenfä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 +NOTIFYfä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::watchpasst 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 gegenbroadcastund 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, undmpsc::try_sendpro Verbindung, um die Drop-Entscheidung pro Client statt global zu treffen. -
broadcastspeichert 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_broadcastscheidet 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.flumescheidet 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 desPgPool(heutemax_connections = 10) — beim Sizing einplanen.NOTIFYist 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. UndNOTIFYliefert 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.
PgListenerbelegt eine dedizierte Verbindung außerhalb des Pools (heutemax_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.