Phase 10: hardening and ops
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>
This commit is contained in:
tajniak81andClaude Opus 5.5 committed 2026-10-17 20:21:31 +02:00
1 parent a94794b866
commit 487e0f410b
100 files changed
+10528 -893

No files matched your search

+129 -11
View File
@@ -256,6 +256,14 @@ So the test is whether the response has anything in it, with an initial
sync as the exception: a client that has just logged in needs an answer
whatever is in it.
"Initial" means the client sent no `?since=`, not that its token is all
zeroes. On a server whose streams have not moved yet, `s0` is every
client's token, and taking it for a first sync made every long poll
return at once. The push rules a first sync carries are sent on the
client's first sync for the same reason, not on any sync whose account
data position is zero: before anybody had written account data, that was
every sync, and each one counted as news.
The same trap caught presence. A client says it is still online by syncing,
so `?set_presence=` makes every sync a presence write; a stream that moved
each time would wake every peer's long poll, which would sync, which would
@@ -280,11 +288,12 @@ What Warp acts on:
tuples, not a window of a stream, so there is no index to push it to.
- `room.rooms` / `not_rooms`, at both levels.
- `room.timeline.lazy_load_members`.
- `room.timeline.unread_thread_notifications`: the unread counts split by
thread; see [push.md](push.md).
- `room.include_leave`.
- `presence` and `account_data` types and senders.
What it parses and does not act on: `event_fields`, `contains_url`,
`unread_thread_notifications`. A field Warp ignores is **not** an error — a
What it parses and does not act on: `event_fields`, `contains_url`. A field Warp ignores is **not** an error — a
server is allowed to return a superset of what a filter asked for, and
refusing the filter is the one outcome that would stop a client syncing at
all. `event_format: federation` *is* refused: that is a server's view of an
@@ -400,16 +409,120 @@ its own response type for that reason. Reporting it as a room event with
empty fields would be worse: a client would see an event with no sender and
no timestamp and have to decide what that meant.
## Simplified Sliding Sync
`POST /_matrix/client/unstable/org.matrix.simplified_msc3575/sync` is
MSC4186, which Element X and every client on the matrix-rust-sdk use when
`/versions` advertises `org.matrix.simplified_msc3575` — as Warp's does.
A classic initial sync sends every room before a client can draw
anything, which for a large account takes minutes. Sliding sync turns that
round: the client asks for *lists* — "my rooms, most recently active
first, positions 0 to 19" — and for the few pieces of state it needs to
draw each one, and is sent those rooms and a count of the rest. As the
user scrolls the client widens the window; as rooms become active they
move into it and are sent.
The code is `internal/syncapi/sliding.go` and `slidingconn.go`, over a few
roomserver reads in `internal/roomserver/sliding.go`.
### Connections
What a client has been sent depends on what it asked for, so a token
cannot just be a position in each stream. The server remembers, per
connection — one user, one device, one `conn_id` — which rooms it has sent
and up to which position, and `pos` names one of those memories. A room is
sent once in full (`"initial": true`) and then only when it changes; a room
that leaves the window and comes back is not sent again unless something
happened to it.
The memory is in the process and is lost on restart. An unknown `pos` —
expired after 30 minutes idle, from before a restart, or one of the
connection's older positions beyond the last three — is answered with
`400 M_UNKNOWN_POS`, and the client starts a new connection. That is how
the protocol is meant to recover. A request without `pos` starts its
connection over. A device holds at most ten connections; the oldest goes
first.
Request parameters are not sticky: every request says what it wants.
### Lists
A list holds the rooms the user is joined to, invited to or knocking on,
plus rooms they left or were banned from while the connection was open,
which is how a client hears that a room it is showing is gone. Rooms are
sorted by `bump_stamp`, the position of the last message, encrypted
message, sticker, call invite, poll or live location in the room (not
membership changes, reactions or receipts, which would keep a busy room on
top for reasons nobody could see). An invite, a knock or a room the user
has left is bumped by their own membership event, because they cannot see
what happens in it.
| Filter | |
|---|---|
| `is_dm` | rooms in the user's `m.direct` account data |
| `is_invite` | |
| `is_encrypted` | an `m.room.encryption` in the room's state |
| `room_types`, `not_room_types` | the create event's `type`; `null` is an ordinary room |
| `spaces` | children of the named spaces this server is in |
| `tags`, `not_tags` | the room's `m.tag` account data |
`ranges` are inclusive windows into the sorted list. The response gives
each list's `count`, and the rooms in any window, merged with the room
subscriptions.
### Rooms
`room_subscriptions` names rooms directly, whatever list they are or are
not in: the room a client has open. A room in several lists and
subscriptions is sent with the largest `timeline_limit` and the union of
the `required_state`.
`required_state` is a list of `[type, state_key]`. `*` is a wildcard in
either place, `$ME` is the user, and `["m.room.member", "$LAZY"]` is the
members who sent something in the timeline, plus the user. Only those
tuples are read: the room's state is read as a set of keys first and the
event bodies after, so a room with ten thousand members costs one
member event, not ten thousand. On an incremental response it is the
tuples that changed since the room was last sent.
Each room carries `name` and `avatar`, `heroes` (with names and avatars)
when it has no name, `is_dm`, `joined_count`, `invited_count`, the unread
counts, `bump_stamp`, and the `timeline` with `prev_batch` (a `/messages`
token), `limited` and `num_live` (how many of the timeline's events are
new since the previous response). An invite is its `invite_state`, a knock
its `knock_state`, and neither has a timeline.
### Extensions
| Extension | What |
|---|---|
| `to_device` | The device's queued messages, 100 at a time by default. It has a `since` token of its own; coming back with it acknowledges the batch. |
| `e2ee` | `device_lists`, `device_one_time_keys_count`, `device_unused_fallback_key_types`, worked out as for `/sync`. |
| `account_data` | Global, and per room for the rooms in the response's lists and subscriptions — all of it for a room sent in full. |
| `receipts` | Per room, the receipts since the last response; all of them for a room sent in full. |
| `typing` | Per room, the typing list when it changed — including to empty, so a client stops showing somebody typing. |
`lists` and `rooms` narrow the rooms a per-room extension covers. Naming
any list the request has is every room in the windows: the windows are
merged before the extensions run.
### Long polling
A response is returned at once on a new connection; otherwise only when a
room is sent, a list's count moved, or an extension has something to say.
Until then it waits on the same notifier as `/sync`.
## What is deliberately missing
| Thing | Needs | Phase |
|---|---|---|
| `unread_thread_notifications` | threads | 10 |
| Remote presence | presence over federation | — |
| Threads, and threaded receipts beyond storing `thread_id` | threads | 10 |
| `contains_url`, `event_fields` | nothing; they are parsed and ignored | — |
| Per-event history visibility | state storage (open question 1) | — |
| Simplified Sliding Sync (MSC4186) | — | 10 |
| Thing | Needs |
|---|---|
| Remote presence | presence over federation |
| `contains_url`, `event_fields` | nothing; they are parsed and ignored |
| Per-event history visibility | state storage (open question 1) |
Threads, relations and the summaries every timeline event carries in
`unsigned.m.relations` are in [relations.md](relations.md).
`device_lists` is sent as two empty lists rather than left out: a client
reads a missing one as a malformed response, where two empty lists say
@@ -417,7 +530,9 @@ reads a missing one as a malformed response, where two empty lists say
that ride along with them, are in [e2ee.md](e2ee.md).
Each joined room carries `unread_notifications` from the push server,
whose counts are the seventh stream, and the global account data carries
whose counts are the seventh stream — the main timeline's alone, beside an
`unread_thread_notifications` map of each thread's, when the filter sets
`unread_thread_notifications` — and the global account data carries
`m.push_rules`, rendered over the server defaults for a user who never
changed a rule. Both are described in [push.md](push.md). A build with no
push server leaves the counts out entirely rather than sending zeroes,
@@ -429,6 +544,9 @@ is not.
| Endpoint | Notes |
|---|---|
| `GET /sync` | Long-polls. `?since=`, `?filter=`, `?timeout=`, `?full_state=`, `?set_presence=`. |
| `POST /_matrix/client/unstable/org.matrix.simplified_msc3575/sync` | Simplified Sliding Sync; see below. `?pos=`, `?timeout=`, `?set_presence=`. |
| `GET /_matrix/client/v1/rooms/{id}/relations/{eventId}` · `GET /_matrix/client/v1/rooms/{id}/threads` | See [relations.md](relations.md). |
| `POST /search` | See [search.md](search.md). |
| `GET /rooms/{id}/messages` | `?dir=` (`b` default), `?from=`, `?to=`, `?limit=`, `?filter=`. No `end` at the end of the room. |
| `GET /rooms/{id}/context/{eventId}` | `?limit=`, `?filter=`. Half the budget each side, the odd one backwards. |
| `POST /user/{userId}/filter` | Returns an opaque ID. |