Phase 4: sync
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 4: the read model. /sync, /messages and /context, the filters that narrow them, and the streams a response carries that are not room events — typing, receipts, presence and to-device messages. With this a client can be used against Warp, which is milestone M1. The one architectural choice is that the syncapi has no copy of the rooms: it reads them through the roomserver. Dendrite gives its syncapi its own tables and feeds them from the output stream, which is right for a design where the two can be separate processes, and Dendrite's could be. Warp is one process by decision, and there the copy buys nothing and costs the hardest class of bug a homeserver has — a read model that disagrees with the room, where a client is shown a message that was rejected and the only way to find out is to compare two tables. So the roomserver grew a read model (stream.go, roomsync.go, visibility.go) rather than exporting its store: reading an event means parsing it under its room's version, and a second copy of the version registry, the parser and the client projection would be a second place for them to disagree. syncapi: stream tokens naming a position in each of five independent streams, so a client that has seen receipt 7 but only event 4 can say so in one opaque string. A token with fewer positions than this build writes is accepted and the rest read as zero, which is what lets a token stored before a stream existed go on working. The PDU position comes from MAX(stream_ordering) and not from the counter row the other four use: the counter is advanced inside the writing transaction, so a token taken from it would name a position whose event no reader can see yet, and the event would be skipped for good once that transaction committed. Long polling goes through a notifier that wakes by room and by user rather than globally — an event wakes the listeners of its room, a membership event also wakes the user it names, so an invite reaches a client that is not in the room yet. A wake-up is only ever a hint: the listener re-reads the streams and decides for itself what changed, which is what makes it safe for the notifier to hold no state about what each client has seen. The listener registers before the read, so an event landing in between leaves the channel signalled and the sync reads again rather than blocking on a change it just missed. EventPersisted must not block, because it is called with the room's write lock still held. "Something happened" is not "a token moved". A stream can move without this user being concerned by it, and returning on every such move would turn a long poll back into a busy loop. The same trap caught presence twice over: a client says it is still online by syncing, so ?set_presence= makes every sync a presence write, and a stream that moved each time would wake every peer, which would sync, which would move it again. SetPresence therefore reports whether anything a peer would notice actually changed, and a write that only refreshes the last-active timestamp leaves the stream alone. Timeline windows are named by their two ends and a direction, Low exclusive and High inclusive in both directions, rather than by "from" and "to" as the wire format spells them. Each of those would otherwise be the low bound in one direction and the high bound in the other, which is exactly the off-by-one every pagination bug is made of; the handlers translate once and nothing below them thinks about it again. A sync's timeline is read backwards and reported forwards, because a client wants the newest events of the window it missed — a forward read with a limit hands it the oldest and then calls the rest a gap. visibility: history visibility is now a span rather than a yes or no, which is what Phase 3 deferred here. A user who has left a room is served the room as it was when they left — their state reads, their members and their timeline — instead of being refused, and is bounded above at their own membership event so a kicked client cannot go on streaming. Their span is found from their own member events, so a user who joined, left and rejoined sees their latest span. ?at= on /members is honoured for the same reason. What is left is that the span is computed from the room's current history_visibility where the spec judges each event by the one in force when it was sent; that is a state read per event and belongs with open question 1. Typing is held in memory and nowhere else: notifications expire in seconds, are worthless across a restart, and arrive one per keystroke-burst per room, so persisting them would be the busiest table in the database holding the least valuable data in it. Receipts only ever advance, compared on the position of the event acknowledged rather than on arrival, because two of a user's clients read at different speeds and the slower one must not drag the marker backwards. To-device queues are per device and drained by the next sync rather than by the one that delivered them, so a client that crashes in between gets the batch again — the right way round, since a lost key share leaves a message permanently undecryptable and a duplicate is ignored. A wildcard addressee is expanded when sent, not stored, because "every device" means the ones that existed then. Presence is off by default, which answers PLAN.md open question 3. It is the noisiest thing in Matrix and the one genuinely fan-out notification here; a private homeserver gains little from it. With it off the endpoints refuse rather than disappearing, because a client that gets a 404 from /presence concludes the server is too old to have it and stops asking. storage: one migration per driver adding the receipt, to-device, presence and filter tables with a counter row each, and a stream position on the existing account data table rather than a parallel one — there is exactly one current value per (user, room, type), and a second table would be a second thing to keep in step. Account data is reported in an event list but is not an event, so it gets its own response type: a room event with an empty sender and no timestamp would make a client decide what that meant. Deliberately missing, each named in docs/sync.md: notification counts (they need push rules, Phase 8), device lists (the keyserver, Phase 7), anything remote (Phase 5), threads (Phase 10). device_lists is sent as two empty lists because a client reads a missing one as malformed; the notification counts are left out entirely, because a zero there is a claim and an absent field is not. Tested through HTTP rather than against the builder, because what is worth pinning down is the response a client sees — which section a room lands in, whether a token handed back can be handed in again, whether paging terminates and loses nothing. The storage tests run against both SQL drivers; the notifier has its own tests for the concurrent case. A live server was driven end to end, which is what caught the two bugs the unit tests had missed: a backwards /messages page must be in reverse chronological order, and `start` must name where the page began rather than position zero when ?from= was omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c96f88e994
commit
d187ad0d17
60 files changed
+10196
-264
No files matched your search
@@ -57,6 +57,9 @@ func (*Database) Users() storage.UserStore { return notImplementedUsers{} }
|
||||
// Rooms implements storage.Database.
|
||||
func (*Database) Rooms() storage.RoomStore { return notImplementedRooms{} }
|
||||
|
||||
// Sync implements storage.Database.
|
||||
func (*Database) Sync() storage.SyncStore { return notImplementedSync{} }
|
||||
|
||||
// Close implements storage.Database.
|
||||
func (*Database) Close() error { return nil }
|
||||
|
||||
@@ -227,6 +230,30 @@ func (notImplementedRooms) RoomsForUser(context.Context, string, []string) ([]st
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) MembershipsForUser(context.Context, string, []string) ([]storage.SyncMembership, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) UserMembershipEvents(context.Context, string, string) ([]storage.StoredEvent, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) MaxStreamOrdering(context.Context) (int64, error) {
|
||||
return 0, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) Timeline(context.Context, storage.TimelineRequest) ([]storage.StoredEvent, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) RoomsWithEvents(context.Context, []string, int64, int64) ([]string, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) StateAtStream(context.Context, string, int64) ([]storage.StateEntry, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedRooms) TransactionEvent(context.Context, storage.TransactionRef) (string, error) {
|
||||
return "", storage.ErrNotImplemented
|
||||
}
|
||||
@@ -271,9 +298,71 @@ func (notImplementedRooms) RoomStats(context.Context) (storage.RoomStats, error)
|
||||
return storage.RoomStats{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// notImplementedSync satisfies storage.SyncStore the same way. A sync that
|
||||
// returned an empty success would be worse than an error: a client would
|
||||
// take it for "nothing has happened" and stop asking.
|
||||
type notImplementedSync struct{}
|
||||
|
||||
func (notImplementedSync) StreamPositions(context.Context) (storage.StreamPositions, error) {
|
||||
return storage.StreamPositions{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) SetReceipt(context.Context, storage.Receipt) (int64, bool, error) {
|
||||
return 0, false, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) Receipts(context.Context, []string, int64, int64) ([]storage.Receipt, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) ReceiptsForUser(context.Context, string, []string) ([]storage.Receipt, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) SendToDevice(context.Context, []storage.NewToDeviceMessage) (int64, error) {
|
||||
return 0, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) ToDeviceMessages(context.Context, string, string, int64, int64, int) ([]storage.ToDeviceMessage, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) DeleteToDeviceMessages(context.Context, string, string, int64) error {
|
||||
return storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) SetPresence(context.Context, storage.PresenceState) (int64, bool, error) {
|
||||
return 0, false, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) Presence(context.Context, string) (storage.PresenceState, error) {
|
||||
return storage.PresenceState{}, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) PresenceForUsers(context.Context, []string, int64, int64) ([]storage.PresenceState, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) AccountDataChanges(context.Context, string, int64, int64) ([]storage.AccountDataEntry, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) AccountDataAt(context.Context, string, int64) ([]storage.AccountDataEntry, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) SaveFilter(context.Context, string, []byte, int64) (string, error) {
|
||||
return "", storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
func (notImplementedSync) Filter(context.Context, string, string) ([]byte, error) {
|
||||
return nil, storage.ErrNotImplemented
|
||||
}
|
||||
|
||||
// Compile-time proof that the stub keeps up with the interfaces.
|
||||
var (
|
||||
_ storage.Database = (*Database)(nil)
|
||||
_ storage.UserStore = notImplementedUsers{}
|
||||
_ storage.RoomStore = notImplementedRooms{}
|
||||
_ storage.SyncStore = notImplementedSync{}
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
-- The sync read model: the streams /sync reports that are not room events,
|
||||
-- and the filters a client uploads.
|
||||
--
|
||||
-- A room event's position in the event stream is its stream_ordering in
|
||||
-- roomserver_events, and nothing here duplicates it. What is here is
|
||||
-- everything else a sync response carries, each with a stream of its own:
|
||||
-- read receipts, to-device messages, presence and account data all change
|
||||
-- without any event being sent, and a client that has seen receipt 7 but
|
||||
-- only event 4 has to be able to say so in one token.
|
||||
--
|
||||
-- Typing notifications are deliberately absent. They expire after seconds,
|
||||
-- they are worthless after a restart, and writing them to disk would mean a
|
||||
-- row per keystroke-burst per room; they live in memory in the syncapi.
|
||||
|
||||
-- One counter row per stream, incremented inside the writing transaction.
|
||||
-- Same reasoning as roomserver_sequences: an UPDATE takes a row lock in
|
||||
-- both engines and hands out gapless positions, which a sequence does not.
|
||||
CREATE TABLE IF NOT EXISTS sync_sequences (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
value BIGINT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('receipt', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('to_device', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('account_data', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('presence', 0) ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Account data gets a stream position so that an incremental sync can ask
|
||||
-- for the blobs that changed rather than re-sending every one of them. The
|
||||
-- column goes on the existing table rather than into a parallel one: there
|
||||
-- is exactly one current value per (user, room, type), and a second table
|
||||
-- would be a second thing to keep in step with it.
|
||||
ALTER TABLE user_account_data ADD COLUMN stream_pos BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_account_data_stream_idx
|
||||
ON user_account_data (localpart, stream_pos);
|
||||
|
||||
-- Read receipts. Only the latest receipt of each (user, type, thread) in a
|
||||
-- room is kept: a receipt supersedes the one before it, and /sync reports
|
||||
-- the current one rather than a history.
|
||||
CREATE TABLE IF NOT EXISTS sync_receipts (
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
-- m.read or m.read.private.
|
||||
receipt_type TEXT NOT NULL,
|
||||
-- The thread the receipt is for, or '' for the room's main timeline.
|
||||
-- Threads are Phase 10; the column is here because the key would
|
||||
-- otherwise have to change when they arrive, and a receipt table is
|
||||
-- not something to migrate twice.
|
||||
thread_id TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL,
|
||||
-- When the receipt was sent, which clients show as "read at".
|
||||
ts_ms BIGINT NOT NULL,
|
||||
stream_pos BIGINT NOT NULL,
|
||||
PRIMARY KEY (room_id, user_id, receipt_type, thread_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_receipts_stream_idx
|
||||
ON sync_receipts (stream_pos);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_receipts_room_stream_idx
|
||||
ON sync_receipts (room_id, stream_pos);
|
||||
|
||||
-- To-device messages: a queue per device, drained by the device
|
||||
-- acknowledging a later sync token rather than by reading it.
|
||||
CREATE TABLE IF NOT EXISTS sync_to_device (
|
||||
stream_pos BIGINT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sent_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_to_device_device_idx
|
||||
ON sync_to_device (localpart, device_id, stream_pos);
|
||||
|
||||
-- Presence. One row per user, the current state and nothing historical.
|
||||
CREATE TABLE IF NOT EXISTS sync_presence (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
presence TEXT NOT NULL,
|
||||
status_msg TEXT NOT NULL DEFAULT '',
|
||||
last_active_ms BIGINT NOT NULL,
|
||||
stream_pos BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_presence_stream_idx
|
||||
ON sync_presence (stream_pos);
|
||||
|
||||
-- Uploaded filters. The JSON is stored as the client sent it: a filter is
|
||||
-- returned verbatim by GET /user/{userID}/filter/{filterID}, and clients
|
||||
-- compare what they get back with what they uploaded.
|
||||
CREATE TABLE IF NOT EXISTS sync_filters (
|
||||
localpart TEXT NOT NULL,
|
||||
filter_id TEXT NOT NULL,
|
||||
filter_json TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (localpart, filter_id)
|
||||
);
|
||||
@@ -310,6 +310,45 @@ type RoomStore interface {
|
||||
// in, ordered by room ID.
|
||||
RoomsForUser(ctx context.Context, userID string, memberships []string) ([]string, error)
|
||||
|
||||
// MembershipsForUser is RoomsForUser with the membership and the
|
||||
// position of the event that set it: one query for everything a sync
|
||||
// needs to know about where a user stands.
|
||||
MembershipsForUser(ctx context.Context, userID string, memberships []string) ([]SyncMembership, error)
|
||||
|
||||
// UserMembershipEvents returns every m.room.member event of one user in
|
||||
// one room, oldest first.
|
||||
//
|
||||
// It is how far back a user's own view of a room reaches: a user who
|
||||
// joined, left and rejoined has several spans, and only their own
|
||||
// membership events say where each one began. There is one row per
|
||||
// membership change of one user in one room, so this stays small
|
||||
// however busy the room is.
|
||||
UserMembershipEvents(ctx context.Context, roomID, userID string) ([]StoredEvent, error)
|
||||
|
||||
// MaxStreamOrdering returns the top of this server's event stream, or 0
|
||||
// for a server that has persisted no events.
|
||||
MaxStreamOrdering(ctx context.Context) (int64, error)
|
||||
|
||||
// Timeline reads one window of a room's event stream. See
|
||||
// TimelineRequest: the bounds are exclusive and the limit is required.
|
||||
Timeline(ctx context.Context, req TimelineRequest) ([]StoredEvent, error)
|
||||
|
||||
// RoomsWithEvents returns the subset of roomIDs that have an event
|
||||
// after from and at or before to.
|
||||
//
|
||||
// It is what keeps an incremental sync down to one query for the common
|
||||
// case of nothing having happened: a user in two hundred rooms would
|
||||
// otherwise need two hundred timeline reads to find that out.
|
||||
RoomsWithEvents(ctx context.Context, roomIDs []string, from, to int64) ([]string, error)
|
||||
|
||||
// StateAtStream returns the room's state as of a stream position: the
|
||||
// state after the last event at or before pos.
|
||||
//
|
||||
// It answers the two questions current state cannot. A user who has
|
||||
// left sees the room as it was when they left, and a sync with a gap in
|
||||
// it has to say which state changed inside that gap.
|
||||
StateAtStream(ctx context.Context, roomID string, pos int64) ([]StateEntry, error)
|
||||
|
||||
// TransactionEvent returns the event a client transaction ID already
|
||||
// produced, or ErrNoSuchEvent when this is the first time it is seen.
|
||||
TransactionEvent(ctx context.Context, ref TransactionRef) (string, error)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- The sync read model: the streams /sync reports that are not room events,
|
||||
-- and the filters a client uploads.
|
||||
--
|
||||
-- A room event's position in the event stream is its stream_ordering in
|
||||
-- roomserver_events, and nothing here duplicates it. What is here is
|
||||
-- everything else a sync response carries, each with a stream of its own:
|
||||
-- read receipts, to-device messages, presence and account data all change
|
||||
-- without any event being sent, and a client that has seen receipt 7 but
|
||||
-- only event 4 has to be able to say so in one token.
|
||||
--
|
||||
-- Typing notifications are deliberately absent. They expire after seconds,
|
||||
-- they are worthless after a restart, and writing them to disk would mean a
|
||||
-- row per keystroke-burst per room; they live in memory in the syncapi.
|
||||
|
||||
-- One counter row per stream, incremented inside the writing transaction.
|
||||
-- Same reasoning as roomserver_sequences: an UPDATE takes a row lock in
|
||||
-- both engines and hands out gapless positions, which a sequence does not.
|
||||
CREATE TABLE IF NOT EXISTS sync_sequences (
|
||||
name TEXT NOT NULL PRIMARY KEY,
|
||||
value BIGINT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('receipt', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('to_device', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('account_data', 0) ON CONFLICT (name) DO NOTHING;
|
||||
INSERT INTO sync_sequences (name, value) VALUES ('presence', 0) ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Account data gets a stream position so that an incremental sync can ask
|
||||
-- for the blobs that changed rather than re-sending every one of them. The
|
||||
-- column goes on the existing table rather than into a parallel one: there
|
||||
-- is exactly one current value per (user, room, type), and a second table
|
||||
-- would be a second thing to keep in step with it.
|
||||
ALTER TABLE user_account_data ADD COLUMN stream_pos BIGINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS user_account_data_stream_idx
|
||||
ON user_account_data (localpart, stream_pos);
|
||||
|
||||
-- Read receipts. Only the latest receipt of each (user, type, thread) in a
|
||||
-- room is kept: a receipt supersedes the one before it, and /sync reports
|
||||
-- the current one rather than a history.
|
||||
CREATE TABLE IF NOT EXISTS sync_receipts (
|
||||
room_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
-- m.read or m.read.private.
|
||||
receipt_type TEXT NOT NULL,
|
||||
-- The thread the receipt is for, or '' for the room's main timeline.
|
||||
-- Threads are Phase 10; the column is here because the key would
|
||||
-- otherwise have to change when they arrive, and a receipt table is
|
||||
-- not something to migrate twice.
|
||||
thread_id TEXT NOT NULL DEFAULT '',
|
||||
event_id TEXT NOT NULL,
|
||||
-- When the receipt was sent, which clients show as "read at".
|
||||
ts_ms BIGINT NOT NULL,
|
||||
stream_pos BIGINT NOT NULL,
|
||||
PRIMARY KEY (room_id, user_id, receipt_type, thread_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_receipts_stream_idx
|
||||
ON sync_receipts (stream_pos);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_receipts_room_stream_idx
|
||||
ON sync_receipts (room_id, stream_pos);
|
||||
|
||||
-- To-device messages: a queue per device, drained by the device
|
||||
-- acknowledging a later sync token rather than by reading it.
|
||||
CREATE TABLE IF NOT EXISTS sync_to_device (
|
||||
stream_pos BIGINT NOT NULL PRIMARY KEY,
|
||||
localpart TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
sent_at_ms BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_to_device_device_idx
|
||||
ON sync_to_device (localpart, device_id, stream_pos);
|
||||
|
||||
-- Presence. One row per user, the current state and nothing historical.
|
||||
CREATE TABLE IF NOT EXISTS sync_presence (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
presence TEXT NOT NULL,
|
||||
status_msg TEXT NOT NULL DEFAULT '',
|
||||
last_active_ms BIGINT NOT NULL,
|
||||
stream_pos BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sync_presence_stream_idx
|
||||
ON sync_presence (stream_pos);
|
||||
|
||||
-- Uploaded filters. The JSON is stored as the client sent it: a filter is
|
||||
-- returned verbatim by GET /user/{userID}/filter/{filterID}, and clients
|
||||
-- compare what they get back with what they uploaded.
|
||||
CREATE TABLE IF NOT EXISTS sync_filters (
|
||||
localpart TEXT NOT NULL,
|
||||
filter_id TEXT NOT NULL,
|
||||
filter_json TEXT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (localpart, filter_id)
|
||||
);
|
||||
@@ -167,27 +167,10 @@ func (s *Rooms) Write(ctx context.Context, w storage.RoomWrite) (int64, error) {
|
||||
return ordering, nil
|
||||
}
|
||||
|
||||
const (
|
||||
bumpSequenceSQL = `UPDATE roomserver_sequences SET value = value + 1 WHERE name = ?`
|
||||
selectSequenceSQL = `SELECT value FROM roomserver_sequences WHERE name = ?`
|
||||
)
|
||||
|
||||
// nextSequence hands out the next value of a counter.
|
||||
//
|
||||
// The UPDATE takes a row lock that both engines hold until the transaction
|
||||
// ends, so two concurrent writers queue rather than collide, and a value
|
||||
// handed out by a transaction that then rolls back is never used — which is
|
||||
// what keeps the stream gapless. A database sequence would be faster and
|
||||
// would not have that property.
|
||||
// nextSequence hands out the next value of one of this component's
|
||||
// counters; see sequences.go for why they are rows rather than sequences.
|
||||
func (s *Rooms) nextSequence(ctx context.Context, tx *sql.Tx, name string) (int64, error) {
|
||||
if _, err := tx.ExecContext(ctx, s.q(bumpSequenceSQL), name); err != nil {
|
||||
return 0, fmt.Errorf("advance the %s counter: %w", name, err)
|
||||
}
|
||||
var value int64
|
||||
if err := tx.QueryRowContext(ctx, s.q(selectSequenceSQL), name).Scan(&value); err != nil {
|
||||
return 0, fmt.Errorf("read the %s counter: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
return nextSequence(ctx, tx, s.q, roomSequences, name)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The sequence tables. Each component owns its own, so that a component
|
||||
// added later does not need a migration to a table another one is already
|
||||
// writing.
|
||||
const (
|
||||
roomSequences = "roomserver_sequences"
|
||||
syncSequences = "sync_sequences"
|
||||
)
|
||||
|
||||
// The counters in sync_sequences.
|
||||
const (
|
||||
seqReceipt = "receipt"
|
||||
seqToDevice = "to_device"
|
||||
seqAccountData = "account_data"
|
||||
seqPresence = "presence"
|
||||
)
|
||||
|
||||
// execQuerier is the part of *sql.Tx and *sql.DB that the sequence helpers
|
||||
// need, so that a counter can be read outside a transaction and bumped
|
||||
// inside one.
|
||||
type execQuerier interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
|
||||
}
|
||||
|
||||
// nextSequence hands out the next value of a counter.
|
||||
//
|
||||
// The UPDATE takes a row lock that both engines hold until the transaction
|
||||
// ends, so two concurrent writers queue rather than collide, and a value
|
||||
// handed out by a transaction that then rolls back is never used — which is
|
||||
// what keeps a stream gapless. A database sequence would be faster and
|
||||
// would not have that property: PostgreSQL's are explicitly not
|
||||
// transactional, so a rolled-back write would leave a hole that every
|
||||
// reader of the stream would have to be taught to skip.
|
||||
//
|
||||
// It must be called inside a transaction. Called on a bare *sql.DB the two
|
||||
// statements are two transactions, and two callers can then be handed the
|
||||
// same value.
|
||||
func nextSequence(ctx context.Context, db execQuerier, q func(string) string, table, name string) (int64, error) {
|
||||
bump := fmt.Sprintf(`UPDATE %s SET value = value + 1 WHERE name = ?`, table)
|
||||
if _, err := db.ExecContext(ctx, q(bump), name); err != nil {
|
||||
return 0, fmt.Errorf("advance the %s counter: %w", name, err)
|
||||
}
|
||||
return currentSequence(ctx, db, q, table, name)
|
||||
}
|
||||
|
||||
// currentSequence reads a counter without advancing it: the top of a stream
|
||||
// as a sync token has to name it.
|
||||
func currentSequence(ctx context.Context, db execQuerier, q func(string) string, table, name string) (int64, error) {
|
||||
query := fmt.Sprintf(`SELECT value FROM %s WHERE name = ?`, table)
|
||||
var value int64
|
||||
if err := db.QueryRowContext(ctx, q(query), name).Scan(&value); err != nil {
|
||||
return 0, fmt.Errorf("read the %s counter: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -58,6 +58,7 @@ type Store struct {
|
||||
settings *Settings
|
||||
users *Users
|
||||
rooms *Rooms
|
||||
sync *Sync
|
||||
}
|
||||
|
||||
// New builds a Store. It does not touch the database.
|
||||
@@ -75,6 +76,7 @@ func New(opts Options) (*Store, error) {
|
||||
settings: NewSettings(opts.DB, opts.Settings),
|
||||
users: NewUsers(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
rooms: NewRooms(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
sync: NewSync(opts.DB, opts.Rebind, opts.IsUniqueViolation),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -99,6 +101,9 @@ func (s *Store) Users() storage.UserStore { return s.users }
|
||||
// Rooms implements storage.Database.
|
||||
func (s *Store) Rooms() storage.RoomStore { return s.rooms }
|
||||
|
||||
// Sync implements storage.Database.
|
||||
func (s *Store) Sync() storage.SyncStore { return s.sync }
|
||||
|
||||
// Migrate implements storage.Database.
|
||||
func (s *Store) Migrate(ctx context.Context) error {
|
||||
ms, err := s.migrations()
|
||||
|
||||
@@ -0,0 +1,585 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// Sync is the shared SQL implementation of storage.SyncStore: the streams a
|
||||
// sync response carries that are not room events.
|
||||
//
|
||||
// Like the other component stores it is written once with `?` placeholders
|
||||
// and rebound per driver. Every stream here follows the same shape — a
|
||||
// counter row handed out inside the writing transaction, and a stream_pos
|
||||
// column on the row it stamps — so that a reader can ask any of them "what
|
||||
// changed after position N" with one index lookup.
|
||||
type Sync struct {
|
||||
db *sql.DB
|
||||
rebind func(string) string
|
||||
isUnique func(error) bool
|
||||
}
|
||||
|
||||
// NewSync builds a sync store.
|
||||
func NewSync(db *sql.DB, rebind func(string) string, isUnique func(error) bool) *Sync {
|
||||
if rebind == nil {
|
||||
rebind = func(query string) string { return query }
|
||||
}
|
||||
if isUnique == nil {
|
||||
isUnique = func(error) bool { return false }
|
||||
}
|
||||
return &Sync{db: db, rebind: rebind, isUnique: isUnique}
|
||||
}
|
||||
|
||||
// maxToDeviceBatch bounds how many to-device messages one sync carries. A
|
||||
// device that has been offline for a week can have a long queue, and the
|
||||
// rest of it is still there on the next sync.
|
||||
const maxToDeviceBatch = 100
|
||||
|
||||
// q rebinds a statement for this driver.
|
||||
func (s *Sync) q(query string) string { return s.rebind(query) }
|
||||
|
||||
// tx runs fn in a transaction, rolling back on any error.
|
||||
func (s *Sync) tx(ctx context.Context, fn func(*sql.Tx) error) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const selectStreamPositionsSQL = `
|
||||
SELECT name, value FROM sync_sequences`
|
||||
|
||||
// StreamPositions implements storage.SyncStore.
|
||||
//
|
||||
// The PDU position comes from the events table and the rest from the
|
||||
// counter rows, and both are read here so that a caller gets one consistent
|
||||
// set. The counters are read rather than the rows they stamp because a
|
||||
// stream whose latest row was deleted — a drained to-device queue — must
|
||||
// not appear to have gone backwards.
|
||||
func (s *Sync) StreamPositions(ctx context.Context) (storage.StreamPositions, error) {
|
||||
var out storage.StreamPositions
|
||||
|
||||
err := s.db.QueryRowContext(ctx, s.q(selectMaxStreamOrderingSQL)).Scan(&out.PDU)
|
||||
if err != nil {
|
||||
return storage.StreamPositions{}, fmt.Errorf("read the top of the event stream: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(selectStreamPositionsSQL))
|
||||
if err != nil {
|
||||
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
name string
|
||||
value int64
|
||||
)
|
||||
if err := rows.Scan(&name, &value); err != nil {
|
||||
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
|
||||
}
|
||||
switch name {
|
||||
case seqReceipt:
|
||||
out.Receipt = value
|
||||
case seqToDevice:
|
||||
out.ToDevice = value
|
||||
case seqAccountData:
|
||||
out.AccountData = value
|
||||
case seqPresence:
|
||||
out.Presence = value
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return storage.StreamPositions{}, fmt.Errorf("read the stream positions: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
const (
|
||||
selectReceiptPositionSQL = `
|
||||
SELECT e.stream_ordering FROM roomserver_events e
|
||||
WHERE e.room_id = ? AND e.event_id = ?`
|
||||
|
||||
selectCurrentReceiptSQL = `
|
||||
SELECT r.event_id, e.stream_ordering, r.stream_pos
|
||||
FROM sync_receipts r
|
||||
LEFT JOIN roomserver_events e ON e.event_id = r.event_id
|
||||
WHERE r.room_id = ? AND r.user_id = ? AND r.receipt_type = ? AND r.thread_id = ?`
|
||||
|
||||
upsertReceiptSQL = `
|
||||
INSERT INTO sync_receipts
|
||||
(room_id, user_id, receipt_type, thread_id, event_id, ts_ms, stream_pos)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (room_id, user_id, receipt_type, thread_id) DO UPDATE SET
|
||||
event_id = excluded.event_id,
|
||||
ts_ms = excluded.ts_ms,
|
||||
stream_pos = excluded.stream_pos`
|
||||
)
|
||||
|
||||
// SetReceipt implements storage.SyncStore.
|
||||
//
|
||||
// A receipt that would move backwards is dropped. Two clients of one user
|
||||
// read a room at different speeds, and letting the slower one win would
|
||||
// make the unread marker jump backwards on the other — so the comparison is
|
||||
// on the position of the event being acknowledged, not on when the receipt
|
||||
// arrived.
|
||||
func (s *Sync) SetReceipt(ctx context.Context, r storage.Receipt) (int64, bool, error) {
|
||||
var (
|
||||
pos int64
|
||||
moved bool
|
||||
)
|
||||
err := s.tx(ctx, func(tx *sql.Tx) error {
|
||||
var target int64
|
||||
err := tx.QueryRowContext(ctx, s.q(selectReceiptPositionSQL), r.RoomID, r.EventID).Scan(&target)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("event %q: %w", r.EventID, storage.ErrNoSuchEvent)
|
||||
case err != nil:
|
||||
return fmt.Errorf("locate the event a receipt names: %w", err)
|
||||
}
|
||||
|
||||
var (
|
||||
haveEventID string
|
||||
havePos sql.NullInt64
|
||||
haveStream int64
|
||||
)
|
||||
err = tx.QueryRowContext(ctx, s.q(selectCurrentReceiptSQL),
|
||||
r.RoomID, r.UserID, r.ReceiptType, r.ThreadID).Scan(&haveEventID, &havePos, &haveStream)
|
||||
switch {
|
||||
case err == nil:
|
||||
// A receipt whose event has since been purged has no position
|
||||
// to compare against, and the new one supersedes it.
|
||||
if havePos.Valid && havePos.Int64 >= target {
|
||||
pos, moved = haveStream, false
|
||||
return nil
|
||||
}
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("read the current receipt: %w", err)
|
||||
}
|
||||
|
||||
if pos, err = nextSequence(ctx, tx, s.q, syncSequences, seqReceipt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q(upsertReceiptSQL),
|
||||
r.RoomID, r.UserID, r.ReceiptType, r.ThreadID, r.EventID, r.TimestampMS, pos); err != nil {
|
||||
return fmt.Errorf("write the receipt: %w", err)
|
||||
}
|
||||
moved = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("set receipt in %q: %w", r.RoomID, err)
|
||||
}
|
||||
return pos, moved, nil
|
||||
}
|
||||
|
||||
const receiptColumns = `
|
||||
room_id, user_id, receipt_type, thread_id, event_id, ts_ms, stream_pos`
|
||||
|
||||
func scanReceipts(rows *sql.Rows) ([]storage.Receipt, error) {
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []storage.Receipt
|
||||
for rows.Next() {
|
||||
var r storage.Receipt
|
||||
if err := rows.Scan(&r.RoomID, &r.UserID, &r.ReceiptType, &r.ThreadID,
|
||||
&r.EventID, &r.TimestampMS, &r.StreamPos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Receipts implements storage.SyncStore.
|
||||
func (s *Sync) Receipts(ctx context.Context, roomIDs []string, from, to int64) ([]storage.Receipt, error) {
|
||||
if len(roomIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `SELECT ` + receiptColumns + ` FROM sync_receipts
|
||||
WHERE room_id IN (` + placeholders(len(roomIDs)) + `) AND stream_pos > ?`
|
||||
args := make([]any, 0, len(roomIDs)+2)
|
||||
for _, id := range roomIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, from)
|
||||
if to > 0 {
|
||||
query += ` AND stream_pos <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY stream_pos`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read receipts: %w", err)
|
||||
}
|
||||
receipts, err := scanReceipts(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read receipts: %w", err)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
// ReceiptsForUser implements storage.SyncStore.
|
||||
func (s *Sync) ReceiptsForUser(ctx context.Context, userID string, roomIDs []string) ([]storage.Receipt, error) {
|
||||
if len(roomIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `SELECT ` + receiptColumns + ` FROM sync_receipts
|
||||
WHERE user_id = ? AND room_id IN (` + placeholders(len(roomIDs)) + `)
|
||||
ORDER BY stream_pos`
|
||||
args := make([]any, 0, len(roomIDs)+1)
|
||||
args = append(args, userID)
|
||||
for _, id := range roomIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the receipts of %q: %w", userID, err)
|
||||
}
|
||||
receipts, err := scanReceipts(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the receipts of %q: %w", userID, err)
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
|
||||
const insertToDeviceSQL = `
|
||||
INSERT INTO sync_to_device
|
||||
(stream_pos, localpart, device_id, sender, event_type, content, sent_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
// SendToDevice implements storage.SyncStore.
|
||||
//
|
||||
// The whole batch shares one transaction but takes a position each: a
|
||||
// message to two of a user's devices is two independent deliveries, and one
|
||||
// device acknowledging its copy must not drop the other's.
|
||||
func (s *Sync) SendToDevice(ctx context.Context, msgs []storage.NewToDeviceMessage) (int64, error) {
|
||||
var top int64
|
||||
err := s.tx(ctx, func(tx *sql.Tx) error {
|
||||
if len(msgs) == 0 {
|
||||
var err error
|
||||
top, err = currentSequence(ctx, tx, s.q, syncSequences, seqToDevice)
|
||||
return err
|
||||
}
|
||||
for _, m := range msgs {
|
||||
pos, err := nextSequence(ctx, tx, s.q, syncSequences, seqToDevice)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q(insertToDeviceSQL),
|
||||
pos, m.Localpart, m.DeviceID, m.Sender, m.Type, string(m.Content), m.SentAtMS); err != nil {
|
||||
return fmt.Errorf("queue a to-device message for %s/%s: %w", m.Localpart, m.DeviceID, err)
|
||||
}
|
||||
top = pos
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("send to-device messages: %w", err)
|
||||
}
|
||||
return top, nil
|
||||
}
|
||||
|
||||
const selectToDeviceSQL = `
|
||||
SELECT stream_pos, localpart, device_id, sender, event_type, content, sent_at_ms
|
||||
FROM sync_to_device
|
||||
WHERE localpart = ? AND device_id = ? AND stream_pos > ?`
|
||||
|
||||
// ToDeviceMessages implements storage.SyncStore.
|
||||
func (s *Sync) ToDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]storage.ToDeviceMessage, error) {
|
||||
if limit <= 0 || limit > maxToDeviceBatch {
|
||||
limit = maxToDeviceBatch
|
||||
}
|
||||
|
||||
query := selectToDeviceSQL
|
||||
args := []any{localpart, deviceID, from}
|
||||
if to > 0 {
|
||||
query += ` AND stream_pos <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY stream_pos LIMIT ?`
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read to-device messages for %s/%s: %w", localpart, deviceID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []storage.ToDeviceMessage
|
||||
for rows.Next() {
|
||||
var (
|
||||
m storage.ToDeviceMessage
|
||||
content string
|
||||
)
|
||||
if err := rows.Scan(&m.StreamPos, &m.Localpart, &m.DeviceID,
|
||||
&m.Sender, &m.Type, &content, &m.SentAtMS); err != nil {
|
||||
return nil, fmt.Errorf("read to-device messages for %s/%s: %w", localpart, deviceID, err)
|
||||
}
|
||||
m.Content = []byte(content)
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const deleteToDeviceSQL = `
|
||||
DELETE FROM sync_to_device
|
||||
WHERE localpart = ? AND device_id = ? AND stream_pos <= ?`
|
||||
|
||||
// DeleteToDeviceMessages implements storage.SyncStore.
|
||||
func (s *Sync) DeleteToDeviceMessages(ctx context.Context, localpart, deviceID string, pos int64) error {
|
||||
if pos <= 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, s.q(deleteToDeviceSQL), localpart, deviceID, pos); err != nil {
|
||||
return fmt.Errorf("delete to-device messages for %s/%s: %w", localpart, deviceID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
upsertPresenceSQL = `
|
||||
INSERT INTO sync_presence (user_id, presence, status_msg, last_active_ms, stream_pos)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
presence = excluded.presence,
|
||||
status_msg = excluded.status_msg,
|
||||
last_active_ms = excluded.last_active_ms,
|
||||
stream_pos = excluded.stream_pos`
|
||||
|
||||
touchPresenceSQL = `
|
||||
UPDATE sync_presence SET last_active_ms = ? WHERE user_id = ?`
|
||||
)
|
||||
|
||||
// SetPresence implements storage.SyncStore.
|
||||
//
|
||||
// A write that changes nothing a peer would notice refreshes the
|
||||
// last-active timestamp in place and leaves the stream where it was. Every
|
||||
// /sync is such a write — a client says it is still online by syncing — and
|
||||
// moving the stream each time would wake every peer's long poll, which
|
||||
// would sync, which would move it again.
|
||||
func (s *Sync) SetPresence(ctx context.Context, p storage.PresenceState) (int64, bool, error) {
|
||||
var (
|
||||
pos int64
|
||||
moved bool
|
||||
)
|
||||
err := s.tx(ctx, func(tx *sql.Tx) error {
|
||||
var existing storage.PresenceState
|
||||
err := tx.QueryRowContext(ctx, s.q(selectPresenceSQL), p.UserID).Scan(
|
||||
&existing.UserID, &existing.Presence, &existing.StatusMsg,
|
||||
&existing.LastActiveMS, &existing.StreamPos)
|
||||
switch {
|
||||
case err == nil:
|
||||
if existing.Presence == p.Presence && existing.StatusMsg == p.StatusMsg {
|
||||
pos = existing.StreamPos
|
||||
if _, err := tx.ExecContext(ctx, s.q(touchPresenceSQL),
|
||||
p.LastActiveMS, p.UserID); err != nil {
|
||||
return fmt.Errorf("refresh the last-active timestamp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case !errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("read the current presence: %w", err)
|
||||
}
|
||||
|
||||
if pos, err = nextSequence(ctx, tx, s.q, syncSequences, seqPresence); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, s.q(upsertPresenceSQL),
|
||||
p.UserID, p.Presence, p.StatusMsg, p.LastActiveMS, pos); err != nil {
|
||||
return fmt.Errorf("write presence: %w", err)
|
||||
}
|
||||
moved = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("set the presence of %q: %w", p.UserID, err)
|
||||
}
|
||||
return pos, moved, nil
|
||||
}
|
||||
|
||||
const presenceColumns = `user_id, presence, status_msg, last_active_ms, stream_pos`
|
||||
|
||||
const selectPresenceSQL = `
|
||||
SELECT ` + presenceColumns + ` FROM sync_presence WHERE user_id = ?`
|
||||
|
||||
// Presence implements storage.SyncStore.
|
||||
func (s *Sync) Presence(ctx context.Context, userID string) (storage.PresenceState, error) {
|
||||
var p storage.PresenceState
|
||||
err := s.db.QueryRowContext(ctx, s.q(selectPresenceSQL), userID).
|
||||
Scan(&p.UserID, &p.Presence, &p.StatusMsg, &p.LastActiveMS, &p.StreamPos)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return storage.PresenceState{}, fmt.Errorf("presence of %q: %w", userID, storage.ErrNoSuchUser)
|
||||
case err != nil:
|
||||
return storage.PresenceState{}, fmt.Errorf("read the presence of %q: %w", userID, err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// PresenceForUsers implements storage.SyncStore.
|
||||
func (s *Sync) PresenceForUsers(ctx context.Context, userIDs []string, from, to int64) ([]storage.PresenceState, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `SELECT ` + presenceColumns + ` FROM sync_presence
|
||||
WHERE user_id IN (` + placeholders(len(userIDs)) + `) AND stream_pos > ?`
|
||||
args := make([]any, 0, len(userIDs)+2)
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, from)
|
||||
if to > 0 {
|
||||
query += ` AND stream_pos <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY stream_pos`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read presence: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []storage.PresenceState
|
||||
for rows.Next() {
|
||||
var p storage.PresenceState
|
||||
if err := rows.Scan(&p.UserID, &p.Presence, &p.StatusMsg, &p.LastActiveMS, &p.StreamPos); err != nil {
|
||||
return nil, fmt.Errorf("read presence: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const selectAccountDataChangesSQL = `
|
||||
SELECT room_id, data_type, stream_pos FROM user_account_data
|
||||
WHERE localpart = ? AND stream_pos > ?`
|
||||
|
||||
// AccountDataChanges implements storage.SyncStore.
|
||||
func (s *Sync) AccountDataChanges(ctx context.Context, localpart string, from, to int64) ([]storage.AccountDataEntry, error) {
|
||||
query := selectAccountDataChangesSQL
|
||||
args := []any{localpart, from}
|
||||
if to > 0 {
|
||||
query += ` AND stream_pos <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY stream_pos`
|
||||
|
||||
return s.accountData(ctx, localpart, query, args)
|
||||
}
|
||||
|
||||
const selectAllAccountDataSQL = `
|
||||
SELECT room_id, data_type, stream_pos FROM user_account_data
|
||||
WHERE localpart = ?`
|
||||
|
||||
// AccountDataAt implements storage.SyncStore.
|
||||
func (s *Sync) AccountDataAt(ctx context.Context, localpart string, to int64) ([]storage.AccountDataEntry, error) {
|
||||
query := selectAllAccountDataSQL
|
||||
args := []any{localpart}
|
||||
if to > 0 {
|
||||
query += ` AND stream_pos <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY room_id, data_type`
|
||||
|
||||
return s.accountData(ctx, localpart, query, args)
|
||||
}
|
||||
|
||||
func (s *Sync) accountData(ctx context.Context, localpart, query string, args []any) ([]storage.AccountDataEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the account data of %q: %w", localpart, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []storage.AccountDataEntry
|
||||
for rows.Next() {
|
||||
var e storage.AccountDataEntry
|
||||
if err := rows.Scan(&e.RoomID, &e.DataType, &e.StreamPos); err != nil {
|
||||
return nil, fmt.Errorf("read the account data of %q: %w", localpart, err)
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const insertFilterSQL = `
|
||||
INSERT INTO sync_filters (localpart, filter_id, filter_json, created_at_ms)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
|
||||
// SaveFilter implements storage.SyncStore.
|
||||
//
|
||||
// The ID is random rather than a counter. The spec says only that it is
|
||||
// opaque, and a counter would be a per-user one: two users would both have
|
||||
// a filter "1", so a filter ID leaking into a URL or a log would say which
|
||||
// user it belonged to only by accident of context.
|
||||
func (s *Sync) SaveFilter(ctx context.Context, localpart string, filter []byte, createdAtMS int64) (string, error) {
|
||||
for attempt := range 3 {
|
||||
filterID, err := newFilterID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q(insertFilterSQL),
|
||||
localpart, filterID, string(filter), createdAtMS)
|
||||
switch {
|
||||
case err == nil:
|
||||
return filterID, nil
|
||||
case s.isUnique(err) && attempt < 2:
|
||||
// 96 bits of randomness collided, or the caller's own ID did.
|
||||
// Either way the answer is another ID.
|
||||
continue
|
||||
default:
|
||||
return "", fmt.Errorf("save a filter for %q: %w", localpart, err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("save a filter for %q: could not find an unused filter ID", localpart)
|
||||
}
|
||||
|
||||
// newFilterID mints a filter ID: 12 random bytes, URL-safe base64.
|
||||
func newFilterID() (string, error) {
|
||||
var raw [12]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("generate a filter ID: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
||||
}
|
||||
|
||||
const selectFilterSQL = `
|
||||
SELECT filter_json FROM sync_filters WHERE localpart = ? AND filter_id = ?`
|
||||
|
||||
// Filter implements storage.SyncStore.
|
||||
func (s *Sync) Filter(ctx context.Context, localpart, filterID string) ([]byte, error) {
|
||||
var body string
|
||||
err := s.db.QueryRowContext(ctx, s.q(selectFilterSQL), localpart, filterID).Scan(&body)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, fmt.Errorf("filter %q: %w", filterID, storage.ErrNoSuchFilter)
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read filter %q: %w", filterID, err)
|
||||
}
|
||||
return []byte(body), nil
|
||||
}
|
||||
|
||||
// Compile-time proof that the shared implementation satisfies the interface.
|
||||
var _ storage.SyncStore = (*Sync)(nil)
|
||||
@@ -0,0 +1,331 @@
|
||||
package sqlstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// The stream reads of the room store: the queries the sync API and
|
||||
// /messages are built on.
|
||||
//
|
||||
// They live apart from the rest of the room store because they are the only
|
||||
// queries in it that are about *order* rather than about identity. Every
|
||||
// other read answers "what is the state of this room" or "what is this
|
||||
// event"; these answer "what happened between here and there", which is a
|
||||
// different access pattern and the one the (room_id, stream_ordering) index
|
||||
// exists for.
|
||||
|
||||
const selectMaxStreamOrderingSQL = `
|
||||
SELECT COALESCE(MAX(stream_ordering), 0) FROM roomserver_events`
|
||||
|
||||
// MaxStreamOrdering implements storage.RoomStore.
|
||||
//
|
||||
// It reads the events table rather than the counter in
|
||||
// roomserver_sequences. The counter is advanced inside the writing
|
||||
// transaction and is therefore already at the new value while that
|
||||
// transaction is still open; a sync token taken from it would name a
|
||||
// position whose event no reader can see yet, and the event would be
|
||||
// skipped for good once the transaction committed.
|
||||
func (s *Rooms) MaxStreamOrdering(ctx context.Context) (int64, error) {
|
||||
var top int64
|
||||
if err := s.db.QueryRowContext(ctx, s.q(selectMaxStreamOrderingSQL)).Scan(&top); err != nil {
|
||||
return 0, fmt.Errorf("read the top of the event stream: %w", err)
|
||||
}
|
||||
return top, nil
|
||||
}
|
||||
|
||||
// MembershipsForUser implements storage.RoomStore.
|
||||
func (s *Rooms) MembershipsForUser(ctx context.Context, userID string, memberships []string) ([]storage.SyncMembership, error) {
|
||||
query := `
|
||||
SELECT cs.room_id, cs.membership, cs.event_id, e.stream_ordering
|
||||
FROM roomserver_current_state cs
|
||||
JOIN roomserver_events e ON e.event_id = cs.event_id
|
||||
WHERE cs.event_type = 'm.room.member' AND cs.state_key = ?`
|
||||
args := []any{userID}
|
||||
if len(memberships) > 0 {
|
||||
query += ` AND cs.membership IN (` + placeholders(len(memberships)) + `)`
|
||||
for _, m := range memberships {
|
||||
args = append(args, m)
|
||||
}
|
||||
}
|
||||
query += ` ORDER BY cs.room_id`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the memberships of %q: %w", userID, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []storage.SyncMembership
|
||||
for rows.Next() {
|
||||
var m storage.SyncMembership
|
||||
if err := rows.Scan(&m.RoomID, &m.Membership, &m.EventID, &m.StreamOrdering); err != nil {
|
||||
return nil, fmt.Errorf("read the memberships of %q: %w", userID, err)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
var selectUserMembershipEventsSQL = `
|
||||
SELECT ` + eventColumns + `
|
||||
FROM roomserver_events e
|
||||
WHERE e.room_id = ? AND e.event_type = 'm.room.member' AND e.state_key = ?
|
||||
ORDER BY e.stream_ordering`
|
||||
|
||||
// UserMembershipEvents implements storage.RoomStore.
|
||||
func (s *Rooms) UserMembershipEvents(ctx context.Context, roomID, userID string) ([]storage.StoredEvent, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q(selectUserMembershipEventsSQL), roomID, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the membership events of %q in %q: %w", userID, roomID, err)
|
||||
}
|
||||
events, err := scanEvents(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the membership events of %q in %q: %w", userID, roomID, err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// Timeline implements storage.RoomStore.
|
||||
func (s *Rooms) Timeline(ctx context.Context, req storage.TimelineRequest) ([]storage.StoredEvent, error) {
|
||||
if req.Limit <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
conditions := []string{`e.room_id = ?`}
|
||||
args := []any{req.RoomID}
|
||||
if req.Low > 0 {
|
||||
conditions = append(conditions, `e.stream_ordering > ?`)
|
||||
args = append(args, req.Low)
|
||||
}
|
||||
if req.High > 0 {
|
||||
conditions = append(conditions, `e.stream_ordering <= ?`)
|
||||
args = append(args, req.High)
|
||||
}
|
||||
|
||||
// The order decides which end the LIMIT trims: reading backwards keeps
|
||||
// the newest of the window, which is what a timeline is.
|
||||
order := `ASC`
|
||||
if req.Direction == storage.DirectionBackward {
|
||||
order = `DESC`
|
||||
}
|
||||
|
||||
if clause, filterArgs := filterConditions(req.Filter); clause != "" {
|
||||
conditions = append(conditions, clause)
|
||||
args = append(args, filterArgs...)
|
||||
}
|
||||
args = append(args, req.Limit)
|
||||
|
||||
query := `SELECT ` + eventColumns + `
|
||||
FROM roomserver_events e
|
||||
WHERE ` + strings.Join(conditions, " AND ") + `
|
||||
ORDER BY e.stream_ordering ` + order + `
|
||||
LIMIT ?`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the timeline of %q: %w", req.RoomID, err)
|
||||
}
|
||||
events, err := scanEvents(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read the timeline of %q: %w", req.RoomID, err)
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// filterConditions renders the part of an event filter the store applies in
|
||||
// SQL, as a WHERE fragment and its arguments.
|
||||
//
|
||||
// The exclusions are ANDed in after the inclusions, which is what makes
|
||||
// not_types win over types for an event matching both — the behaviour the
|
||||
// spec requires.
|
||||
func filterConditions(f storage.TimelineFilter) (string, []any) {
|
||||
var (
|
||||
clauses []string
|
||||
args []any
|
||||
)
|
||||
add := func(column string, patterns []string, negate bool) {
|
||||
if len(patterns) == 0 {
|
||||
return
|
||||
}
|
||||
clause, clauseArgs := patternMatch(column, patterns)
|
||||
if negate {
|
||||
clause = `NOT (` + clause + `)`
|
||||
}
|
||||
clauses = append(clauses, clause)
|
||||
args = append(args, clauseArgs...)
|
||||
}
|
||||
add(`e.event_type`, f.Types, false)
|
||||
add(`e.event_type`, f.NotTypes, true)
|
||||
add(`e.sender`, f.Senders, false)
|
||||
add(`e.sender`, f.NotSenders, true)
|
||||
|
||||
if len(clauses) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return strings.Join(clauses, " AND "), args
|
||||
}
|
||||
|
||||
// patternMatch renders "this column matches any of these patterns".
|
||||
//
|
||||
// A filter pattern may end in '*', which the spec defines as matching any
|
||||
// suffix — "m.room.*" is how a client asks for every room event type. Exact
|
||||
// patterns go into one IN list so that the common case is a single indexed
|
||||
// lookup; a wildcard becomes a LIKE with an explicit escape character, so
|
||||
// that a '%' or '_' inside the pattern is matched literally rather than
|
||||
// being read as a LIKE wildcard of its own.
|
||||
func patternMatch(column string, patterns []string) (string, []any) {
|
||||
var (
|
||||
exact []string
|
||||
wildcard []string
|
||||
args []any
|
||||
)
|
||||
for _, p := range patterns {
|
||||
if prefix, isWildcard := strings.CutSuffix(p, "*"); isWildcard {
|
||||
wildcard = append(wildcard, prefix)
|
||||
continue
|
||||
}
|
||||
exact = append(exact, p)
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if len(exact) > 0 {
|
||||
parts = append(parts, column+` IN (`+placeholders(len(exact))+`)`)
|
||||
for _, p := range exact {
|
||||
args = append(args, p)
|
||||
}
|
||||
}
|
||||
for _, prefix := range wildcard {
|
||||
parts = append(parts, column+` LIKE ? ESCAPE '\'`)
|
||||
args = append(args, escapeLike(prefix)+`%`)
|
||||
}
|
||||
return strings.Join(parts, " OR "), args
|
||||
}
|
||||
|
||||
// escapeLike makes a literal string safe to embed in a LIKE pattern that
|
||||
// declares '\' as its escape character.
|
||||
func escapeLike(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for _, r := range s {
|
||||
if r == '\\' || r == '%' || r == '_' {
|
||||
b.WriteRune('\\')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RoomsWithEvents implements storage.RoomStore.
|
||||
func (s *Rooms) RoomsWithEvents(ctx context.Context, roomIDs []string, from, to int64) ([]string, error) {
|
||||
if len(roomIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT DISTINCT room_id FROM roomserver_events
|
||||
WHERE room_id IN (` + placeholders(len(roomIDs)) + `) AND stream_ordering > ?`
|
||||
args := make([]any, 0, len(roomIDs)+2)
|
||||
for _, id := range roomIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, from)
|
||||
if to > 0 {
|
||||
query += ` AND stream_ordering <= ?`
|
||||
args = append(args, to)
|
||||
}
|
||||
query += ` ORDER BY room_id`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find the rooms with events after %d: %w", from, err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var roomID string
|
||||
if err := rows.Scan(&roomID); err != nil {
|
||||
return nil, fmt.Errorf("find the rooms with events after %d: %w", from, err)
|
||||
}
|
||||
out = append(out, roomID)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
const selectEventAtStreamSQL = `
|
||||
SELECT e.event_id, e.event_type, e.state_key, e.state_before_id
|
||||
FROM roomserver_events e
|
||||
WHERE e.room_id = ? AND e.stream_ordering <= ?
|
||||
ORDER BY e.stream_ordering DESC
|
||||
LIMIT 1`
|
||||
|
||||
const selectSnapshotEntriesSQL = `
|
||||
SELECT se.event_type, se.state_key, se.event_id, ''
|
||||
FROM roomserver_state_entries se
|
||||
WHERE se.snapshot_id = ?
|
||||
ORDER BY se.event_type, se.state_key`
|
||||
|
||||
// StateAtStream implements storage.RoomStore.
|
||||
//
|
||||
// The state after a position is the snapshot recorded against the last
|
||||
// event at or before it, plus that event itself when it is a state event:
|
||||
// the snapshot is the state the event was authorised against, which is the
|
||||
// state *before* it.
|
||||
func (s *Rooms) StateAtStream(ctx context.Context, roomID string, pos int64) ([]storage.StateEntry, error) {
|
||||
if pos <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var (
|
||||
eventID string
|
||||
eventType string
|
||||
stateKey sql.NullString
|
||||
snapshotID int64
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx, s.q(selectEventAtStreamSQL), roomID, pos).
|
||||
Scan(&eventID, &eventType, &stateKey, &snapshotID)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
// Nothing had happened in the room by then, which for a position
|
||||
// before its create event is the honest answer.
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("read the state of %q at %d: %w", roomID, pos, err)
|
||||
}
|
||||
|
||||
var entries []storage.StateEntry
|
||||
if snapshotID != 0 {
|
||||
rows, qErr := s.db.QueryContext(ctx, s.q(selectSnapshotEntriesSQL), snapshotID)
|
||||
if qErr != nil {
|
||||
return nil, fmt.Errorf("read the state of %q at %d: %w", roomID, pos, qErr)
|
||||
}
|
||||
if entries, err = scanStateEntries(rows); err != nil {
|
||||
return nil, fmt.Errorf("read the state of %q at %d: %w", roomID, pos, err)
|
||||
}
|
||||
}
|
||||
if stateKey.Valid {
|
||||
entries = upsertStateEntry(entries, storage.StateEntry{
|
||||
Type: eventType,
|
||||
StateKey: stateKey.String,
|
||||
EventID: eventID,
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// upsertStateEntry replaces the matching tuple in entries, or appends it.
|
||||
func upsertStateEntry(entries []storage.StateEntry, entry storage.StateEntry) []storage.StateEntry {
|
||||
for i, existing := range entries {
|
||||
if existing.Type == entry.Type && existing.StateKey == entry.StateKey {
|
||||
entries[i] = entry
|
||||
return entries
|
||||
}
|
||||
}
|
||||
return append(entries, entry)
|
||||
}
|
||||
@@ -610,10 +610,10 @@ const (
|
||||
WHERE localpart = ? AND room_id = ? AND data_type = ?`
|
||||
|
||||
upsertAccountDataSQL = `
|
||||
INSERT INTO user_account_data (localpart, room_id, data_type, content)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO user_account_data (localpart, room_id, data_type, content, stream_pos)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (localpart, room_id, data_type)
|
||||
DO UPDATE SET content = excluded.content`
|
||||
DO UPDATE SET content = excluded.content, stream_pos = excluded.stream_pos`
|
||||
)
|
||||
|
||||
// AccountData implements storage.UserStore.
|
||||
@@ -632,9 +632,26 @@ func (u *Users) AccountData(ctx context.Context, localpart, roomID, dataType str
|
||||
// SetAccountData implements storage.UserStore. The content is stored as text
|
||||
// rather than a JSON column type: the two engines disagree on what JSON
|
||||
// normalisation does, and account data has to come back out byte for byte.
|
||||
//
|
||||
// The write also stamps the row with a position in the account data stream,
|
||||
// taken inside the same transaction as the write itself. Account data is
|
||||
// one of the streams /sync reports, and a position assigned separately
|
||||
// would either be visible before the content it describes or after it — in
|
||||
// the second case a client would be told the stream had moved and then find
|
||||
// the old value.
|
||||
func (u *Users) SetAccountData(ctx context.Context, localpart, roomID, dataType string, content []byte) error {
|
||||
if _, err := u.db.ExecContext(ctx, u.q(upsertAccountDataSQL),
|
||||
localpart, roomID, dataType, string(content)); err != nil {
|
||||
err := u.tx(ctx, func(tx *sql.Tx) error {
|
||||
pos, err := nextSequence(ctx, tx, u.q, syncSequences, seqAccountData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, u.q(upsertAccountDataSQL),
|
||||
localpart, roomID, dataType, string(content), pos); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("set account data %q: %w", dataType, err)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -67,6 +67,10 @@ type Database interface {
|
||||
// Rooms is the roomserver store: rooms, events, room state, aliases.
|
||||
Rooms() RoomStore
|
||||
|
||||
// Sync is the syncapi store: receipts, to-device messages, presence and
|
||||
// uploaded filters.
|
||||
Sync() SyncStore
|
||||
|
||||
// Close releases the connection pool.
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Errors the sync store returns for cases a caller has to tell apart.
|
||||
var (
|
||||
// ErrNoSuchFilter is returned for a filter ID the user has not
|
||||
// uploaded.
|
||||
ErrNoSuchFilter = errors.New("no such filter")
|
||||
)
|
||||
|
||||
// StreamPositions is the top of every stream a sync token points into, read
|
||||
// in one call.
|
||||
//
|
||||
// A sync response is assembled from several independent streams, and the
|
||||
// token it returns has to name a position in each. Reading them one at a
|
||||
// time would let a stream move between two of the reads, and the client
|
||||
// would then be handed a token that skips whatever landed in the gap.
|
||||
type StreamPositions struct {
|
||||
// PDU is the room event stream: the highest stream ordering in
|
||||
// roomserver_events.
|
||||
PDU int64
|
||||
|
||||
Receipt int64
|
||||
ToDevice int64
|
||||
AccountData int64
|
||||
Presence int64
|
||||
}
|
||||
|
||||
// TimelineDirection is which way a timeline read walks the stream.
|
||||
type TimelineDirection string
|
||||
|
||||
// The two directions, spelled as the Client-Server API spells them in
|
||||
// /messages' `dir` parameter.
|
||||
const (
|
||||
// DirectionForward reads oldest first, which is the order /sync hands
|
||||
// events to a client.
|
||||
DirectionForward TimelineDirection = "f"
|
||||
|
||||
// DirectionBackward reads newest first, which is how /messages pages
|
||||
// back through a room's history.
|
||||
DirectionBackward TimelineDirection = "b"
|
||||
)
|
||||
|
||||
// TimelineRequest is one window of a room's event stream.
|
||||
//
|
||||
// The window is named by its two ends rather than by a direction and a
|
||||
// starting point, because the two are independent: which end the limit
|
||||
// trims, and which order the rows come back in, is Direction's business,
|
||||
// and nothing else about the request changes with it. Spelling the bounds
|
||||
// as "from" and "to" instead would mean each of them being the low bound in
|
||||
// one direction and the high bound in the other, which is exactly the
|
||||
// off-by-one every pagination bug is made of.
|
||||
//
|
||||
// Low is exclusive and High inclusive, in both directions. That is what
|
||||
// lets a token a client was handed be passed straight back without
|
||||
// re-reading the event it names: a position is somewhere a client has got
|
||||
// to, not an event it still wants.
|
||||
type TimelineRequest struct {
|
||||
RoomID string
|
||||
|
||||
// Low is an exclusive lower bound. Zero means the start of the room.
|
||||
Low int64
|
||||
|
||||
// High is an inclusive upper bound. Zero means the top of the stream,
|
||||
// and goes on meaning that as the stream grows.
|
||||
High int64
|
||||
|
||||
// Direction decides which end of the window the limit trims — the
|
||||
// bottom when reading backwards, the top when reading forwards — and
|
||||
// the order the rows come back in.
|
||||
Direction TimelineDirection
|
||||
|
||||
// Limit caps the rows returned. It is required: an unbounded timeline
|
||||
// read is a way to ask the server to serialise a whole room.
|
||||
Limit int
|
||||
|
||||
// Filter narrows which events come back. The store applies it in SQL
|
||||
// because a filter applied after the limit would return fewer events
|
||||
// than the client asked for and then look like the end of the room.
|
||||
Filter TimelineFilter
|
||||
}
|
||||
|
||||
// TimelineFilter is the part of a Client-Server API event filter that the
|
||||
// store can apply itself.
|
||||
//
|
||||
// Only the fields that are a plain comparison on an indexed or stored
|
||||
// column are here. The rest of a filter — `contains_url`, lazy-loading,
|
||||
// `include_redundant_members` — is about what an event means or about how a
|
||||
// response is assembled, and is applied by the syncapi.
|
||||
type TimelineFilter struct {
|
||||
// Types, when non-empty, admits only these event types. A trailing '*'
|
||||
// is a wildcard, as the spec defines for filter patterns.
|
||||
Types []string
|
||||
|
||||
// NotTypes excludes types, and wins over Types for an event matching
|
||||
// both, which is what the spec requires.
|
||||
NotTypes []string
|
||||
|
||||
// Senders, when non-empty, admits only these senders.
|
||||
Senders []string
|
||||
|
||||
// NotSenders excludes senders, and likewise wins.
|
||||
NotSenders []string
|
||||
}
|
||||
|
||||
// IsEmpty reports whether the filter admits everything.
|
||||
func (f TimelineFilter) IsEmpty() bool {
|
||||
return len(f.Types) == 0 && len(f.NotTypes) == 0 &&
|
||||
len(f.Senders) == 0 && len(f.NotSenders) == 0
|
||||
}
|
||||
|
||||
// SyncMembership is a user's current membership in one room, with the
|
||||
// position of the event that set it.
|
||||
//
|
||||
// The position is what bounds what a user who has left may see: their view
|
||||
// of the room ends at their own leave event, and a room they were kicked
|
||||
// out of must not keep streaming to them.
|
||||
type SyncMembership struct {
|
||||
RoomID string
|
||||
Membership string
|
||||
EventID string
|
||||
|
||||
// StreamOrdering is the position of the membership event.
|
||||
StreamOrdering int64
|
||||
}
|
||||
|
||||
// Receipt is one read receipt.
|
||||
type Receipt struct {
|
||||
RoomID string
|
||||
UserID string
|
||||
ReceiptType string
|
||||
|
||||
// ThreadID is the thread the receipt is for, or empty for the room's
|
||||
// main timeline.
|
||||
ThreadID string
|
||||
|
||||
EventID string
|
||||
|
||||
// TimestampMS is when the receipt was sent, which is what a client
|
||||
// shows as "read at".
|
||||
TimestampMS int64
|
||||
|
||||
// StreamPos is the receipt's position in the receipt stream.
|
||||
StreamPos int64
|
||||
}
|
||||
|
||||
// ToDeviceMessage is one message addressed to a single device.
|
||||
type ToDeviceMessage struct {
|
||||
// StreamPos is the message's position in the to-device stream.
|
||||
StreamPos int64
|
||||
|
||||
Localpart string
|
||||
DeviceID string
|
||||
Sender string
|
||||
Type string
|
||||
|
||||
// Content is the message body as the sender wrote it.
|
||||
Content []byte
|
||||
|
||||
SentAtMS int64
|
||||
}
|
||||
|
||||
// NewToDeviceMessage is one message to enqueue.
|
||||
type NewToDeviceMessage struct {
|
||||
Localpart string
|
||||
|
||||
// DeviceID is the device to deliver to. The caller has already expanded
|
||||
// a wildcard addressee into one of these per device: which devices a
|
||||
// user has is the userapi's business, not the store's.
|
||||
DeviceID string
|
||||
|
||||
Sender string
|
||||
Type string
|
||||
Content []byte
|
||||
SentAtMS int64
|
||||
}
|
||||
|
||||
// PresenceState is a user's presence as the store holds it.
|
||||
type PresenceState struct {
|
||||
UserID string
|
||||
|
||||
// Presence is "online", "offline" or "unavailable".
|
||||
Presence string
|
||||
|
||||
StatusMsg string
|
||||
|
||||
// LastActiveMS is when the user was last seen doing something.
|
||||
LastActiveMS int64
|
||||
|
||||
// StreamPos is the position of the last change to this row.
|
||||
StreamPos int64
|
||||
}
|
||||
|
||||
// AccountDataEntry locates one account data blob that has changed. The
|
||||
// content is not carried: a sync response needs to know which blobs to
|
||||
// re-read, and reading them is a separate, cacheable call.
|
||||
type AccountDataEntry struct {
|
||||
// RoomID is empty for global account data.
|
||||
RoomID string
|
||||
DataType string
|
||||
|
||||
StreamPos int64
|
||||
}
|
||||
|
||||
// StoredFilter is an uploaded filter.
|
||||
type StoredFilter struct {
|
||||
Localpart string
|
||||
FilterID string
|
||||
|
||||
// JSON is the filter exactly as the client uploaded it.
|
||||
JSON []byte
|
||||
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// SyncStore persists the streams a sync response carries that are not room
|
||||
// events, plus the filters clients upload: the storage half of the syncapi
|
||||
// component.
|
||||
//
|
||||
// Typing notifications are not here on purpose. They expire in seconds and
|
||||
// are worthless across a restart, so the syncapi keeps them in memory.
|
||||
type SyncStore interface {
|
||||
// StreamPositions reads the top of every stream in one call.
|
||||
StreamPositions(ctx context.Context) (StreamPositions, error)
|
||||
|
||||
// SetReceipt records a read receipt, replacing any earlier one from the
|
||||
// same user for the same type and thread, and returns its stream
|
||||
// position.
|
||||
//
|
||||
// A receipt that would move backwards is not written and reports the
|
||||
// current position with moved false: receipts only ever advance, and a
|
||||
// client syncing on two devices would otherwise flap.
|
||||
SetReceipt(ctx context.Context, r Receipt) (pos int64, moved bool, err error)
|
||||
|
||||
// Receipts returns the current receipts of the given rooms whose
|
||||
// position is after from and at or before to. A from of 0 returns every
|
||||
// current receipt, which is what an initial sync needs.
|
||||
Receipts(ctx context.Context, roomIDs []string, from, to int64) ([]Receipt, error)
|
||||
|
||||
// ReceiptsForUser returns one user's own current receipts in the given
|
||||
// rooms, whatever their position. A client needs its own read markers
|
||||
// on an initial sync even when it set them long ago.
|
||||
ReceiptsForUser(ctx context.Context, userID string, roomIDs []string) ([]Receipt, error)
|
||||
|
||||
// SendToDevice enqueues messages for delivery and returns the highest
|
||||
// position it wrote. An empty batch is not an error and returns the
|
||||
// current position.
|
||||
SendToDevice(ctx context.Context, msgs []NewToDeviceMessage) (int64, error)
|
||||
|
||||
// ToDeviceMessages returns the messages queued for one device whose
|
||||
// position is after from and at or before to, oldest first.
|
||||
ToDeviceMessages(ctx context.Context, localpart, deviceID string, from, to int64, limit int) ([]ToDeviceMessage, error)
|
||||
|
||||
// DeleteToDeviceMessages removes the messages for a device at or before
|
||||
// pos. A device acknowledges a batch by syncing past it, so this is
|
||||
// what the next sync does with the batch before it.
|
||||
DeleteToDeviceMessages(ctx context.Context, localpart, deviceID string, pos int64) error
|
||||
|
||||
// SetPresence records a user's presence and returns its stream
|
||||
// position, reporting whether anything a peer would care about
|
||||
// changed.
|
||||
//
|
||||
// A write that only refreshes LastActiveMS does not move the stream.
|
||||
// A client says it is still online by syncing, so every sync is one of
|
||||
// these — and a stream that moved each time would wake every peer's
|
||||
// long poll, which would sync, which would move it again.
|
||||
SetPresence(ctx context.Context, p PresenceState) (pos int64, moved bool, err error)
|
||||
|
||||
// Presence reads one user's presence, returning ErrNoSuchUser when
|
||||
// nothing has ever been recorded for them.
|
||||
Presence(ctx context.Context, userID string) (PresenceState, error)
|
||||
|
||||
// PresenceForUsers returns the presence of the given users whose
|
||||
// position is after from and at or before to. A from of 0 returns every
|
||||
// current state.
|
||||
PresenceForUsers(ctx context.Context, userIDs []string, from, to int64) ([]PresenceState, error)
|
||||
|
||||
// AccountDataChanges lists the account data of a user that changed
|
||||
// after from and at or before to.
|
||||
AccountDataChanges(ctx context.Context, localpart string, from, to int64) ([]AccountDataEntry, error)
|
||||
|
||||
// AccountDataAt lists every account data blob a user has, as an initial
|
||||
// sync needs.
|
||||
AccountDataAt(ctx context.Context, localpart string, to int64) ([]AccountDataEntry, error)
|
||||
|
||||
// SaveFilter stores a filter under a new ID and returns it.
|
||||
SaveFilter(ctx context.Context, localpart string, filter []byte, createdAtMS int64) (string, error)
|
||||
|
||||
// Filter reads an uploaded filter, returning ErrNoSuchFilter when the
|
||||
// ID is unknown.
|
||||
Filter(ctx context.Context, localpart, filterID string) ([]byte, error)
|
||||
}
|
||||
@@ -0,0 +1,698 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/dariuszjaniak/warp/server/internal/storage"
|
||||
)
|
||||
|
||||
// The sync store and the stream reads of the room store, against every
|
||||
// driver that can run without external services.
|
||||
|
||||
// migratedSyncDB opens a driver, migrates it and empties both the
|
||||
// roomserver and the sync tables.
|
||||
func migratedSyncDB(t *testing.T, driver string) storage.Database {
|
||||
t.Helper()
|
||||
db := migratedRoomDB(t, driver)
|
||||
resetSyncTables(t, driver)
|
||||
return db
|
||||
}
|
||||
|
||||
// resetSyncTables empties the sync tables and rewinds their counters, for
|
||||
// the same reason resetRoomTables exists: the PostgreSQL tests share one
|
||||
// database.
|
||||
func resetSyncTables(t *testing.T, driver string) {
|
||||
t.Helper()
|
||||
if driver != storage.DriverPostgres {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN"))
|
||||
if err != nil {
|
||||
t.Fatalf("open postgres to reset the sync tables: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if _, err := db.ExecContext(t.Context(), `TRUNCATE
|
||||
sync_receipts, sync_to_device, sync_presence, sync_filters`); err != nil {
|
||||
t.Fatalf("reset the sync tables: %v", err)
|
||||
}
|
||||
if _, err := db.ExecContext(t.Context(),
|
||||
`UPDATE sync_sequences SET value = 0`); err != nil {
|
||||
t.Fatalf("rewind the sync counters: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// writeMessages appends n plausible message events to a room and returns
|
||||
// their IDs in order. The room store never parses an event, so the JSON
|
||||
// only has to be JSON.
|
||||
func writeMessages(t *testing.T, rooms storage.RoomStore, roomID string, ids ...string) {
|
||||
t.Helper()
|
||||
for _, id := range ids {
|
||||
if _, err := rooms.Write(t.Context(), storage.RoomWrite{
|
||||
Event: newTestEvent(roomID, id, "m.room.message", nil),
|
||||
Extremities: []string{id},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimelineBoundsAndDirection(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
writeMessages(t, rooms, testRoomID, "$a", "$b", "$c", "$d", "$e")
|
||||
|
||||
top, err := rooms.MaxStreamOrdering(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("MaxStreamOrdering: %v", err)
|
||||
}
|
||||
if top != 5 {
|
||||
t.Fatalf("MaxStreamOrdering = %d, want 5", top)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
req storage.TimelineRequest
|
||||
wantOrder []string
|
||||
}{
|
||||
{
|
||||
// Low is exclusive, High inclusive, in both
|
||||
// directions: that is what lets a token be handed back
|
||||
// without re-reading the event it names.
|
||||
name: "forwards from the start",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, Direction: storage.DirectionForward, Limit: 10,
|
||||
},
|
||||
wantOrder: []string{"$a", "$b", "$c", "$d", "$e"},
|
||||
},
|
||||
{
|
||||
name: "forwards past a position",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, Low: 2,
|
||||
Direction: storage.DirectionForward, Limit: 10,
|
||||
},
|
||||
wantOrder: []string{"$c", "$d", "$e"},
|
||||
},
|
||||
{
|
||||
name: "forwards up to a position",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, High: 3,
|
||||
Direction: storage.DirectionForward, Limit: 10,
|
||||
},
|
||||
wantOrder: []string{"$a", "$b", "$c"},
|
||||
},
|
||||
{
|
||||
// The limit trims the far end, so a backwards read
|
||||
// keeps the newest: that is what a timeline is.
|
||||
name: "backwards keeps the newest",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, Direction: storage.DirectionBackward, Limit: 2,
|
||||
},
|
||||
wantOrder: []string{"$e", "$d"},
|
||||
},
|
||||
{
|
||||
name: "forwards keeps the oldest",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, Direction: storage.DirectionForward, Limit: 2,
|
||||
},
|
||||
wantOrder: []string{"$a", "$b"},
|
||||
},
|
||||
{
|
||||
name: "a window with both bounds",
|
||||
req: storage.TimelineRequest{
|
||||
RoomID: testRoomID, Low: 1, High: 4,
|
||||
Direction: storage.DirectionBackward, Limit: 10,
|
||||
},
|
||||
wantOrder: []string{"$d", "$c", "$b"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
events, err := rooms.Timeline(ctx, tc.req)
|
||||
if err != nil {
|
||||
t.Fatalf("Timeline: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
got = append(got, e.EventID)
|
||||
}
|
||||
if len(got) != len(tc.wantOrder) {
|
||||
t.Fatalf("Timeline returned %v, want %v", got, tc.wantOrder)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.wantOrder[i] {
|
||||
t.Fatalf("Timeline returned %v, want %v", got, tc.wantOrder)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimelineFilterIsAppliedInSQL(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
|
||||
// Two types and two senders.
|
||||
for _, e := range []storage.StoredEvent{
|
||||
newTestEvent(testRoomID, "$msg1", "m.room.message", nil),
|
||||
newTestEvent(testRoomID, "$topic", "m.room.topic", stateKey("")),
|
||||
func() storage.StoredEvent {
|
||||
e := newTestEvent(testRoomID, "$msg2", "m.room.message", nil)
|
||||
e.Sender = "@bob:warp.example"
|
||||
return e
|
||||
}(),
|
||||
} {
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: e, Extremities: []string{e.EventID},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write %s: %v", e.EventID, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
filter storage.TimelineFilter
|
||||
want []string
|
||||
}{
|
||||
{"types", storage.TimelineFilter{Types: []string{"m.room.message"}},
|
||||
[]string{"$msg1", "$msg2"}},
|
||||
{"not_types", storage.TimelineFilter{NotTypes: []string{"m.room.message"}},
|
||||
[]string{"$topic"}},
|
||||
{"senders", storage.TimelineFilter{Senders: []string{"@bob:warp.example"}},
|
||||
[]string{"$msg2"}},
|
||||
{"not_senders", storage.TimelineFilter{NotSenders: []string{"@bob:warp.example"}},
|
||||
[]string{"$msg1", "$topic"}},
|
||||
{
|
||||
// A trailing '*' is the spec's wildcard.
|
||||
"a wildcard type", storage.TimelineFilter{Types: []string{"m.room.m*"}},
|
||||
[]string{"$msg1", "$msg2"},
|
||||
},
|
||||
{
|
||||
// not_types wins over types for an event matching
|
||||
// both, which the spec requires.
|
||||
"exclusions win",
|
||||
storage.TimelineFilter{
|
||||
Types: []string{"m.room.message"},
|
||||
NotTypes: []string{"m.room.message"},
|
||||
},
|
||||
nil,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
events, err := rooms.Timeline(ctx, storage.TimelineRequest{
|
||||
RoomID: testRoomID,
|
||||
Direction: storage.DirectionForward,
|
||||
Limit: 10,
|
||||
Filter: tc.filter,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Timeline: %v", err)
|
||||
}
|
||||
got := make([]string, 0, len(events))
|
||||
for _, e := range events {
|
||||
got = append(got, e.EventID)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("Timeline returned %v, want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("Timeline returned %v, want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimelineWildcardDoesNotTreatPatternCharactersAsWildcards(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
writeMessages(t, rooms, testRoomID, "$msg")
|
||||
|
||||
// A filter pattern is matched with LIKE, so a '%' or '_' in it
|
||||
// has to be escaped or it would match anything.
|
||||
events, err := rooms.Timeline(ctx, storage.TimelineRequest{
|
||||
RoomID: testRoomID,
|
||||
Direction: storage.DirectionForward,
|
||||
Limit: 10,
|
||||
Filter: storage.TimelineFilter{Types: []string{"m.room.m%*"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Timeline: %v", err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("a '%%' in a filter pattern matched %d events; it must be literal", len(events))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomsWithEventsNarrowsToWhatChanged(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
|
||||
const other = "!other:warp.example"
|
||||
for _, id := range []string{testRoomID, other} {
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(id)); err != nil {
|
||||
t.Fatalf("CreateRoom %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
writeMessages(t, rooms, testRoomID, "$a")
|
||||
writeMessages(t, rooms, other, "$b")
|
||||
writeMessages(t, rooms, testRoomID, "$c")
|
||||
|
||||
// Only the first room has an event after position 2.
|
||||
changed, err := rooms.RoomsWithEvents(ctx, []string{testRoomID, other}, 2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomsWithEvents: %v", err)
|
||||
}
|
||||
if len(changed) != 1 || changed[0] != testRoomID {
|
||||
t.Errorf("RoomsWithEvents = %v, want [%s]", changed, testRoomID)
|
||||
}
|
||||
|
||||
// Nothing happened after the top of the stream.
|
||||
quiet, err := rooms.RoomsWithEvents(ctx, []string{testRoomID, other}, 3, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("RoomsWithEvents: %v", err)
|
||||
}
|
||||
if len(quiet) != 0 {
|
||||
t.Errorf("RoomsWithEvents past the top = %v, want nothing", quiet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateAtStreamIsTheStateAfterAPosition(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
|
||||
create := []storage.StateEntry{{Type: "m.room.create", StateKey: "", EventID: "$create"}}
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: newTestEvent(testRoomID, "$create", "m.room.create", stateKey("")),
|
||||
StateDelta: create,
|
||||
Extremities: []string{"$create"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write the create event: %v", err)
|
||||
}
|
||||
|
||||
// A topic, authorised against the state the create event left.
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: newTestEvent(testRoomID, "$topic", "m.room.topic", stateKey("")),
|
||||
StateBefore: create,
|
||||
StateDelta: []storage.StateEntry{{Type: "m.room.topic", StateKey: "", EventID: "$topic"}},
|
||||
Extremities: []string{"$topic"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write the topic: %v", err)
|
||||
}
|
||||
|
||||
// A message after it, whose state-before includes the topic.
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: newTestEvent(testRoomID, "$msg", "m.room.message", nil),
|
||||
StateBefore: append(create,
|
||||
storage.StateEntry{Type: "m.room.topic", StateKey: "", EventID: "$topic"}),
|
||||
Extremities: []string{"$msg"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write the message: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
pos int64
|
||||
types []string
|
||||
}{
|
||||
// Before anything happened.
|
||||
{0, nil},
|
||||
// After the create event: the state is the create event,
|
||||
// which is the snapshot *before* it plus itself.
|
||||
{1, []string{"m.room.create"}},
|
||||
// After the topic.
|
||||
{2, []string{"m.room.create", "m.room.topic"}},
|
||||
// After a message, which changes no state.
|
||||
{3, []string{"m.room.create", "m.room.topic"}},
|
||||
} {
|
||||
entries, err := rooms.StateAtStream(ctx, testRoomID, tc.pos)
|
||||
if err != nil {
|
||||
t.Fatalf("StateAtStream(%d): %v", tc.pos, err)
|
||||
}
|
||||
if len(entries) != len(tc.types) {
|
||||
t.Errorf("StateAtStream(%d) returned %d entries, want %d (%v)",
|
||||
tc.pos, len(entries), len(tc.types), tc.types)
|
||||
continue
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
have[e.Type] = true
|
||||
}
|
||||
for _, want := range tc.types {
|
||||
if !have[want] {
|
||||
t.Errorf("StateAtStream(%d) is missing %s", tc.pos, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptsOnlyEverAdvance(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := migratedSyncDB(t, driver)
|
||||
rooms, sync := db.Rooms(), db.Sync()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
writeMessages(t, rooms, testRoomID, "$first", "$second")
|
||||
|
||||
receipt := storage.Receipt{
|
||||
RoomID: testRoomID,
|
||||
UserID: "@alice:warp.example",
|
||||
ReceiptType: "m.read",
|
||||
EventID: "$second",
|
||||
TimestampMS: 1_700_000_000_000,
|
||||
}
|
||||
pos, moved, err := sync.SetReceipt(ctx, receipt)
|
||||
if err != nil {
|
||||
t.Fatalf("SetReceipt: %v", err)
|
||||
}
|
||||
if !moved || pos == 0 {
|
||||
t.Fatalf("SetReceipt = (%d, %v), want a position and moved", pos, moved)
|
||||
}
|
||||
|
||||
// Backwards: two of a user's clients read a room at different
|
||||
// speeds, and letting the slower one win would make the unread
|
||||
// marker jump backwards on the other.
|
||||
backwards := receipt
|
||||
backwards.EventID = "$first"
|
||||
back, moved, err := sync.SetReceipt(ctx, backwards)
|
||||
if err != nil {
|
||||
t.Fatalf("SetReceipt backwards: %v", err)
|
||||
}
|
||||
if moved {
|
||||
t.Error("a receipt that would move backwards was written")
|
||||
}
|
||||
if back != pos {
|
||||
t.Errorf("the position moved from %d to %d on a rejected receipt", pos, back)
|
||||
}
|
||||
|
||||
current, err := sync.Receipts(ctx, []string{testRoomID}, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Receipts: %v", err)
|
||||
}
|
||||
if len(current) != 1 || current[0].EventID != "$second" {
|
||||
t.Errorf("Receipts = %+v, want the one receipt at $second", current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiptForAnUnknownEventIsRefused(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := migratedSyncDB(t, driver)
|
||||
if err := db.Rooms().CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
|
||||
_, _, err := db.Sync().SetReceipt(ctx, storage.Receipt{
|
||||
RoomID: testRoomID,
|
||||
UserID: "@alice:warp.example",
|
||||
ReceiptType: "m.read",
|
||||
EventID: "$nope",
|
||||
})
|
||||
if !errors.Is(err, storage.ErrNoSuchEvent) {
|
||||
t.Errorf("SetReceipt for an unknown event = %v, want ErrNoSuchEvent", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestToDeviceQueueIsPerDeviceAndDrainable(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sync := migratedSyncDB(t, driver).Sync()
|
||||
|
||||
top, err := sync.SendToDevice(ctx, []storage.NewToDeviceMessage{
|
||||
{Localpart: "alice", DeviceID: "ONE", Sender: "@bob:warp.example",
|
||||
Type: "m.room_key", Content: []byte(`{"n":1}`)},
|
||||
{Localpart: "alice", DeviceID: "TWO", Sender: "@bob:warp.example",
|
||||
Type: "m.room_key", Content: []byte(`{"n":2}`)},
|
||||
{Localpart: "alice", DeviceID: "ONE", Sender: "@bob:warp.example",
|
||||
Type: "m.room_key", Content: []byte(`{"n":3}`)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendToDevice: %v", err)
|
||||
}
|
||||
if top != 3 {
|
||||
t.Errorf("SendToDevice returned %d, want 3: every message takes a position", top)
|
||||
}
|
||||
|
||||
one, err := sync.ToDeviceMessages(ctx, "alice", "ONE", 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ToDeviceMessages: %v", err)
|
||||
}
|
||||
if len(one) != 2 {
|
||||
t.Fatalf("device ONE has %d messages, want 2", len(one))
|
||||
}
|
||||
|
||||
// Draining one device's queue leaves the other's alone: a
|
||||
// message to two devices is two independent deliveries.
|
||||
if err := sync.DeleteToDeviceMessages(ctx, "alice", "ONE", one[len(one)-1].StreamPos); err != nil {
|
||||
t.Fatalf("DeleteToDeviceMessages: %v", err)
|
||||
}
|
||||
if remaining, err := sync.ToDeviceMessages(ctx, "alice", "ONE", 0, 0, 0); err != nil {
|
||||
t.Fatalf("ToDeviceMessages: %v", err)
|
||||
} else if len(remaining) != 0 {
|
||||
t.Errorf("device ONE still has %d messages after draining", len(remaining))
|
||||
}
|
||||
if two, err := sync.ToDeviceMessages(ctx, "alice", "TWO", 0, 0, 0); err != nil {
|
||||
t.Fatalf("ToDeviceMessages: %v", err)
|
||||
} else if len(two) != 1 {
|
||||
t.Errorf("device TWO has %d messages, want its own 1", len(two))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceStreamMovesOnlyOnARealChange(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sync := migratedSyncDB(t, driver).Sync()
|
||||
|
||||
state := storage.PresenceState{
|
||||
UserID: "@alice:warp.example", Presence: "online", LastActiveMS: 1_000,
|
||||
}
|
||||
first, moved, err := sync.SetPresence(ctx, state)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if !moved {
|
||||
t.Error("the first presence write must move the stream")
|
||||
}
|
||||
|
||||
// Every /sync is a write that only refreshes the timestamp. A
|
||||
// stream that moved each time would wake every peer's long
|
||||
// poll, which would sync, which would move it again.
|
||||
state.LastActiveMS = 2_000
|
||||
again, moved, err := sync.SetPresence(ctx, state)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if moved {
|
||||
t.Error("a write that refreshes only the timestamp must not move the stream")
|
||||
}
|
||||
if again != first {
|
||||
t.Errorf("the position moved from %d to %d without a change", first, again)
|
||||
}
|
||||
|
||||
// The timestamp did update, even though the stream did not.
|
||||
read, err := sync.Presence(ctx, state.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("Presence: %v", err)
|
||||
}
|
||||
if read.LastActiveMS != 2_000 {
|
||||
t.Errorf("last_active_ms = %d, want it refreshed to 2000", read.LastActiveMS)
|
||||
}
|
||||
|
||||
state.Presence = "unavailable"
|
||||
third, moved, err := sync.SetPresence(ctx, state)
|
||||
if err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
if !moved || third <= first {
|
||||
t.Errorf("a real change gave (%d, %v), want a higher position and moved", third, moved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceOfAnUnknownUser(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
sync := migratedSyncDB(t, driver).Sync()
|
||||
_, err := sync.Presence(t.Context(), "@nobody:warp.example")
|
||||
if !errors.Is(err, storage.ErrNoSuchUser) {
|
||||
t.Errorf("Presence of an unknown user = %v, want ErrNoSuchUser", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFiltersAreStoredVerbatimAndScopedToTheirOwner(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
sync := migratedSyncDB(t, driver).Sync()
|
||||
|
||||
// Spacing and key order are preserved: a client compares what
|
||||
// it gets back with what it sent.
|
||||
raw := []byte(`{"room":{"timeline":{"limit":5}},"event_fields":["type"]}`)
|
||||
filterID, err := sync.SaveFilter(ctx, "alice", raw, 1_700_000_000_000)
|
||||
if err != nil {
|
||||
t.Fatalf("SaveFilter: %v", err)
|
||||
}
|
||||
if filterID == "" {
|
||||
t.Fatal("SaveFilter returned no ID")
|
||||
}
|
||||
|
||||
read, err := sync.Filter(ctx, "alice", filterID)
|
||||
if err != nil {
|
||||
t.Fatalf("Filter: %v", err)
|
||||
}
|
||||
if string(read) != string(raw) {
|
||||
t.Errorf("Filter = %s, want it byte for byte: %s", read, raw)
|
||||
}
|
||||
|
||||
// A filter belongs to the user who uploaded it.
|
||||
if _, err := sync.Filter(ctx, "bob", filterID); !errors.Is(err, storage.ErrNoSuchFilter) {
|
||||
t.Errorf("another user's filter = %v, want ErrNoSuchFilter", err)
|
||||
}
|
||||
if _, err := sync.Filter(ctx, "alice", "nope"); !errors.Is(err, storage.ErrNoSuchFilter) {
|
||||
t.Errorf("an unknown filter = %v, want ErrNoSuchFilter", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamPositionsReadsEveryStream(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
db := migratedSyncDB(t, driver)
|
||||
rooms, sync := db.Rooms(), db.Sync()
|
||||
|
||||
positions, err := sync.StreamPositions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamPositions: %v", err)
|
||||
}
|
||||
if positions != (storage.StreamPositions{}) {
|
||||
t.Errorf("a fresh database is at %+v, want every stream at zero", positions)
|
||||
}
|
||||
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
writeMessages(t, rooms, testRoomID, "$a")
|
||||
if _, _, err := sync.SetReceipt(ctx, storage.Receipt{
|
||||
RoomID: testRoomID, UserID: "@alice:warp.example",
|
||||
ReceiptType: "m.read", EventID: "$a",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetReceipt: %v", err)
|
||||
}
|
||||
if _, err := sync.SendToDevice(ctx, []storage.NewToDeviceMessage{
|
||||
{Localpart: "alice", DeviceID: "ONE", Type: "m.room_key", Content: []byte(`{}`)},
|
||||
}); err != nil {
|
||||
t.Fatalf("SendToDevice: %v", err)
|
||||
}
|
||||
if _, _, err := sync.SetPresence(ctx, storage.PresenceState{
|
||||
UserID: "@alice:warp.example", Presence: "online",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetPresence: %v", err)
|
||||
}
|
||||
|
||||
positions, err = sync.StreamPositions(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamPositions: %v", err)
|
||||
}
|
||||
if positions.PDU != 1 || positions.Receipt != 1 ||
|
||||
positions.ToDevice != 1 || positions.Presence != 1 {
|
||||
t.Errorf("StreamPositions = %+v, want every written stream at 1", positions)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserMembershipEventsAreInStreamOrder(t *testing.T) {
|
||||
for _, driver := range testDrivers {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
rooms := migratedSyncDB(t, driver).Rooms()
|
||||
if err := rooms.CreateRoom(ctx, newTestRoom(testRoomID)); err != nil {
|
||||
t.Fatalf("CreateRoom: %v", err)
|
||||
}
|
||||
|
||||
const alice = "@alice:warp.example"
|
||||
for _, id := range []string{"$join", "$leave", "$rejoin"} {
|
||||
e := newTestEvent(testRoomID, id, "m.room.member", stateKey(alice))
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: e,
|
||||
Extremities: []string{id},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
// Somebody else's membership must not be in the answer.
|
||||
other := newTestEvent(testRoomID, "$bob", "m.room.member", stateKey("@bob:warp.example"))
|
||||
if _, err := rooms.Write(ctx, storage.RoomWrite{
|
||||
Event: other, Extremities: []string{"$bob"},
|
||||
}); err != nil {
|
||||
t.Fatalf("Write $bob: %v", err)
|
||||
}
|
||||
|
||||
events, err := rooms.UserMembershipEvents(ctx, testRoomID, alice)
|
||||
if err != nil {
|
||||
t.Fatalf("UserMembershipEvents: %v", err)
|
||||
}
|
||||
want := []string{"$join", "$leave", "$rejoin"}
|
||||
if len(events) != len(want) {
|
||||
t.Fatalf("UserMembershipEvents returned %d events, want %d", len(events), len(want))
|
||||
}
|
||||
for i, e := range events {
|
||||
if e.EventID != want[i] {
|
||||
t.Errorf("event %d = %s, want %s", i, e.EventID, want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user