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
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:
1 parent
a94794b866
commit
487e0f410b
100 files changed
+10528
-893
No files matched your search
+129
-11
@@ -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. |
|
||||
|
||||
Reference in new issue
Block a user