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