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>
220 lines
6.4 KiB
Go
220 lines
6.4 KiB
Go
package syncapi
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"maps"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Sliding sync connections: what the server remembers about what each
|
|
// client has already been sent.
|
|
//
|
|
// A classic sync token is a position in every stream and nothing else; the
|
|
// client is sent everything after it. Sliding sync cannot work that way,
|
|
// because what a client is sent depends on what it asked for — which rooms
|
|
// are in the window of which list, with which pieces of state — and that
|
|
// changes from request to request. So the server remembers, per connection,
|
|
// which rooms it has sent and as of which position, and `pos` names one of
|
|
// those memories.
|
|
//
|
|
// The memory is in process and is lost on restart. That is what the
|
|
// protocol expects: an unknown `pos` is answered with M_UNKNOWN_POS, and
|
|
// the client starts a new connection — one initial request, after which it
|
|
// is in step again. Persisting it would buy a slightly cheaper restart for
|
|
// a write on every request.
|
|
|
|
// ErrUnknownPos is a `pos` this server does not remember: expired, from
|
|
// before a restart, or never handed out.
|
|
var ErrUnknownPos = errors.New("unknown sliding sync position")
|
|
|
|
// Limits of the connection store.
|
|
const (
|
|
// connectionTTL is how long an idle connection is remembered. A client
|
|
// that comes back later starts again.
|
|
connectionTTL = 30 * time.Minute
|
|
|
|
// maxConnectionsPerDevice caps the connections one device may hold
|
|
// open, each conn_id being one. The oldest goes first.
|
|
maxConnectionsPerDevice = 10
|
|
|
|
// keptGenerations is how many of a connection's latest positions stay
|
|
// usable. A client that never saw a response retries with the pos it
|
|
// sent, so the one before the latest has to still work.
|
|
keptGenerations = 3
|
|
)
|
|
|
|
// sentRoom is what a connection has been sent of one room.
|
|
type sentRoom struct {
|
|
// pos is the event stream position the room was last sent up to.
|
|
pos int64
|
|
|
|
// config is the room's required_state and timeline_limit as of that
|
|
// send, rendered as a string: when it changes, the state the client
|
|
// holds is no longer what it asked for.
|
|
config string
|
|
|
|
// membership is the user's membership as of that send.
|
|
membership string
|
|
|
|
// typing is the typing notification last sent, as its JSON.
|
|
typing string
|
|
}
|
|
|
|
// connSnapshot is a connection as of one `pos`.
|
|
type connSnapshot struct {
|
|
// token is the stream positions the response at this pos read up to.
|
|
token Token
|
|
|
|
// startPDU is the event stream position the connection began at: rooms
|
|
// the user left before it are not news to it.
|
|
startPDU int64
|
|
|
|
rooms map[string]sentRoom
|
|
|
|
// lists is each list's room count as last sent.
|
|
lists map[string]int
|
|
|
|
// toDevice is where the to_device extension has got to.
|
|
toDevice int64
|
|
}
|
|
|
|
// clone copies a snapshot so that a new generation can be built from it
|
|
// without touching the one a retry might still need.
|
|
func (c *connSnapshot) clone() *connSnapshot {
|
|
return &connSnapshot{
|
|
token: c.token,
|
|
startPDU: c.startPDU,
|
|
rooms: maps.Clone(c.rooms),
|
|
lists: maps.Clone(c.lists),
|
|
toDevice: c.toDevice,
|
|
}
|
|
}
|
|
|
|
// slidingConn is one connection: a client device's conn_id.
|
|
type slidingConn struct {
|
|
// nonce makes the connection's positions its own: a pos from another
|
|
// connection, or from before a restart, cannot name one of these
|
|
// generations by accident.
|
|
nonce string
|
|
|
|
generations map[int64]*connSnapshot
|
|
latest int64
|
|
lastUsed time.Time
|
|
}
|
|
|
|
type connKey struct {
|
|
user, device, connID string
|
|
}
|
|
|
|
// connStore holds every connection.
|
|
type connStore struct {
|
|
mu sync.Mutex
|
|
conns map[connKey]*slidingConn
|
|
now func() time.Time
|
|
}
|
|
|
|
func newConnStore(now func() time.Time) *connStore {
|
|
return &connStore{conns: map[connKey]*slidingConn{}, now: now}
|
|
}
|
|
|
|
// load returns the snapshot a request's pos names, or a fresh one for a
|
|
// request with none. A request without a pos starts the connection over,
|
|
// which is what a client does after M_UNKNOWN_POS and on first launch.
|
|
func (s *connStore) load(key connKey, pos string) (*connSnapshot, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.sweep()
|
|
|
|
if pos == "" {
|
|
delete(s.conns, key)
|
|
return nil, nil
|
|
}
|
|
conn, ok := s.conns[key]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: the connection has expired", ErrUnknownPos)
|
|
}
|
|
nonce, raw, ok := strings.Cut(pos, ".")
|
|
gen, err := strconv.ParseInt(raw, 10, 64)
|
|
if !ok || err != nil || nonce != conn.nonce {
|
|
return nil, fmt.Errorf("%w: %q is not a position of this connection", ErrUnknownPos, pos)
|
|
}
|
|
snap, ok := conn.generations[gen]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q is too old", ErrUnknownPos, pos)
|
|
}
|
|
conn.lastUsed = s.now()
|
|
return snap, nil
|
|
}
|
|
|
|
// save stores a new generation of a connection and returns its pos.
|
|
func (s *connStore) save(key connKey, snap *connSnapshot) string {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
conn, ok := s.conns[key]
|
|
if !ok {
|
|
conn = &slidingConn{nonce: randomNonce(), generations: map[int64]*connSnapshot{}, lastUsed: s.now()}
|
|
s.conns[key] = conn
|
|
s.capDevice(key)
|
|
}
|
|
conn.latest++
|
|
conn.generations[conn.latest] = snap
|
|
delete(conn.generations, conn.latest-keptGenerations)
|
|
conn.lastUsed = s.now()
|
|
return conn.nonce + "." + strconv.FormatInt(conn.latest, 10)
|
|
}
|
|
|
|
// sweep forgets idle connections. It runs under the lock on every load,
|
|
// which keeps the store bounded without a goroutine of its own.
|
|
func (s *connStore) sweep() {
|
|
cutoff := s.now().Add(-connectionTTL)
|
|
for key, conn := range s.conns {
|
|
if conn.lastUsed.Before(cutoff) {
|
|
delete(s.conns, key)
|
|
}
|
|
}
|
|
}
|
|
|
|
// capDevice drops a device's oldest connections beyond the cap. The
|
|
// connection being saved is never one of them.
|
|
func (s *connStore) capDevice(key connKey) {
|
|
var others []connKey
|
|
for k := range s.conns {
|
|
if k != key && k.user == key.user && k.device == key.device {
|
|
others = append(others, k)
|
|
}
|
|
}
|
|
slices.SortFunc(others, func(a, b connKey) int {
|
|
return s.conns[a].lastUsed.Compare(s.conns[b].lastUsed)
|
|
})
|
|
for len(others) > maxConnectionsPerDevice-1 {
|
|
delete(s.conns, others[0])
|
|
others = others[1:]
|
|
}
|
|
}
|
|
|
|
// connections reports how many connections are held, for tests and
|
|
// metrics.
|
|
func (s *connStore) connections() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.conns)
|
|
}
|
|
|
|
func randomNonce() string {
|
|
var b [6]byte
|
|
_, _ = rand.Read(b[:])
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// SlidingConnections reports how many sliding sync connections the server
|
|
// is holding state for.
|
|
func (s *API) SlidingConnections() int { return s.conns.connections() }
|