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:
2026-08-14 19:04:41 +02:00
parent 099315a538
commit 55a2af4756
14 changed files with 4470 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
# Monotoner Cursor
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Worauf zeigt der `?since=<cursor>`-Backfill, sodass ein Client garantiert **keine**
Nachricht überspringt?
Das ist Risiko 1 aus dem Council-Verdikt und die Voraussetzung dafür, dass die
Best-effort-Zustellung überhaupt tragfähig ist. Ohne monotonen Cursor ist „WS-Event
verpasst, hol es per REST nach" ein Versprechen, das der Server nicht halten kann.
### Warum die naheliegenden Antworten falsch sind
- **`created_at`** — Zeitstempel kollidieren bei gleichzeitigen Sendern, und ein
Cursor auf einem nicht-eindeutigen Wert verliert oder dupliziert an der Grenze.
- **`BIGSERIAL` / `id`** — Sequenzen vergeben IDs bei `INSERT`, aber Transaktionen
werden in **Commit-Reihenfolge** sichtbar. Transaktion A holt ID 100, Transaktion B
holt ID 101 und committet zuerst. Ein Client liest bis `since=101`, dann committet A.
Nachricht 100 wird **nie** ausgeliefert. Bei zwei gleichzeitigen Sendern ist das kein
Randfall, sondern der Normalfall.
Unter E2EE kann der Server den Verlust weder erkennen noch reparieren — er sieht den
Inhalt nicht und kann keine Lückenprüfung über die Nachrichtenfolge machen.
### Optionen, die zu bewerten sind
1. **Sequenz pro Conversation unter Lock** — beim Insert `SELECT ... FOR UPDATE` auf
eine Zählerzeile der Conversation, dann `seq = counter + 1`. Lückenlos und monoton
*pro Conversation*, was genau die Granularität des Dumb-Pipe-Events ist. Kosten:
Serialisierung der Schreiber pro Conversation. Ist das bei den Zielgrößen ein Problem?
2. **Snowflake-/ULID-artige ID** — zeitbasiert + Zufall, monoton generierbar ohne
DB-Rundtrip. Löst die Eindeutigkeit, aber **nicht** das Commit-Order-Problem.
3. **Sichtbarkeitsbasierter Cursor** — `pg_current_snapshot()` / `xmin`-basiertes
Nachlesen, sodass der Cursor die Transaktionssichtbarkeit abbildet statt der
Insert-Reihenfolge. Korrekt, aber deutlich exotischer.
4. **Outbox-Tabelle mit eigener Sequenz**, geschrieben in derselben Transaktion,
gelesen in Commit-Reihenfolge.
### Zu entscheiden
- Welche Option — und warum überwiegt sie die anderen bei den Faktoren Simplizität
(harte Regel: eine Binary + Postgres) und Korrektheit?
- Ist der Cursor **pro Conversation** oder **pro Server/Nutzer**? Das Dumb-Pipe-Event
trägt `{conversation, cursor}`, also spricht viel für pro Conversation — aber ein
Client, der 40 Conversations offen hat, muss dann 40 Cursor verwalten.
- Wie verhalten sich **Edits** und **Soft-Deletes**? Beide ändern eine bestehende Zeile,
ohne einen neuen Cursor-Wert zu erzeugen — ein Client, der nur ab `since` nachlädt,
sieht sie nie. Braucht es einen zweiten Kanal, eine `updated_at`-Achse, oder wird jede
Änderung als neuer Cursor-Eintrag geführt?
- Wie sieht die **Migration** aus? `messages` hat heute keine solche Spalte.
### Abnahmekriterium
Das Ticket ist gelöst, wenn es einen kleinen, lauffähigen Test gibt, der unter zwei
gleichzeitigen Sendern die Lücke **im gewählten Ansatz nicht mehr** reproduziert — und
sie im naiven `BIGSERIAL`-Ansatz reproduziert, damit der Test beweist, dass er misst,
was er messen soll.
---
## Answer
Entschieden am 14.08.2026. Vier Entscheidungen, die zusammen einen Mechanismus ergeben.
### 1. Ein Zähler pro Conversation, vergeben unter Zeilen-Lock
```sql
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
```
Jede Mutation zieht die nächste Nummer **in derselben Transaktion** wie die Änderung:
```sql
UPDATE conversations SET seq = seq + 1 WHERE id = $1 RETURNING seq;
```
Der `UPDATE` nimmt einen Zeilen-Lock auf die Conversation. Ein zweiter Schreiber in
dieselbe Conversation blockiert bis zum Commit des ersten — **damit ist Commit-Reihenfolge
gleich `seq`-Reihenfolge**, lückenlos und monoton. Genau das ist die Eigenschaft, die
`BIGSERIAL` nicht hat: dort wird die Nummer beim `INSERT` vergeben, sichtbar wird die Zeile
aber erst beim Commit, und diese beiden Reihenfolgen können auseinanderlaufen.
Die Korrektheit folgt hier aus der Konstruktion, nicht aus einer Heuristik: solange die
Nummer unter demselben Lock vergeben wird, unter dem die Zeile entsteht, kann ein Leser
mit `seq > N` nichts überspringen.
**Bonus, der die Wahl mitentschieden hat:** der Zähler *ist* der aktuelle Maximal-Cursor.
Erstabruf, Ungelesen-Anzeige und der Dumb-Pipe-Event lesen ein Feld statt zu scannen.
**Bewusst in Kauf genommen:** Schreiber derselben Conversation serialisieren (bei Chat
irrelevant — Menschen tippen), und pro Mutation entsteht eine neue Zeilenversion auf
`conversations`, die Autovacuum abräumt.
**Verworfen:** Snowflake/ULID lösen nur Eindeutigkeit, nicht die Commit-Reihenfolge.
Advisory-Lock + `MAX(seq)+1` ist gleich korrekt und vermeidet den Schreib-Churn, braucht
aber einen Hash der UUID auf int64 (Kollisionen kosten Durchsatz) und einen Scan für den
Maximal-Cursor. Sichtbarkeitsbasierte Cursor (`pg_current_snapshot()`, `xmin`) sind
theoretisch am saubersten, brechen aber die Dumb-Pipe-Form `{conversation, cursor}`, weil
der Cursor kein `bigint` mehr wäre.
### 2. Die Änderungsachse ist ein Event-Log, keine Spalte auf `messages`
```sql
CREATE TABLE conversation_events (
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
seq BIGINT NOT NULL,
kind TEXT NOT NULL, -- message_created | message_edited | message_deleted | reaction_added | ...
target_id UUID NOT NULL,
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (conversation_id, seq)
);
```
Jede Mutation hängt eine Zeile an, mit der Nummer aus dem Conversation-Zähler. Das löst
das Problem, an dem eine bloße Spalte auf `messages` scheitert: eine bearbeitete oder
soft-gelöschte Nachricht erzeugt keinen neuen Cursor-Wert und wäre für einen
`?since`-Backfill unsichtbar.
Das Log deckt zusätzlich alles ab, was **keine** Nachrichtenzeile hat — Reactions,
Attachments, Mitgliederwechsel, Umbenennungen — und trägt damit die Fog-Themen Presence,
Typing und Read-Receipts später ohne Umbau mit. Und: **die Log-Zeile ist buchstäblich der
Dumb-Pipe-Event**; `NOTIFY` kann direkt darauf feuern.
Preis, bewusst akzeptiert: eine zweite Tabelle und ein zusätzlicher Insert pro Mutation.
### 3. Genau ein Zähler, und `created_at` verliert seine Sortierrolle
Es gibt **einen** Zähler pro Conversation, den das Log verbraucht. Eine Nachricht speichert
die `seq` ihres `message_created`-Events als **unveränderlichen Sortierschlüssel**:
```sql
ALTER TABLE messages ADD COLUMN seq BIGINT NOT NULL;
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
```
Damit fällt `created_at` als Sortierkriterium weg und wird reine Anzeigeinformation. Das
ist kein Nebeneffekt, sondern eine Korrektur: `created_at` ist heute `NOW()`, in Postgres
der **Transaktionsstart**. Zwei gleichzeitige Sender bekommen Zeitstempel, deren
Reihenfolge der Commit-Reihenfolge widersprechen kann — und der Wert ist nicht einmal
eindeutig. Die bestehende Sortierung in `list_messages` ist damit schon heute falsch,
unabhängig vom Realtime-Umbau.
Eine bearbeitete Nachricht bekommt ein neues Event mit höherer `seq`, behält aber ihre
eigene `seq` — sie springt also im Cursor-Raum ans Ende, nicht im Chatverlauf.
### 4. Der Backfill liefert aufgelösten Zustand, nicht rohe Events
`GET …?since=N` liest das Log ab `N`, sammelt die betroffenen IDs und gibt den **aktuellen
Zustand** dieser Entitäten zurück, plus den neuen Cursor. Der Client ersetzt schlicht, was
er bekommt — keine Event-Interpretation, keine Abspiel-Logik.
Drei Eigenschaften, die daraus folgen:
- **Idempotent.** Ein doppelt zugestelltes Signal kann nichts kaputtmachen.
- **Koaleszierend.** Zehn Edits derselben Nachricht ergeben eine Zeile im Ergebnis.
- **Das Log ist reine Signalisierung** und damit wegwerfbar — es ist nicht die
Wahrheitsquelle, die Tabellen sind es.
**Wichtig:** der Backfill darf Soft-Deletes **nicht** herausfiltern. Er muss Tombstones
liefern, sonst erfährt der Client nie von einer Löschung. Das weicht vom heutigen
Verhalten von `list_messages` ab (`WHERE deleted_at IS NULL`).
Verworfen wurde die Alternative „rohe Events, Client spielt sie ab": sie böte eine echte
Änderungshistorie, macht aber das Log zur Wahrheitsquelle (also unbeschneidbar), verlangt
idempotente Client-Logik pro Event-Typ, und unter E2EE müssten die Events selbst
verschlüsselt werden — sonst leckt das Log genau die Metadaten, die der Blob schützen soll.
### 5. Keine Retention, aber ein Antwortlimit und ein `resync`-Pfad
Das Log wird vorerst **nicht** beschnitten. Eine Zeile pro Mutation ist für Postgres nichts,
und eine Aufräum-Mechanik, die niemand braucht, ist Wartungslast mit Fehlerpotenzial —
zumal sie bei der Regel „eine Binary + Postgres" entweder als Timer-Task in den Server
wandert oder `pg_cron` dazuzieht.
Stattdessen bekommt der Backfill ein hartes Limit pro Antwort. Wird es überschritten,
antwortet der Server mit `resync` statt mit einem Delta; der Client verwirft seinen Cache
für diese Conversation und lädt frisch.
**Dieser Pfad wird ohnehin gebraucht** — für das Handy, das drei Wochen aus war, für die
allererste Verbindung ohne Cursor, und laut
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) auch als
Reaktion auf `RecvError::Lagged`. Damit sind drei verschiedene Situationen auf **einen**
Mechanismus abgebildet.
Retention lässt sich später nachrüsten, ohne das Protokoll zu ändern.
### Begriffe
Für die einheitliche Sprache im Projekt festgehalten:
| Begriff | Bedeutung |
|---|---|
| **`seq`** | Monoton steigende Nummer pro Conversation, vergeben unter dem Zeilen-Lock der Conversation. Der einzige Ordnungsbegriff — nicht `created_at`, nicht `id`. |
| **Cursor** | Der `seq`-Stand, den ein Client für eine Conversation zuletzt gesehen hat. Ein `bigint`, mehr nicht. |
| **Event** | Eine Zeile in `conversation_events`. Signalisiert, *dass* sich etwas geändert hat; nicht *was* der neue Zustand ist. |
| **Backfill** | `?since=<cursor>`: der aufgelöste aktuelle Zustand aller seither betroffenen Entitäten. |
| **Resync** | Antwort des Servers, wenn ein Delta nicht sinnvoll bildbar ist (Cursor zu alt, kein Cursor, Limit überschritten). Der Client verwirft seinen Cache für die Conversation. |
| **Tombstone** | Eine soft-gelöschte Zeile, die der Backfill **mitliefert**, damit der Client die Löschung erfährt. |
### Abnahmekriterium — Status
Das Kriterium verlangte einen lauffähigen Test. **Er ist spezifiziert, aber nicht
geschrieben** — dieser Effort plant, und der Test setzt die Migration und den geänderten
Handler voraus, also Implementierungsarbeit. Die Spezifikation:
> Zwei Transaktionen A und B fügen gleichzeitig in dieselbe Conversation ein. A beginnt
> zuerst und committet zuletzt. Ein Leser pollt zwischen den Commits mit `?since=<cursor>`
> und merkt sich alles Gelieferte. Erwartung: nach beiden Commits hat der Leser **beide**
> Nachrichten gesehen.
>
> Der Test muss in der naiven Variante (`BIGSERIAL` oder `created_at` als Cursor)
> **fehlschlagen** — sonst misst er nicht, was er messen soll.
Der eigentliche Beweis liegt in der Konstruktion (§1); der Test bewacht die Implementierung
dagegen, dass jemand den `UPDATE … RETURNING` später aus der Transaktion herauszieht oder
durch ein `SELECT max(...)` ersetzt.
### Offene Kopplung
**Wo die Event-Zeile geschrieben wird — im Handler oder per DB-Trigger — ist hier bewusst
nicht entschieden.** Diese Frage steht in
[*Fan-out-Architektur*](08-fan-out-architektur.md), weil sie dort mit `NOTIFY` zusammenfällt:
ein Trigger feuert garantiert auf jedem Schreibweg und macht die zweite
Fan-out-Implementierung fast geschenkt, ein Handler-Aufruf ist dafür leichter zu lesen.