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 10. Simplified Sliding Sync (MSC4186) is served at the unstable org.matrix.simplified_msc3575 endpoint and advertised in /versions. Lists are sorted by activity and filtered by DM, invite, encryption, room type, space and tag; room subscriptions, required_state with wildcards, $ME and $LAZY, and the to_device, e2ee, account_data, receipts and typing extensions are supported. Per-connection state is kept in memory and an unknown pos answers M_UNKNOWN_POS. Threads and relations: m.relates_to is indexed on roomserver_events (migration 0012), /relations (with recursion) and /threads are served, and every event a timeline read returns carries its m.thread, m.replace and m.reference summaries in unsigned.m.relations. Notifications carry a thread ID (migration 0013): threaded receipts clear only their thread, and sync reports unread_thread_notifications when the filter asks. Spaces: /hierarchy walks a space breadth first, asking other servers about rooms this one is not in through the new federation hierarchy endpoint; room_summary describes one room. /search runs on FTS5 (sqlite, pocketbase) or tsvector (postgres), bounded to what each user may see, with context, profiles, state, grouping and paging. The PocketBase driver is real: the sqlite schema and store on an embedded PocketBase app's database, with media in its filesystem, local or S3 (database.pocketbase.s3). It runs the full storage test suite. Ops: OpenTelemetry tracing over OTLP/HTTP (tracing.*), with server spans per route, component spans, and trace propagation on federation, push and application service calls. New metrics for persisted events, waiting syncs, sliding sync connections and the database pool, with a Grafana dashboard. deploy/ adds a Compose setup (PostgreSQL, Prometheus, Grafana, Jaeger) and a Helm chart; cmd/warp-loadtest loads a server and reports request and delivery latencies. The load tester found a long-standing bug, fixed here: on a server where no account data had been written, every incremental /sync re-sent the push rules and returned at once, and an all-zero token was taken for an initial sync, so long polls became busy loops. The PostgreSQL migrations and search dialect compile but were not run against a live PostgreSQL in this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
421 lines
13 KiB
Go
421 lines
13 KiB
Go
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 = ? AND NOT e.outlier
|
|
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
|
|
}
|
|
|
|
// Outliers have no place in a timeline; see the outlier column.
|
|
conditions := []string{`e.room_id = ?`, `NOT e.outlier`}
|
|
args := []any{req.RoomID}
|
|
// Zero is "unbounded", not a position, at either end: a bound can be
|
|
// negative, inside backfilled history, so "is it set" cannot be
|
|
// spelled "is it positive".
|
|
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
|
|
}
|
|
|
|
// StreamEvents implements storage.RoomStore.
|
|
//
|
|
// It reads by stream_ordering alone, which the column's own uniqueness
|
|
// index serves. Backfilled history sits below zero and a reader starting at
|
|
// a real position never reaches it, which is right for what this is for:
|
|
// an application service is told what happens, not what a backfill dug up.
|
|
func (s *Rooms) StreamEvents(ctx context.Context, after, upTo int64, limit int) ([]storage.StoredEvent, error) {
|
|
if limit <= 0 {
|
|
return nil, nil
|
|
}
|
|
conditions := []string{`e.stream_ordering > ?`, `NOT e.outlier`}
|
|
args := []any{after}
|
|
if upTo != 0 {
|
|
conditions = append(conditions, `e.stream_ordering <= ?`)
|
|
args = append(args, upTo)
|
|
}
|
|
args = append(args, limit)
|
|
|
|
query := `SELECT ` + eventColumns + `
|
|
FROM roomserver_events e
|
|
WHERE ` + strings.Join(conditions, " AND ") + `
|
|
ORDER BY e.stream_ordering
|
|
LIMIT ?`
|
|
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the event stream after %d: %w", after, err)
|
|
}
|
|
events, err := scanEvents(rows)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read the event stream after %d: %w", after, 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) {
|
|
return filterConditionsOn(`e.event_type`, `e.sender`, f)
|
|
}
|
|
|
|
// filterConditionsOn is filterConditions against named columns, for a
|
|
// table other than roomserver_events.
|
|
func filterConditionsOn(typeColumn, senderColumn string, 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(typeColumn, f.Types, false)
|
|
add(typeColumn, f.NotTypes, true)
|
|
add(senderColumn, f.Senders, false)
|
|
add(senderColumn, 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 NOT outlier`
|
|
args := make([]any, 0, len(roomIDs)+2)
|
|
for _, id := range roomIDs {
|
|
args = append(args, id)
|
|
}
|
|
if from != 0 {
|
|
query += ` AND stream_ordering > ?`
|
|
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()
|
|
}
|
|
|
|
// LatestEventPositions implements storage.RoomStore.
|
|
func (s *Rooms) LatestEventPositions(ctx context.Context, roomIDs, types []string, upTo int64) (map[string]int64, error) {
|
|
if len(roomIDs) == 0 || len(types) == 0 {
|
|
return map[string]int64{}, nil
|
|
}
|
|
query := `
|
|
SELECT room_id, MAX(stream_ordering) FROM roomserver_events
|
|
WHERE room_id IN (` + placeholders(len(roomIDs)) + `)
|
|
AND event_type IN (` + placeholders(len(types)) + `) AND NOT outlier`
|
|
args := make([]any, 0, len(roomIDs)+len(types)+1)
|
|
for _, id := range roomIDs {
|
|
args = append(args, id)
|
|
}
|
|
for _, t := range types {
|
|
args = append(args, t)
|
|
}
|
|
if upTo != 0 {
|
|
query += ` AND stream_ordering <= ?`
|
|
args = append(args, upTo)
|
|
}
|
|
query += ` GROUP BY room_id`
|
|
|
|
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("find the latest activity of %d rooms: %w", len(roomIDs), err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
|
|
out := make(map[string]int64, len(roomIDs))
|
|
for rows.Next() {
|
|
var (
|
|
roomID string
|
|
pos int64
|
|
)
|
|
if err := rows.Scan(&roomID, &pos); err != nil {
|
|
return nil, fmt.Errorf("find the latest activity of %d rooms: %w", len(roomIDs), err)
|
|
}
|
|
out[roomID] = pos
|
|
}
|
|
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 NOT e.outlier 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)
|
|
}
|