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.
204 lines
12 KiB
Markdown
204 lines
12 KiB
Markdown
# 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*](02-monotoner-cursor.md)
|
|
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`](../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*](03-research-ws-auth-reconnect.md) 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*](02-monotoner-cursor.md)
|
|
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.
|