Skip to main content

matrix_sdk/event_cache/caches/thread/
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
15use std::{fmt, sync::Arc};
16
17use eyeball::SharedObservable;
18use eyeball_im::VectorDiff;
19use matrix_sdk_base::{
20    event_cache::{Event, Gap},
21    linked_chunk::{ChunkContent, LinkedChunkId, Update},
22};
23use ruma::api::Direction;
24use tracing::{error, trace};
25
26use super::{
27    super::{
28        super::{
29            EventCacheError, EventsOrigin, Result, TimelineVectorDiffs,
30            deduplicator::{DeduplicationOutcome, filter_duplicate_events},
31        },
32        pagination::{
33            BackPaginationOutcome, LoadMoreEventsBackwardsOutcome, PaginatedCache, Pagination,
34            SharedPaginationStatus,
35        },
36        room::RoomEventCacheGenericUpdate,
37    },
38    ThreadEventCacheInner,
39    updates::ThreadEventCacheUpdate,
40};
41use crate::room::{IncludeRelations, RelationsOptions};
42
43/// Intermediate type because the `ThreadEventCache` state doesn't provide all
44/// the feature for the moment.
45// TODO: Remove this intermediate type.
46#[derive(Clone)]
47struct ThreadEventCacheWrapper {
48    cache: Arc<ThreadEventCacheInner>,
49
50    // Threads do not support pagination status for the moment but we need one,
51    // so let's use a dummy one for now.
52    dummy_pagination_status: SharedObservable<SharedPaginationStatus>,
53}
54
55/// An API object to run pagination queries on a `ThreadEventCache`.
56#[allow(missing_debug_implementations)]
57pub struct ThreadPagination(Pagination<ThreadEventCacheWrapper>);
58
59impl ThreadPagination {
60    /// Construct a new [`ThreadPagination`].
61    pub(super) fn new(cache: Arc<ThreadEventCacheInner>) -> Self {
62        Self(Pagination::new(ThreadEventCacheWrapper {
63            cache,
64            dummy_pagination_status: SharedObservable::new(SharedPaginationStatus::Idle {
65                hit_timeline_start: false,
66            }),
67        }))
68    }
69
70    /// Starts a back-pagination for the requested number of events.
71    ///
72    /// This automatically takes care of waiting for a pagination token from
73    /// sync, if we haven't done that before.
74    ///
75    /// It will run multiple back-paginations until one of these two conditions
76    /// is met:
77    ///
78    /// - either we've reached the start of the timeline,
79    /// - or we've obtained enough events to fulfill the requested number of
80    ///   events.
81    pub async fn run_backwards_until(
82        &self,
83        num_requested_events: u16,
84    ) -> Result<BackPaginationOutcome> {
85        self.0.run_backwards_until(num_requested_events).await
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    pub async fn run_backwards_once(&self, batch_size: u16) -> Result<BackPaginationOutcome> {
93        self.0.run_backwards_once(batch_size).await
94    }
95}
96
97impl PaginatedCache for ThreadEventCacheWrapper {
98    fn status(&self) -> &SharedObservable<SharedPaginationStatus> {
99        &self.dummy_pagination_status
100    }
101
102    async fn load_more_events_backwards(&self) -> Result<LoadMoreEventsBackwardsOutcome> {
103        let mut state = self.cache.state.write().await?;
104
105        // If any in-memory chunk is a gap, don't load more events, and let the
106        // caller resolve the gap.
107        if let Some(prev_token) = state.thread_linked_chunk().rgap().map(|gap| gap.token) {
108            trace!(%prev_token, "thread chunk has at least a gap");
109
110            return Ok(LoadMoreEventsBackwardsOutcome::Gap {
111                prev_token: Some(prev_token),
112                waited_for_initial_prev_token: state.waited_for_initial_prev_token(),
113            });
114        }
115
116        let prev_first_chunk = state.thread_linked_chunk().first_chunk();
117
118        // If we are here, it means all gaps have been resolved (see the `if`
119        // block above). So the first chunk is not a gap, we can load its
120        // previous chunk.
121        let linked_chunk_id = LinkedChunkId::Thread(&state.room_id, &state.thread_id);
122        let new_first_chunk = match state
123            .store
124            .load_previous_chunk(linked_chunk_id, prev_first_chunk.identifier())
125            .await
126        {
127            Ok(Some(new_first_chunk)) => {
128                // All good, let's continue with this chunk.
129                new_first_chunk
130            }
131
132            Ok(None) => {
133                // No previous chunk in the store.
134                //
135                // If the first in-memory event is the thread root, it's all
136                // good, we have effectively reached the start of the thread.
137                if let Some((_pos, first_event)) = state.thread_linked_chunk().events().next()
138                    && self.cache.thread_id
139                        == first_event.event_id().expect("Stored events all have an ID")
140                {
141                    trace!("thread chunk is fully loaded and non-empty: reached_start=true");
142
143                    return Ok(LoadMoreEventsBackwardsOutcome::StartOfTimeline);
144                }
145
146                // Otherwise, start back-pagination from the end of the thread.
147                return Ok(LoadMoreEventsBackwardsOutcome::Gap {
148                    prev_token: None,
149                    waited_for_initial_prev_token: state.waited_for_initial_prev_token(),
150                });
151            }
152
153            Err(err) => {
154                error!("error when loading the previous chunk of a linked chunk: {err}");
155
156                // Clear storage for this room.
157                state
158                    .store
159                    .handle_linked_chunk_updates(linked_chunk_id, vec![Update::Clear])
160                    .await?;
161
162                // Return the error.
163                return Err(err.into());
164            }
165        };
166
167        let chunk_content = new_first_chunk.content.clone();
168
169        // We've reached the start on disk, if and only if, there was no chunk
170        // prior to the one we just loaded.
171        //
172        // This value is correct, if and only if, it is used for a chunk content
173        // of kind `Items`.
174        let reached_start = new_first_chunk.previous.is_none();
175
176        if let Err(err) = state.thread_linked_chunk_mut().insert_new_chunk_as_first(new_first_chunk)
177        {
178            error!("error when inserting the previous chunk into its linked chunk: {err}");
179
180            // Clear storage for this thread.
181            state
182                .store
183                .handle_linked_chunk_updates(
184                    LinkedChunkId::Thread(&state.room_id, &state.thread_id),
185                    vec![Update::Clear],
186                )
187                .await?;
188
189            // Return the error.
190            return Err(err.into());
191        }
192
193        // ⚠️ Let's not propagate the updates to the store! We already have
194        // these data in the store! Let's drain them.
195        let _ = state.thread_linked_chunk_mut().store_updates().take();
196
197        // However, we want to get updates as `VectorDiff`s.
198        let timeline_event_diffs = state.thread_linked_chunk_mut().updates_as_vector_diffs();
199
200        Ok(match chunk_content {
201            ChunkContent::Gap(gap) => {
202                trace!("reloaded chunk from disk (gap)");
203
204                LoadMoreEventsBackwardsOutcome::Gap {
205                    prev_token: Some(gap.token),
206                    waited_for_initial_prev_token: state.waited_for_initial_prev_token(),
207                }
208            }
209
210            ChunkContent::Items(events) => {
211                trace!(?reached_start, "reloaded chunk from disk ({} items)", events.len());
212
213                LoadMoreEventsBackwardsOutcome::Events {
214                    events,
215                    timeline_event_diffs,
216                    reached_start,
217                }
218            }
219        })
220    }
221
222    async fn mark_has_waited_for_initial_prev_token(&self) -> Result<()> {
223        *self.cache.state.write().await?.waited_for_initial_prev_token_mut() = true;
224
225        Ok(())
226    }
227
228    async fn wait_for_prev_token(&self) {
229        self.cache.pagination_batch_token_notifier.notified().await
230    }
231
232    async fn paginate_backwards_with_network(
233        &self,
234        batch_size: u16,
235        prev_token: &Option<String>,
236    ) -> Result<Option<(Vec<Event>, Option<String>)>> {
237        let Some(room) = self.cache.weak_room.get() else {
238            // The client is shutting down.
239            return Ok(None);
240        };
241
242        let options = RelationsOptions {
243            from: prev_token.clone(),
244            dir: Direction::Backward,
245            limit: Some(batch_size.into()),
246            include_relations: IncludeRelations::AllRelations,
247            recurse: true,
248        };
249
250        let response = room
251            .relations(self.cache.thread_id.clone(), options)
252            .await
253            .map_err(|err| EventCacheError::PaginationError(Arc::new(err)))?;
254
255        Ok(Some((response.chunk, response.next_batch_token)))
256    }
257
258    async fn conclude_backwards_pagination_from_disk(
259        &self,
260        events: Vec<Event>,
261        timeline_event_diffs: Vec<VectorDiff<Event>>,
262        reached_start: bool,
263    ) -> BackPaginationOutcome {
264        if !timeline_event_diffs.is_empty() {
265            self.cache.update_sender.send(
266                ThreadEventCacheUpdate::UpdateTimelineEvents(TimelineVectorDiffs {
267                    diffs: timeline_event_diffs,
268                    origin: EventsOrigin::Cache,
269                }),
270                Some(RoomEventCacheGenericUpdate { room_id: self.cache.room_id.clone() }),
271            );
272        }
273
274        BackPaginationOutcome {
275            reached_start,
276            // This is a backwards pagination. `BackPaginationOutcome` expects
277            // events to be in “reverse order”.
278            events: events.into_iter().rev().collect(),
279        }
280    }
281
282    async fn conclude_backwards_pagination_from_network(
283        &self,
284        mut events: Vec<Event>,
285        prev_token: Option<String>,
286        mut new_token: Option<String>,
287    ) -> Result<Option<BackPaginationOutcome>> {
288        let Some(room) = self.cache.weak_room.get() else {
289            // The client is shutting down.
290            return Ok(None);
291        };
292
293        // The thread root event is **NOT** part of the `/relations` response.
294        // However, we want the thread root event to be part of the thread
295        // itself. It's easier in a lot of situations. Let's load it if
296        // necessary.
297        //
298        // It is necessary to load the thread root event when `new_token` is
299        // `None`, i.e. when we've reached the start of the thread usually.
300        //
301        // We must do this dance before acquiring the state lock because
302        // `Room::load_or_fetch_event` is hitting the state lock too.
303        if new_token.is_none() {
304            events.push(
305                room.load_or_fetch_event(&self.cache.thread_id, None)
306                    .await
307                    .map_err(|err| EventCacheError::PaginationError(Arc::new(err)))?,
308            );
309        }
310
311        let mut state = self.cache.state.write().await?;
312
313        // Check that the previous token still exists; otherwise it's a sign
314        // that the thread's timeline has been cleared.
315        let prev_gap_id = if let Some(token) = prev_token {
316            // Find the corresponding gap in the in-memory linked chunk.
317            let gap_chunk_id = state.thread_linked_chunk().chunk_identifier(|chunk| {
318                    matches!(chunk.content(), ChunkContent::Gap(Gap { token: prev_token }) if *prev_token == token)
319                });
320
321            if gap_chunk_id.is_none() {
322                // We got a previous-batch token from the linked chunk _before_
323                // running the request, but it is missing _after_ completing the
324                // request.
325                //
326                // It may be a sign the linked chunk has been reset, but it's
327                // fine!
328                return Ok(None);
329            }
330
331            gap_chunk_id
332        } else {
333            None
334        };
335
336        let DeduplicationOutcome {
337            all_events: mut events,
338            in_memory_duplicated_event_ids,
339            in_store_duplicated_event_ids,
340            non_empty_all_duplicates: all_duplicates,
341        } = filter_duplicate_events(
342            &state.own_user_id,
343            &state.store,
344            LinkedChunkId::Thread(&state.room_id, &state.thread_id),
345            state.thread_linked_chunk(),
346            events,
347        )
348        .await?;
349
350        // If not all the events have been back-paginated, we need to remove the
351        // previous ones, otherwise we can end up with misordered events.
352        //
353        // Consider the following scenario:
354        //
355        // - sync returns [D, E, F]
356        // - then sync returns [] with a previous batch token PB1, so the internal
357        //   linked chunk state is [D, E, F, PB1].
358        // - back-paginating with PB1 may return [A, B, C, D, E, F].
359        //
360        // Only inserting the new events when replacing PB1 would result in a
361        // timeline ordering of [D, E, F, A, B, C], which is incorrect. So we do
362        // have to remove all the events, in case this happens (see also #4746).
363
364        if !all_duplicates {
365            // Let's forget all the previous events.
366            state
367                .remove_events(in_memory_duplicated_event_ids, in_store_duplicated_event_ids)
368                .await?;
369        } else {
370            // All new events are duplicated, they can all be ignored.
371            events.clear();
372            // The gap can be ditched too, as it won't be useful to backpaginate
373            // any further.
374            new_token = None;
375        }
376
377        // `/relations` has been called with `dir=b` (backwards), so the events
378        // are in the inverted order; reorder them.
379        let topo_ordered_events = events.iter().rev().cloned().collect::<Vec<_>>();
380
381        let new_gap = new_token.map(|prev_token| Gap { token: prev_token });
382        let reached_start = state.thread_linked_chunk_mut().push_backwards_pagination_events(
383            prev_gap_id,
384            new_gap,
385            &topo_ordered_events,
386        );
387
388        // Update the store.
389        state.state.propagate_changes(&state.store).await?;
390
391        // A back-pagination can't include new read receipt events, as those are
392        // ephemeral events not included in /relations responses, so we can
393        // safely set the receipt event to None here.
394        //
395        // Note: read receipts may be updated anyhow in the post-processing
396        // step, as the back-pagination may have revealed the event pointed to
397        // by the latest read receipt.
398        let receipt_event = None;
399
400        // Post-process newly inserted events.
401        state.post_process_upserted_events(topo_ordered_events.iter(), receipt_event).await?;
402
403        // Notify observers about the updates.
404        let timeline_event_diffs = state.thread_linked_chunk_mut().updates_as_vector_diffs();
405
406        if !timeline_event_diffs.is_empty() {
407            state.update_sender.send(
408                ThreadEventCacheUpdate::UpdateTimelineEvents(TimelineVectorDiffs {
409                    diffs: timeline_event_diffs,
410                    origin: EventsOrigin::Pagination,
411                }),
412                Some(RoomEventCacheGenericUpdate { room_id: state.room_id.clone() }),
413            );
414        }
415
416        Ok(Some(BackPaginationOutcome { reached_start, events }))
417    }
418}
419
420impl fmt::Debug for ThreadPagination {
421    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
422        formatter.debug_tuple("ThreadPagination").finish_non_exhaustive()
423    }
424}