feat(realtime): add monotonic cursor, change log and WebSocket fan-out
Messages were only reachable by polling. This adds the server half of the realtime path: a cursor that cannot skip rows, a change log, and a socket that pushes "conversation X is at seq N". The cursor is a counter on conversations, drawn with UPDATE ... RETURNING inside the mutating transaction. That takes the conversation row lock, so a second writer blocks until the first commits and commit order equals seq order. A BIGSERIAL on messages does not have this property: it hands out numbers at INSERT time while rows become visible at COMMIT time, and those two orders diverge under concurrent senders — a reader following seq > n then loses a message permanently. Two tests cover this; the second one asserts that the naive design *does* lose a row, so the first is proven to measure something. created_at loses its ordering role. It is NOW(), i.e. transaction start, so it is neither unique nor commit-ordered — the existing ORDER BY created_at was already wrong, independently of realtime. Every mutation appends to conversation_events, and that row is the event. A three-line trigger on that table fires pg_notify, so no future write path can forget to signal, and NOTIFY being transactional means it fires exactly when the row becomes visible. Fan-out always goes through NOTIFY, even with a single instance. One path instead of two means the tested path is the production path, and running more than one instance needs no second implementation. Per connection the pending work is a map rather than a queue: only the newest seq per conversation matters, so a newer value overwrites an older one, there is no overflow and no overflow error path. Entries are merged with max(), because NOTIFY only orders per listener. When the listener reconnects, every client is told to resync — notifications sent during the gap are gone, as NOTIFY keeps no history. Sockets are closed by hand on shutdown: axum's graceful shutdown neither waits for nor closes upgraded connections (axum#3003, fix PR #3831 still open). Also adds membership checks to the message history endpoint, which had none, and paginates it — it previously returned entire conversations unbounded. Not yet done, marked as TODO in the code: reactions and attachments do not emit events, and create/update/delete still trust user_id from the request body.
This commit is contained in:
7
migrations/20260814120000_realtime_cursor.down.sql
Normal file
7
migrations/20260814120000_realtime_cursor.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
DROP TRIGGER IF EXISTS conversation_events_notify ON conversation_events;
|
||||
DROP FUNCTION IF EXISTS notify_conversation_event();
|
||||
DROP TABLE IF EXISTS conversation_events;
|
||||
DROP INDEX IF EXISTS messages_conversation_seq_idx;
|
||||
ALTER TABLE messages ALTER COLUMN seq DROP NOT NULL;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS seq;
|
||||
ALTER TABLE conversations DROP COLUMN IF EXISTS seq;
|
||||
69
migrations/20260814120000_realtime_cursor.up.sql
Normal file
69
migrations/20260814120000_realtime_cursor.up.sql
Normal file
@@ -0,0 +1,69 @@
|
||||
-- Realtime-Grundlage: monotoner Cursor je Conversation + Änderungs-Log.
|
||||
--
|
||||
-- Warum ein Zähler auf `conversations` und nicht BIGSERIAL auf `messages`:
|
||||
-- Eine Sequenz vergibt die Nummer beim INSERT, sichtbar wird die Zeile aber erst
|
||||
-- beim COMMIT. Diese beiden Reihenfolgen können auseinanderlaufen — Transaktion A
|
||||
-- holt 100, B holt 101 und committet zuerst; ein Leser bei `since = 101` verliert
|
||||
-- Nachricht 100 für immer. Der Zähler hier wird per UPDATE gezogen und nimmt damit
|
||||
-- den Zeilen-Lock der Conversation: der zweite Schreiber blockiert bis zum COMMIT
|
||||
-- des ersten, also ist Commit-Reihenfolge == seq-Reihenfolge.
|
||||
|
||||
ALTER TABLE conversations ADD COLUMN seq BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
-- Das Änderungs-Log. Eine Zeile je Mutation — diese Zeile *ist* der Event, der
|
||||
-- über den WebSocket geht. Deckt auch ab, was keine eigene Nachrichtenzeile hat
|
||||
-- (Reactions, Attachments, später Presence und Read-Receipts).
|
||||
CREATE TABLE conversation_events (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
seq BIGINT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target_id UUID NOT NULL,
|
||||
at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, seq)
|
||||
);
|
||||
-- Der Primärschlüssel bedient bereits `WHERE conversation_id = $1 AND seq > $2
|
||||
-- ORDER BY seq` — der Backfill braucht keinen weiteren Index.
|
||||
|
||||
-- Unveränderlicher Sortierschlüssel einer Nachricht: die seq ihres
|
||||
-- Erzeugungs-Events. Damit verliert `created_at` seine Sortierrolle — es ist NOW(),
|
||||
-- also der Transaktionsstart, weder eindeutig noch commit-geordnet.
|
||||
ALTER TABLE messages ADD COLUMN seq BIGINT;
|
||||
|
||||
-- Bestand nachziehen: nach created_at durchnummerieren, dann den Conversation-
|
||||
-- Zähler auf das Maximum setzen.
|
||||
WITH nummeriert AS (
|
||||
SELECT id, row_number() OVER (PARTITION BY conversation_id ORDER BY created_at, id) AS n
|
||||
FROM messages
|
||||
)
|
||||
UPDATE messages m
|
||||
SET seq = nummeriert.n
|
||||
FROM nummeriert
|
||||
WHERE m.id = nummeriert.id;
|
||||
|
||||
UPDATE conversations c
|
||||
SET seq = COALESCE((SELECT max(m.seq) FROM messages m WHERE m.conversation_id = c.id), 0);
|
||||
|
||||
ALTER TABLE messages ALTER COLUMN seq SET NOT NULL;
|
||||
CREATE UNIQUE INDEX messages_conversation_seq_idx ON messages(conversation_id, seq);
|
||||
|
||||
-- Für Bestandsnachrichten werden bewusst KEINE Log-Zeilen nachgetragen: ein Client
|
||||
-- ohne Cursor bekommt `resync` und lädt über den Historien-Endpunkt, nie über das
|
||||
-- Log. Das Log braucht also keine Vergangenheit, nur Zukunft.
|
||||
|
||||
-- Der Signalpfad. Der Trigger enthält keine Fachlogik — er feuert nur. Damit kann
|
||||
-- kein künftiger Schreibweg das Benachrichtigen vergessen, und weil NOTIFY
|
||||
-- transaktional ist, feuert er erst beim COMMIT, also genau dann, wenn die Zeile
|
||||
-- auch sichtbar wird.
|
||||
CREATE FUNCTION notify_conversation_event() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify(
|
||||
'conversation_changed',
|
||||
json_build_object('conversation', NEW.conversation_id, 'seq', NEW.seq)::text
|
||||
);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER conversation_events_notify
|
||||
AFTER INSERT ON conversation_events
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_conversation_event();
|
||||
Reference in New Issue
Block a user