matrix_sdk/event_cache/caches/pagination.rs
1// Copyright 2026 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The logic to paginate a cache (room, thread…) over the disk or the network.
16
17use std::{pin::Pin, sync::Arc, time::Duration};
18
19use eyeball::{ObservableWriteGuard, SharedObservable};
20use futures_util::{
21 FutureExt as _,
22 future::{Either, Shared, ready},
23};
24use matrix_sdk_base::{
25 SendOutsideWasm, SyncOutsideWasm, event_cache::Event, executor::AbortOnDrop, timeout::timeout,
26};
27use matrix_sdk_common::executor::spawn;
28use tracing::{debug, instrument, trace, warn};
29
30use super::super::Result;
31
32/// Type to run paginations.
33#[derive(Clone, Debug)]
34pub(in super::super) struct Pagination<C: SendOutsideWasm + 'static> {
35 pub cache: C,
36}
37
38impl<C: SendOutsideWasm + 'static> Pagination<C> {
39 /// Create a new [`Pagination`].
40 pub fn new(cache: C) -> Self {
41 Self { cache }
42 }
43}
44
45impl<C> Pagination<C>
46where
47 C: Clone + PaginatedCache + SendOutsideWasm + 'static + SyncOutsideWasm,
48{
49 /// Starts a back-pagination for the requested number of events.
50 ///
51 /// This automatically takes care of waiting for a pagination token from
52 /// sync, if we haven't done that before.
53 ///
54 /// It will run multiple back-paginations until one of these two conditions
55 /// is met:
56 ///
57 /// - either we've reached the start of the timeline,
58 /// - or we've obtained enough events to fulfill the requested number of
59 /// events.
60 #[instrument(skip(self))]
61 pub async fn run_backwards_until(
62 &self,
63 num_requested_events: u16,
64 ) -> Result<BackPaginationOutcome> {
65 let mut events = Vec::new();
66
67 loop {
68 if let Some(outcome) = self.run_backwards_impl(num_requested_events).await? {
69 events.extend(outcome.events);
70
71 if outcome.reached_start || events.len() >= num_requested_events as usize {
72 return Ok(BackPaginationOutcome {
73 reached_start: outcome.reached_start,
74 events,
75 });
76 }
77
78 trace!(
79 "restarting back-pagination, because we haven't reached \
80 the start or obtained enough events yet"
81 );
82 }
83
84 debug!("restarting back-pagination because of a timeline reset.");
85 }
86 }
87
88 /// Run a single back-pagination for the requested number of events.
89 ///
90 /// This automatically takes care of waiting for a pagination token from
91 /// sync, if we haven't done that before.
92 #[instrument(skip(self))]
93 pub async fn run_backwards_once(&self, batch_size: u16) -> Result<BackPaginationOutcome> {
94 loop {
95 if let Some(outcome) = self.run_backwards_impl(batch_size).await? {
96 return Ok(outcome);
97 }
98
99 debug!("restarting back-pagination because of a timeline reset");
100 }
101 }
102
103 /// Paginate from either the storage or the network, and let pagination
104 /// status observers know about updates.
105 ///
106 /// Returns `Ok(None)` if the pagination token used during a network
107 /// pagination has disappeared from the in-memory linked chunk after
108 /// handling the response.
109 // Implementation note: return a future instead of making the function
110 // async, so as to not cause issues because the `cache` field is borrowed
111 // across await points.
112 fn run_backwards_impl(
113 &self,
114 batch_size: u16,
115 ) -> impl Future<Output = Result<Option<BackPaginationOutcome>>> {
116 // There is at least one gap that must be resolved; reach the network.
117 // First, ensure there's no other ongoing back-pagination.
118 let status_observable = self.cache.status();
119
120 let mut status_guard = status_observable.write();
121
122 match &*status_guard {
123 SharedPaginationStatus::Idle { hit_timeline_start } => {
124 if *hit_timeline_start {
125 // Force an extra notification for observers.
126 ObservableWriteGuard::set(
127 &mut status_guard,
128 SharedPaginationStatus::Idle { hit_timeline_start: true },
129 );
130
131 return Either::Left(ready(Ok(Some(BackPaginationOutcome {
132 reached_start: true,
133 events: Vec::new(),
134 }))));
135 }
136 }
137
138 SharedPaginationStatus::Paginating { shared_task: shared } => {
139 // There was already a back-pagination request in progress; wait
140 // for it to finish and return its result.
141 let shared = shared.clone();
142 drop(status_guard);
143 return Either::Right(shared.fut.clone());
144 }
145 }
146
147 let reset_status_on_drop_guard = ResetStatusOnDrop {
148 prev_status: Some(status_guard.clone()),
149 pagination_status: status_observable.clone(),
150 };
151
152 let this = self.clone();
153
154 let fut: Pin<Box<dyn SharedPaginationFuture>> = Box::pin(async move {
155 match this.paginate_backwards_impl(batch_size).await? {
156 Some(outcome) => {
157 // Back-pagination's over and successful, don't reset the
158 // status to the previous value.
159 reset_status_on_drop_guard.disarm();
160
161 // Notify subscribers that pagination ended.
162 this.cache.status().set(SharedPaginationStatus::Idle {
163 hit_timeline_start: outcome.reached_start,
164 });
165
166 Ok(Some(outcome))
167 }
168
169 None => Ok(None),
170 }
171 });
172
173 let shared_task = fut.shared();
174
175 // Start polling in the background, in a spawned task.
176 let shared_task_clone = shared_task.clone();
177 let join_handle = spawn(async move {
178 if let Err(err) = shared_task_clone.await {
179 warn!("event cache back-pagination failed: {err}");
180 }
181 });
182
183 ObservableWriteGuard::set(
184 &mut status_guard,
185 SharedPaginationStatus::Paginating {
186 shared_task: SharedPaginationTask {
187 fut: shared_task.clone(),
188 _join_handle: Arc::new(AbortOnDrop::new(join_handle)),
189 },
190 },
191 );
192
193 // Release the shared lock before waiting for the task to complete.
194 drop(status_guard);
195
196 Either::Right(shared_task)
197 }
198
199 /// Paginate from either the storage or the network.
200 ///
201 /// This method isn't concerned with setting the pagination status; only the
202 /// caller is.
203 ///
204 /// Returns `Ok(None)` if the pagination token used during a network
205 /// pagination has disappeared from the in-memory linked chunk after
206 /// handling the response.
207 async fn paginate_backwards_impl(
208 &self,
209 batch_size: u16,
210 ) -> Result<Option<BackPaginationOutcome>> {
211 // A linked chunk might not be entirely loaded (if it's been
212 // lazy-loaded). Try to load from disk/storage first, then from network
213 // if disk/storage indicated there's no previous events chunk to load.
214
215 loop {
216 match self.cache.load_more_events_backwards().await? {
217 LoadMoreEventsBackwardsOutcome::Gap {
218 prev_token,
219 waited_for_initial_prev_token,
220 } => {
221 if prev_token.is_none() && !waited_for_initial_prev_token {
222 // We didn't reload a pagination token, and we haven't
223 // waited for one; wait and start over.
224
225 const DEFAULT_WAIT_FOR_TOKEN_DURATION: Duration = Duration::from_secs(3);
226
227 // Otherwise, wait for a notification that we received a
228 // previous-batch token.
229 trace!("waiting for a pagination token…");
230
231 let _ = timeout(
232 self.cache.wait_for_prev_token(),
233 DEFAULT_WAIT_FOR_TOKEN_DURATION,
234 )
235 .await;
236
237 trace!("done waiting");
238
239 self.cache.mark_has_waited_for_initial_prev_token().await?;
240
241 // Retry!
242 //
243 // Note: the next call to `load_more_events_backwards`
244 // should not return `WaitForInitialPrevToken` because
245 // we've just marked we've waited for the initial
246 // `prev_token`, so this is not an infinite loop.
247 //
248 // Note 2: not a recursive call, because recursive and
249 // async have a bad time together.
250 continue;
251 }
252
253 // We have a gap, so resolve it with a network
254 // back-pagination.
255 return self.paginate_backwards_with_network(batch_size, prev_token).await;
256 }
257
258 LoadMoreEventsBackwardsOutcome::StartOfTimeline => {
259 return Ok(Some(BackPaginationOutcome { reached_start: true, events: vec![] }));
260 }
261
262 LoadMoreEventsBackwardsOutcome::Events { events, reached_start } => {
263 return Ok(Some(BackPaginationOutcome {
264 reached_start,
265 // This is a backwards pagination. `BackPaginationOutcome` expects
266 // events to be in “reverse order”.
267 events: events.into_iter().rev().collect(),
268 }));
269 }
270 }
271 }
272 }
273
274 /// Run a single pagination request to the server.
275 ///
276 /// Returns `Ok(None)` if the pagination token used during the request has
277 /// disappeared from the in-memory linked chunk after handling the response.
278 async fn paginate_backwards_with_network(
279 &self,
280 batch_size: u16,
281 prev_token: Option<String>,
282 ) -> Result<Option<BackPaginationOutcome>> {
283 let Some((events, new_token)) =
284 self.cache.paginate_backwards_with_network(batch_size, &prev_token).await?
285 else {
286 // Return an empty default response.
287 return Ok(Some(BackPaginationOutcome {
288 reached_start: false,
289 events: Default::default(),
290 }));
291 };
292
293 self.cache.conclude_backwards_pagination_from_network(events, prev_token, new_token).await
294 }
295}
296
297trait SharedPaginationFuture:
298 Future<Output = Result<Option<BackPaginationOutcome>>> + SendOutsideWasm
299{
300}
301
302impl<T: Future<Output = Result<Option<BackPaginationOutcome>>> + SendOutsideWasm>
303 SharedPaginationFuture for T
304{
305}
306
307/// State for having a pagination run in the background, and be awaited upon by
308/// several tasks.
309///
310/// Such a pagination may be started automatically or manually. It's possible
311/// for a manual caller to wait upon its completion, by awaiting the underlying
312/// shared future.
313#[derive(Clone)]
314pub(in super::super) struct SharedPaginationTask {
315 /// The shared future for a pagination request running in the background, so
316 /// that multiple callers can await it.
317 fut: Shared<Pin<Box<dyn SharedPaginationFuture>>>,
318
319 /// The owned task that started the above future.
320 _join_handle: Arc<AbortOnDrop<()>>,
321}
322
323#[derive(Clone)]
324pub(in super::super) enum SharedPaginationStatus {
325 /// No pagination is happening right now.
326 Idle {
327 /// Have we hit the start of the timeline, i.e. paginating wouldn't have
328 /// any effect?
329 hit_timeline_start: bool,
330 },
331
332 /// Pagination is already running in the background.
333 Paginating { shared_task: SharedPaginationTask },
334}
335
336pub(in super::super) trait PaginatedCache {
337 fn status(&self) -> &SharedObservable<SharedPaginationStatus>;
338
339 fn load_more_events_backwards(
340 &self,
341 ) -> impl Future<Output = Result<LoadMoreEventsBackwardsOutcome>> + SendOutsideWasm;
342
343 fn mark_has_waited_for_initial_prev_token(
344 &self,
345 ) -> impl Future<Output = Result<()>> + SendOutsideWasm;
346
347 fn wait_for_prev_token(&self) -> impl Future<Output = ()> + SendOutsideWasm;
348
349 fn paginate_backwards_with_network(
350 &self,
351 batch_size: u16,
352 prev_token: &Option<String>,
353 ) -> impl Future<Output = Result<Option<(Vec<Event>, Option<String>)>>> + SendOutsideWasm;
354
355 fn conclude_backwards_pagination_from_network(
356 &self,
357 events: Vec<Event>,
358 prev_token: Option<String>,
359 new_token: Option<String>,
360 ) -> impl Future<Output = Result<Option<BackPaginationOutcome>>> + SendOutsideWasm;
361}
362
363/// Status for the pagination on a cache.
364#[derive(Debug, PartialEq, Clone, Copy)]
365#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
366pub enum PaginationStatus {
367 /// No pagination is happening right now.
368 Idle {
369 /// Have we hit the start of the timeline, i.e. paginating wouldn't have
370 /// any effect?
371 hit_timeline_start: bool,
372 },
373
374 /// Pagination is already running in the background.
375 Paginating,
376}
377
378/// Small RAII guard to reset the pagination status on drop, if not disarmed in
379/// the meanwhile.
380struct ResetStatusOnDrop {
381 prev_status: Option<SharedPaginationStatus>,
382 pagination_status: SharedObservable<SharedPaginationStatus>,
383}
384
385impl ResetStatusOnDrop {
386 /// Make the RAII guard have no effect.
387 fn disarm(mut self) {
388 self.prev_status = None;
389 }
390}
391
392impl Drop for ResetStatusOnDrop {
393 fn drop(&mut self) {
394 if let Some(status) = self.prev_status.take() {
395 let _ = self.pagination_status.set(status);
396 }
397 }
398}
399
400/// The result of a single back-pagination request.
401#[derive(Clone, Debug)]
402pub struct BackPaginationOutcome {
403 /// Did the back-pagination reach the start of the timeline?
404 pub reached_start: bool,
405
406 /// All the events that have been returned in the back-pagination request.
407 ///
408 /// Events are presented in reverse order: the first element of the vec, if
409 /// present, is the most "recent" event from the chunk (or technically, the
410 /// last one in the topological ordering).
411 pub events: Vec<Event>,
412}
413
414/// Internal type to represent the output of
415/// [`PaginatedCache::load_more_events_backwards`].
416#[derive(Debug)]
417pub(in super::super) enum LoadMoreEventsBackwardsOutcome {
418 /// A gap has been inserted.
419 Gap {
420 /// The previous batch token to be used as the "end" parameter in the
421 /// back-pagination request.
422 prev_token: Option<String>,
423
424 waited_for_initial_prev_token: bool,
425 },
426
427 /// The start of the timeline has been reached.
428 StartOfTimeline,
429
430 /// Events have been inserted.
431 ///
432 /// The resulting timeline diffs have already been sent to subscribers,
433 /// while the state lock was held.
434 Events { events: Vec<Event>, reached_start: bool },
435}