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>
476 lines
14 KiB
Go
476 lines
14 KiB
Go
package roomserver
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/event"
|
|
"github.com/dariuszjaniak/warp/server/internal/matrix/roomversion"
|
|
"github.com/dariuszjaniak/warp/server/internal/storage"
|
|
)
|
|
|
|
// Relations: threads, edits, reactions and references.
|
|
//
|
|
// An event relates to another through content.m.relates_to, and nothing
|
|
// about that is checked by the authorisation rules — a relation is a
|
|
// client convention the server indexes, not part of the room's DAG. The
|
|
// server's part is three things: recording which event points at which
|
|
// (relationOf, written with the event), listing an event's children
|
|
// (/relations and /threads), and summarising them into the parent's
|
|
// unsigned.m.relations wherever the parent is served (the bundled
|
|
// aggregations), so that a client rendering a timeline knows a message
|
|
// has a thread under it or has been edited without asking about every
|
|
// event.
|
|
|
|
// relationOf reads content.m.relates_to. Both fields have to be strings,
|
|
// and an m.in_reply_to alone is not a relation in this sense: a reply
|
|
// points at an event without being one of its children.
|
|
func relationOf(content json.RawMessage) (eventID, relType string) {
|
|
var probe struct {
|
|
RelatesTo *struct {
|
|
EventID any `json:"event_id"`
|
|
RelType any `json:"rel_type"`
|
|
} `json:"m.relates_to"`
|
|
}
|
|
if err := json.Unmarshal(content, &probe); err != nil || probe.RelatesTo == nil {
|
|
return "", ""
|
|
}
|
|
id, ok1 := probe.RelatesTo.EventID.(string)
|
|
rel, ok2 := probe.RelatesTo.RelType.(string)
|
|
if !ok1 || !ok2 || id == "" || rel == "" {
|
|
return "", ""
|
|
}
|
|
return id, rel
|
|
}
|
|
|
|
// ThreadOf is the thread an event is in: the root its m.relates_to names
|
|
// when the relation is m.thread, or "" for an event on the main timeline.
|
|
// The push server files each notification under it.
|
|
func ThreadOf(e *event.Event) string {
|
|
if root, rel := relationOf(e.Content()); rel == storage.RelThread {
|
|
return root
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// searchableText is what the search index holds for an event: the body of
|
|
// a message, the name of a room or its topic, as the spec's three search
|
|
// keys. An encrypted message has no body the server can read and is never
|
|
// found, which is what end-to-end encryption means.
|
|
func searchableText(e *event.Event) (key, text string) {
|
|
var field, k string
|
|
switch e.Type() {
|
|
case TypeMessage:
|
|
field, k = "body", "content.body"
|
|
case TypeName:
|
|
field, k = "name", "content.name"
|
|
case TypeTopic:
|
|
field, k = "topic", "content.topic"
|
|
default:
|
|
return "", ""
|
|
}
|
|
var content map[string]any
|
|
if err := json.Unmarshal(e.Content(), &content); err != nil {
|
|
return "", ""
|
|
}
|
|
value, ok := content[field].(string)
|
|
if !ok || value == "" {
|
|
return "", ""
|
|
}
|
|
return k, value
|
|
}
|
|
|
|
// maxRecursionDepth is how far a recursive /relations walks: the spec's
|
|
// own suggestion, which covers a reaction to an edit in a thread.
|
|
const maxRecursionDepth = 3
|
|
|
|
// maxReferenceChunk caps the event IDs in an m.reference summary.
|
|
const maxReferenceChunk = 50
|
|
|
|
// RelationsRequest is one page of an event's children.
|
|
type RelationsRequest struct {
|
|
RoomID string
|
|
EventID string
|
|
|
|
// User is who is asking. The parent has to be visible to them, and
|
|
// so does every child.
|
|
User string
|
|
|
|
// RelType and EventType narrow the children, when set.
|
|
RelType string
|
|
EventType string
|
|
|
|
// Low, High and Direction are a TimelineRequest's.
|
|
Low int64
|
|
High int64
|
|
Direction storage.TimelineDirection
|
|
|
|
Limit int
|
|
|
|
// Recurse includes the children's children, down to
|
|
// maxRecursionDepth levels.
|
|
Recurse bool
|
|
}
|
|
|
|
// RelationsPage is one page of an event's children.
|
|
type RelationsPage struct {
|
|
Timeline
|
|
|
|
// Depth is how many levels the read went down: 1 without recursion.
|
|
Depth int
|
|
}
|
|
|
|
// Relations reads one page of an event's children.
|
|
func (r *API) Relations(ctx context.Context, req RelationsRequest) (RelationsPage, error) {
|
|
_, rules, err := r.roomAndRules(ctx, req.RoomID)
|
|
if err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
visible, err := r.requireReadable(ctx, req.RoomID, req.User)
|
|
if err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
parent, err := r.store.Event(ctx, req.RoomID, req.EventID)
|
|
if err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
if parent.Outlier || !visible.Contains(parent.StreamOrdering) {
|
|
return RelationsPage{}, fmt.Errorf("event %q: %w", req.EventID, ErrNoSuchEvent)
|
|
}
|
|
|
|
// The parents to read the children of: the event, and with recursion
|
|
// every descendant down to the depth limit. The filters apply at every
|
|
// level, as the spec has it, so a walk for m.thread children only
|
|
// follows threads.
|
|
//
|
|
// The children of one level are the next level, so the walk adds a
|
|
// level to the parents only while it is above the limit: the events at
|
|
// the limit are read as children and are nobody's parents. depth is
|
|
// the deepest level that had anything in it.
|
|
parents := []string{req.EventID}
|
|
depth := 1
|
|
if req.Recurse {
|
|
depth = 0
|
|
level := parents
|
|
seen := map[string]bool{req.EventID: true}
|
|
for depth < maxRecursionDepth {
|
|
children, err := r.store.ChildEventIDs(ctx, req.RoomID, level, req.RelType, req.EventType)
|
|
if err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
// A relation loop cannot be built — an event ID is a hash of
|
|
// content naming its parent — but a repeat across levels can.
|
|
children = slices.DeleteFunc(children, func(id string) bool {
|
|
if seen[id] {
|
|
return true
|
|
}
|
|
seen[id] = true
|
|
return false
|
|
})
|
|
if len(children) == 0 {
|
|
break
|
|
}
|
|
depth++
|
|
if depth < maxRecursionDepth {
|
|
parents = append(parents, children...)
|
|
}
|
|
level = children
|
|
}
|
|
depth = max(depth, 1)
|
|
}
|
|
|
|
empty := Timeline{Start: req.Low, End: req.Low}
|
|
if req.Direction == storage.DirectionBackward && req.High != 0 {
|
|
empty = Timeline{Start: req.High, End: req.High}
|
|
}
|
|
low, high, any := visible.Clamp(req.Low, req.High)
|
|
if !any || req.Limit <= 0 {
|
|
return RelationsPage{Timeline: empty, Depth: depth}, nil
|
|
}
|
|
|
|
stored, err := r.store.Relations(ctx, storage.RelationsRequest{
|
|
RoomID: req.RoomID,
|
|
ParentIDs: parents,
|
|
RelType: req.RelType,
|
|
EventType: req.EventType,
|
|
Low: low,
|
|
High: high,
|
|
Direction: req.Direction,
|
|
Limit: req.Limit + 1,
|
|
})
|
|
if err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
|
|
out := RelationsPage{Timeline: empty, Depth: depth}
|
|
if len(stored) > req.Limit {
|
|
out.Limited = true
|
|
stored = stored[:req.Limit]
|
|
}
|
|
if len(stored) == 0 {
|
|
return out, nil
|
|
}
|
|
if req.Direction == storage.DirectionBackward {
|
|
slices.Reverse(stored)
|
|
}
|
|
if out.Events, err = r.clientEvents(stored, rules, req.RoomID); err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
if err := r.bundleAggregations(ctx, req.RoomID, rules, req.User, visible, stored, out.Events); err != nil {
|
|
return RelationsPage{}, err
|
|
}
|
|
out.Start = storage.PositionBefore(stored[0].StreamOrdering)
|
|
out.End = stored[len(stored)-1].StreamOrdering
|
|
return out, nil
|
|
}
|
|
|
|
// ThreadsRequest is one page of a room's threads.
|
|
type ThreadsRequest struct {
|
|
RoomID string
|
|
User string
|
|
|
|
// Participated keeps only the threads the user started or replied in.
|
|
Participated bool
|
|
|
|
// Before is where the previous page ended, zero for the first.
|
|
Before int64
|
|
|
|
Limit int
|
|
}
|
|
|
|
// ThreadsPage is one page of a room's threads: their root events, newest
|
|
// activity first, each with its m.thread summary.
|
|
type ThreadsPage struct {
|
|
Roots []ClientEvent
|
|
|
|
// Next is the Before of the next page, zero at the end.
|
|
Next int64
|
|
}
|
|
|
|
// Threads reads one page of a room's threads.
|
|
func (r *API) Threads(ctx context.Context, req ThreadsRequest) (ThreadsPage, error) {
|
|
_, rules, err := r.roomAndRules(ctx, req.RoomID)
|
|
if err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
visible, err := r.requireReadable(ctx, req.RoomID, req.User)
|
|
if err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
if req.Limit <= 0 {
|
|
return ThreadsPage{}, nil
|
|
}
|
|
|
|
participant := ""
|
|
if req.Participated {
|
|
participant = req.User
|
|
}
|
|
threads, err := r.store.Threads(ctx, storage.ThreadsRequest{
|
|
RoomID: req.RoomID,
|
|
ParticipatedBy: participant,
|
|
Before: req.Before,
|
|
High: visible.To,
|
|
Limit: req.Limit + 1,
|
|
})
|
|
if err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
|
|
var out ThreadsPage
|
|
if len(threads) > req.Limit {
|
|
threads = threads[:req.Limit]
|
|
out.Next = threads[len(threads)-1].LatestOrdering
|
|
}
|
|
|
|
ids := make([]string, 0, len(threads))
|
|
for _, t := range threads {
|
|
ids = append(ids, t.RootID)
|
|
}
|
|
stored, err := r.store.EventsByID(ctx, req.RoomID, ids)
|
|
if err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
byID := make(map[string]storage.StoredEvent, len(stored))
|
|
for _, s := range stored {
|
|
byID[s.EventID] = s
|
|
}
|
|
|
|
// In the order the store ranked them, leaving out any root the user may
|
|
// not see: a thread started before a `joined` user's join is not
|
|
// theirs to read, even if it is still going.
|
|
roots := make([]storage.StoredEvent, 0, len(threads))
|
|
for _, t := range threads {
|
|
s, ok := byID[t.RootID]
|
|
if !ok || s.Outlier || !visible.Contains(s.StreamOrdering) {
|
|
continue
|
|
}
|
|
roots = append(roots, s)
|
|
}
|
|
if out.Roots, err = r.clientEvents(roots, rules, req.RoomID); err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
if err := r.bundleAggregations(ctx, req.RoomID, rules, req.User, visible, roots, out.Roots); err != nil {
|
|
return ThreadsPage{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// threadSummary is unsigned.m.relations.m.thread.
|
|
type threadSummary struct {
|
|
LatestEvent ClientEvent `json:"latest_event"`
|
|
Count int `json:"count"`
|
|
CurrentUserParticipated bool `json:"current_user_participated"`
|
|
}
|
|
|
|
// referenceSummary is unsigned.m.relations.m.reference.
|
|
type referenceSummary struct {
|
|
Chunk []referenceEntry `json:"chunk"`
|
|
}
|
|
|
|
type referenceEntry struct {
|
|
EventID string `json:"event_id"`
|
|
}
|
|
|
|
// bundleAggregations adds unsigned.m.relations to the events of a read:
|
|
// the thread under each of them, its latest edit, and what references it.
|
|
//
|
|
// stored and events are the same events, in the same order — the rows and
|
|
// their client shape — because the summaries need what only the rows have
|
|
// (whether an event was redacted) and are written into the client shape.
|
|
// Annotations are not summarised: the spec dropped their aggregation, and a
|
|
// client counts reactions from the events themselves.
|
|
func (r *API) bundleAggregations(ctx context.Context, roomID string, rules roomversion.Rules, userID string, visible VisibleRange, stored []storage.StoredEvent, events []ClientEvent) error {
|
|
if len(stored) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]string, 0, len(stored))
|
|
parents := make(map[string]storage.StoredEvent, len(stored))
|
|
for _, s := range stored {
|
|
ids = append(ids, s.EventID)
|
|
parents[s.EventID] = s
|
|
}
|
|
|
|
aggregates, err := r.store.RelationAggregates(ctx, roomID, ids, visible.To)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(aggregates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
type thread struct {
|
|
count int
|
|
latest int64
|
|
latestID string
|
|
participated bool
|
|
}
|
|
threads := map[string]*thread{}
|
|
edits := map[string]storage.RelationAggregate{}
|
|
referenced := map[string]bool{}
|
|
for _, a := range aggregates {
|
|
parent := parents[a.ParentID]
|
|
switch a.RelType {
|
|
case storage.RelThread:
|
|
t := threads[a.ParentID]
|
|
if t == nil {
|
|
t = &thread{participated: parent.Sender == userID}
|
|
threads[a.ParentID] = t
|
|
}
|
|
t.count += a.Count
|
|
if a.LatestOrdering > t.latest {
|
|
t.latest, t.latestID = a.LatestOrdering, a.LatestEventID
|
|
}
|
|
if a.Sender == userID {
|
|
t.participated = true
|
|
}
|
|
case storage.RelReplace:
|
|
// Only the original's sender may edit it, a state event cannot
|
|
// be edited, and a redacted event's edits are moot: what they
|
|
// replaced is gone.
|
|
if a.Sender != parent.Sender || parent.IsState() || parent.RedactedBy != "" {
|
|
continue
|
|
}
|
|
edits[a.ParentID] = a
|
|
case storage.RelReference:
|
|
referenced[a.ParentID] = true
|
|
}
|
|
}
|
|
|
|
// The latest thread replies and edits, read in one go.
|
|
latestIDs := make([]string, 0, len(threads)+len(edits))
|
|
for _, t := range threads {
|
|
latestIDs = append(latestIDs, t.latestID)
|
|
}
|
|
for _, a := range edits {
|
|
latestIDs = append(latestIDs, a.LatestEventID)
|
|
}
|
|
latest, err := r.clientEventsByID(ctx, roomID, rules, latestIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
latestByID := make(map[string]ClientEvent, len(latest))
|
|
for _, e := range latest {
|
|
latestByID[e.EventID] = e
|
|
}
|
|
|
|
var references map[string][]string
|
|
if len(referenced) > 0 {
|
|
refParents := make([]string, 0, len(referenced))
|
|
for id := range referenced {
|
|
refParents = append(refParents, id)
|
|
}
|
|
if references, err = r.store.RelationEventIDs(ctx, roomID, refParents,
|
|
storage.RelReference, visible.To, maxReferenceChunk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for i := range events {
|
|
id := events[i].EventID
|
|
relations := map[string]any{}
|
|
if t, ok := threads[id]; ok {
|
|
if e, ok := latestByID[t.latestID]; ok {
|
|
relations[storage.RelThread] = threadSummary{
|
|
LatestEvent: e,
|
|
Count: t.count,
|
|
CurrentUserParticipated: t.participated,
|
|
}
|
|
}
|
|
}
|
|
if a, ok := edits[id]; ok {
|
|
// An edit has to keep the type of what it edits; one that does
|
|
// not is not a replacement the client should render.
|
|
if e, ok := latestByID[a.LatestEventID]; ok && e.Type == events[i].Type {
|
|
relations[storage.RelReplace] = e
|
|
}
|
|
}
|
|
if refs := references[id]; len(refs) > 0 {
|
|
summary := referenceSummary{Chunk: make([]referenceEntry, 0, len(refs))}
|
|
for _, ref := range refs {
|
|
summary.Chunk = append(summary.Chunk, referenceEntry{EventID: ref})
|
|
}
|
|
relations[storage.RelReference] = summary
|
|
}
|
|
if len(relations) == 0 {
|
|
continue
|
|
}
|
|
events[i].Unsigned = r.withUnsigned(events[i].Unsigned, "m.relations", relations)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// withUnsigned sets one key of an event's unsigned block.
|
|
func (r *API) withUnsigned(unsigned json.RawMessage, key string, value any) json.RawMessage {
|
|
block := map[string]json.RawMessage{}
|
|
if len(unsigned) > 0 {
|
|
if err := json.Unmarshal(unsigned, &block); err != nil {
|
|
r.log.Warn("an unsigned block this server built is unreadable", "error", err)
|
|
block = map[string]json.RawMessage{}
|
|
}
|
|
}
|
|
block[key] = mustMarshal(value)
|
|
return mustMarshal(block)
|
|
}
|