# Monotoner Cursor Type: grilling Status: resolved Blocked by: — Parent: ../map.md ## Question Worauf zeigt der `?since=`-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=`: 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=` > 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.