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.
230 lines
12 KiB
Markdown
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.
|