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.
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 beiINSERT, aber Transaktionen werden in Commit-Reihenfolge sichtbar. Transaktion A holt ID 100, Transaktion B holt ID 101 und committet zuerst. Ein Client liest bissince=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
- Sequenz pro Conversation unter Lock — beim Insert
SELECT ... FOR UPDATEauf eine Zählerzeile der Conversation, dannseq = 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? - Snowflake-/ULID-artige ID — zeitbasiert + Zufall, monoton generierbar ohne DB-Rundtrip. Löst die Eindeutigkeit, aber nicht das Commit-Order-Problem.
- Sichtbarkeitsbasierter Cursor —
pg_current_snapshot()/xmin-basiertes Nachlesen, sodass der Cursor die Transaktionssichtbarkeit abbildet statt der Insert-Reihenfolge. Korrekt, aber deutlich exotischer. - 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
sincenachlädt, sieht sie nie. Braucht es einen zweiten Kanal, eineupdated_at-Achse, oder wird jede Änderung als neuer Cursor-Eintrag geführt? - Wie sieht die Migration aus?
messageshat 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 (
BIGSERIALodercreated_atals 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.