Files
Warp/server/internal/roomserver/relations.go
T
tajniak81andClaude Opus 5.5 487e0f410b
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
Phase 10: hardening and ops
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>
2026-10-17 20:21:31 +02:00

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)
}