Expand description
Sliding Sync Client implementation of MSC3575 & extensions
Sliding Sync
is the third generation synchronization mechanism of
Matrix with a strong focus on bandwidth efficiency. This is made possible by
allowing the client to filter the content very specifically in its request
which, as a result, allows the server to reduce the data sent to the
absolute necessary minimum needed. The API is modeled after common patterns
and UI components end-user messenger clients typically offer. By allowing a
tight coupling of what a client shows and synchronizing that state over
the protocol to the server, the server always sends exactly the information
necessary for the currently displayed subset for the user rather than
filling the connection with data the user isn’t interested in right now.
Sliding Sync is a live-protocol using long-polling HTTP(S) connections to stay up to date. On the client side these updates are applied and propagated through an asynchronous reactive API.
The protocol is split into three major sections for that: [lists][#lists], the room details and extensions, most notably the end-to-end-encryption and to-device extensions to enable full end-to-end-encryption support.
§Starting up
To create a new Sliding Sync session, one must query an existing
(authenticated) Client
for a new SlidingSyncBuilder
by calling
Client::sliding_sync
. The
SlidingSyncBuilder
is the baseline configuration to create a
SlidingSync
session by calling .build()
once everything is ready.
Typically one configures the custom homeserver endpoint, although it’s
automatically detected using the .well-known
endpoint, if configured.
At the time of writing, no Matrix server natively supports Sliding Sync;
a sidecar called the Sliding Sync Proxy is needed. As that
typically runs on a separate domain, it can be configured on the
SlidingSyncBuilder
.
A unique identifier, less than 16 chars long, is required for each instance of Sliding Sync, and must be provided when getting a builder:
let sliding_sync_builder = client
.sliding_sync("main-sync")?
.version(Version::Proxy { url: Url::parse("http://sliding-sync.example.org")? });
After the general configuration, one typically wants to add a list via the
add_list
function.
§Lists
A list defines a subset of matching rooms one wants to filter for, and be
kept up about. The http::request::ListFilters
allows for a granular
specification of the exact rooms one wants the server to select and the way
one wants them to be ordered before receiving. Secondly each list has a set
of ranges
: the subset of indexes of the entire list one is interested in
and a unique name to be identified with.
For example, a user might be part of thousands of rooms, but if the client
app always starts by showing the most recent direct message conversations,
loading all rooms is an inefficient approach. Instead with Sliding Sync one
defines a list (e.g. named "main_list"
) filtering for is_invite
, ordered
by recency and select to list the top 10 via ranges: [ [0,9] ]
(indexes
are inclusive) like so:
use matrix_sdk_base::sliding_sync::http;
use ruma::assign;
let list_builder = SlidingSyncList::builder("main_list")
.sync_mode(SlidingSyncMode::new_selective().add_range(0..=9))
.filters(Some(assign!(
http::request::ListFilters::default(), { is_invite: Some(true)}
)));
Please refer to the specification, the Ruma types,
specifically SyncRequestListFilter
and the
SlidingSyncListBuilder
for details on the filters, and
range-options and data one requests to be sent. Once the list is fully
configured, build()
it and add the list to the sliding sync session
by supplying it to add_list
.
Lists are inherently stateful and all updates are applied on the shared
list-object. Once a list has been added to SlidingSync
, a cloned shared
copy can be retrieved by calling SlidingSync::list()
, providing the name
of the list. Next to the configuration settings (like name and
timeline_limit
), the list provides the stateful
maximum_number_of_rooms
and
state
:
maximum_number_of_rooms
is the number of rooms total there were found matching the filters given.state
is aSlidingSyncMode
signalling meta information about the list and its stateful data — whether this is the state loaded from local cache, whether the full sync is in progress or whether this is the current live information
These are updated upon every update received from the server. One can query these for their current value at any time, or use the Reactive API to subscribe to changes.
§Helper lists
By default lists run in the Selective
mode.
That means one sets the desired range(s) to see explicitly (as described
above). Very often, one still wants to load up the entire room list in
background though. For that, the client implementation offers to run lists
in two additional full-sync-modes, which require additional configuration:
SlidingSyncMode::Paging
: Pages through the entire list of rooms one request at a time asking for the nextbatch_size
number of rooms up to the end ormaximum_number_of_rooms_to_fetch
if configuredSlidingSyncMode::Growing
: Grows the window bybatch_size
on every request till all rooms or untilmaximum_number_of_rooms_to_fetch
of rooms are in list.
§Rooms
Next to the room list, the details for rooms are the next important aspect.
Each list only references the OwnedRoomId
of the room at the given
position. The details (required_state
s and timeline items) requested by all
lists are bundled, together with the common details (e.g. whether it is a dm
or its calculated name) and made available on the Sliding Sync session struct as
a reactive through .get_all_rooms
,
get_room
and get_rooms
APIs.
Notably, this map only knows about the rooms that have come down Sliding
Sync protocol and if the given room isn’t in any active list range, it
may be stale. Additionally to selecting the room data via the room lists,
the Sliding Sync protocol allows to subscribe to specific rooms via
the subscribe_to_rooms()
. Any room subscribed
to will receive updates (with the given settings) regardless of whether they are
visible in any list. The most common case for using this API is when the user
enters a room - as we want to receive the incoming new messages regardless of
whether the room is pushed out of the lists room list.
§Extensions
Additionally to the room list and rooms with their state and latest messages Matrix knows of many other exchange information. All these are modeled as specific, optional extensions in the sliding sync protocol. This includes end-to-end-encryption, to-device-messages, typing- and presence-information and account-data, but can be extended by any implementation as they please. Handling of the data of the e2ee, to-device and typing-extensions takes place transparently within the SDK.
By default SlidingSync
doesn’t activate any extensions to save on
bandwidth, but we generally recommend to use the with_XXX_extensions
family
of methods when building sliding sync to enable e2ee, to-device-messages and
account-data-extensions.
§Timeline events
Both the list configuration as well as the room subscription
settings allow to specify a timeline_limit
to
receive timeline events. If that is unset or set to 0, no events are sent by
the server (which is the default), if multiple limits are found, the highest
takes precedence. Any positive number indicates that on the first request a
room should come into list, up to that count of messages are sent
(depending how many the server has in cache). Following, whenever new events
are found for the matching rooms, the server relays them to the client.
All timeline events coming through Sliding Sync will be processed through
the BaseClient
as in previous sync. This
allows for transparent decryption as well trigger the client_handlers
.
The current and then following live events list can be queried via the
timeline
API from matrix-sdk-ui
. This is prefilled with already received
data.
§Timeline trickling
To allow for a quick startup, client might want to request only a very low
timeline_limit
(maybe 1 or even 0) at first and update the count later on
the list or room subscription (see reactive api), Since
0.99.0-rc1
the sliding sync proxy will then “paginate back” and
resent the now larger number of events. All this is handled transparently.
§Long Polling
Sliding Sync is a long-polling API. That means that immediately after one has received data from the server, they re-open the network connection again and await for a new response. As there might not be happening much or a lot happening in short succession — from the client perspective we never know when new data is received.
One principle of long-polling is, therefore, that it might also takes one or two requests before the changes one asked for to actually be applied and the results come back for that. Just assume that at the same time one adds a room subscription, a new message comes in. The server might reply with that message immediately and will only kick off the process of calculating the rooms details and respond with that in the next request one does after.
This is modelled as a async Stream
in
our API, that one basically wants to continue polling. Once one has made its
setup ready and build its sliding sync sessions, one wants to acquire its
.sync()
and continuously poll it.
While the async stream API allows for streams to end (by returning None
)
Sliding Sync streams items Result<UpdateSummary, Error>
. For every
successful poll, all data is applied internally, through the base client and
the reactive structs and an
Ok(UpdateSummary)
is yielded with the minimum
information, which data has been refreshed in this iteration: names of
lists and room_id
s of rooms. Note that, the same way that a list isn’t
reacting if only the room data has changed (but not its position in its
list), the list won’t be mentioned here either, only the room_id
. So be
sure to look at both for all subscribed objects.
In full, this typically looks like this:
let sliding_sync = client
.sliding_sync("main-sync")?
// any lists you want are added here.
.build()
.await?;
let stream = sliding_sync.sync();
// continuously poll for updates
pin_mut!(stream);
loop {
let update = match stream.next().await {
Some(Ok(u)) => {
info!("Received an update. Summary: {u:?}");
}
Some(Err(e)) => {
error!("loop was stopped by client error processing: {e}");
}
None => {
error!("Streaming loop ended unexpectedly");
break;
}
};
}
§Quick refreshing
A main purpose of Sliding Sync is to provide an API for snappy end
user applications. Long-polling on the other side means that the client has
to wait for the server to respond and that can take quite some time, before
sending the next request with updates, for example an update in a list’s
range
.
That is a bit unfortunate and leaks through the stream
API as well. The
client is waiting for a stream.next().await
call before the next request
is sent. The specification on long-polling also states, however, that
if an new request is found coming in, the previous one shall be sent out. In
practice that means one can just start a new stream and the old connection
will return immediately — with a proper response though. One just needs to
make sure to not call that stream any further. Additionally, as both
requests are sent with the same positional argument, the server might
respond with data, the client has already processed. This isn’t a problem,
the SlidingSync
will only process new data and skip the processing
even across restarts.
To support this, in practice, one can spawn a Future
that runs
SlidingSync::sync
. The spawned Future
can be cancelled safely. If
the client was waiting on a response, it’s cancelled without any issue. If
a response was just received, it
will be fully handled by SlidingSync
. This response is always
handled process isn’t blocking. The cancellation of the spawned Future
will be as immediate as possible, and the response handling (if necessary)
will be done in a “detached mode”. However, any further responses handling
will have to wait. It is not possible for SlidingSync
to handle responses
concurrently.
§Reactive API
As the main source of truth is the data coming from the server, all updates
must be applied transparently throughout to the data layer. The simplest
way to stay up to date on what objects have changed is by checking the
lists
and rooms
of
each UpdateSummary
given by each stream iteration and update the local
copies accordingly. Because of where the loop sits in the stack, that can
be a bit tedious though, so lists and rooms have an additional way of
subscribing to updates via eyeball
.
§Caching
All room data, including the entire timeline events as well as all lists and
maximum_number_of_rooms
are held in memory (unless one pop
s the list out).
Sliding Sync instances are also cached on disk, and restored from disk at
creation. This ensures that we keep track of important position markers, like
the since
tokens used in the sync requests.
Caching for lists can be enabled independently, using the
add_cached_list
method, assuming
caching has been enabled before. In this case, during
.build()
SlidingSyncBuilder::build
sliding sync will attempt to load their
latest cached version from storage, as well as some overall information of
Sliding Sync. If that succeeded the lists state
has been set to
Preloaded
. Only room data of rooms
present in one of the lists is loaded from storage.
Any extension data will not be loaded from the cache, if added after Sliding
Sync has been built: some extension data (like the to-device-message position)
are stored to storage, but only retrieved upon build()
of the
SlidingSyncBuilder
. So if one only adds them later, they will not be reading
the data from storage (to avoid inconsistencies) and might require more data to
be sent in their first request than if they were loaded from a cold cache.
Only the latest 10 timeline items of each room are cached and they are reset whenever a new set of timeline items is received by the server.
§Full example
use matrix_sdk::{Client, sliding_sync::{SlidingSyncList, SlidingSyncMode, Version}};
use matrix_sdk_base::sliding_sync::http;
use ruma::{assign, events::StateEventType};
use tracing::{warn, error, info, debug};
use futures_util::{pin_mut, StreamExt};
use url::Url;
use std::future::ready;
let full_sync_list_name = "full-sync".to_owned();
let active_list_name = "active-list".to_owned();
let sliding_sync_builder = client
.sliding_sync("main-sync")?
.version(Version::Proxy { url: Url::parse("http://sliding-sync.example.org")? }) // our proxy server
.with_account_data_extension(
assign!(http::request::AccountData::default(), { enabled: Some(true) }),
) // we enable the account-data extension
.with_e2ee_extension(assign!(http::request::E2EE::default(), { enabled: Some(true) })) // and the e2ee extension
.with_to_device_extension(
assign!(http::request::ToDevice::default(), { enabled: Some(true) }),
); // and the to-device extension
let full_sync_list = SlidingSyncList::builder(&full_sync_list_name)
.sync_mode(SlidingSyncMode::Growing { batch_size: 50, maximum_number_of_rooms_to_fetch: Some(500) }) // sync up by growing the window
.required_state(vec![
(StateEventType::RoomEncryption, "".to_owned())
]); // only want to know if the room is encrypted
let active_list = SlidingSyncList::builder(&active_list_name) // the active window
.sync_mode(SlidingSyncMode::new_selective().add_range(0..=9)) // sync up the specific range only, first 10 items
.timeline_limit(5u32) // add the last 5 timeline items for room preview and faster timeline loading
.required_state(vec![ // we want to know immediately:
(StateEventType::RoomEncryption, "".to_owned()), // is it encrypted
(StateEventType::RoomTopic, "".to_owned()), // any topic if known
(StateEventType::RoomAvatar, "".to_owned()), // avatar if set
]);
let sliding_sync = sliding_sync_builder
.add_list(active_list)
.add_list(full_sync_list)
.build()
.await?;
tokio::spawn(async move {
// subscribe to rooms updates
let (_rooms, rooms_stream) = client.rooms_stream();
// do something with `_rooms`
pin_mut!(rooms_stream);
while let Some(_room) = rooms_stream.next().await {
info!("a room has been updated");
}
});
let stream = sliding_sync.sync();
// continuously poll for updates
pin_mut!(stream);
loop {
let update = match stream.next().await {
Some(Ok(u)) => {
info!("Received an update. Summary: {u:?}");
},
Some(Err(e)) => {
error!("loop was stopped by client error processing: {e}");
}
None => {
error!("Streaming loop ended unexpectedly");
break;
}
};
}
Modules§
- HTTP types for MSC4186 or MSC3585.
Structs§
- The Sliding Sync instance.
- Configuration for a Sliding Sync instance.
- Holding a specific filtered list within the concept of sliding sync.
- Builder for
SlidingSyncList
. - A Sliding Sync Room.
- Builder for a new sliding sync list in selective mode.
- Builder for a new sliding sync list in growing/paging mode.
- A summary of the updates received after a sync (like in
SlidingSync::sync
).
Enums§
- Internal representation of errors in Sliding Sync.
- The state the
SlidingSyncList
is in. - How a
SlidingSyncList
fetches the data. - The state of a
SlidingSyncRoom
. - A sliding sync version.
- A builder for
Version
. - An error when building a version.
Type Aliases§
- The type used to express natural bounds (including but not limited to: ranges, timeline limit) in the Sliding Sync.
- One range of rooms in a response from Sliding Sync.
- Many ranges of rooms.