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