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

12 KiB

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

ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;

Jede Mutation zieht die nächste Nummer in derselben Transaktion wie die Änderung:

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

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:

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