Files
rustification-server/docs/realtime/issues/02-monotoner-cursor.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

230 lines
12 KiB
Markdown

# 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.