Skip to main content

matrix_sdk/event_cache/
search_backfill.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//! Sweeps every joined room, back-paginating message history to populate the
16//! search index (and the event cache) with a few months of history.
17//!
18//! Requests go on the shared [`BackPaginationQueue`] at the lowest priority, so
19//! reactive work (latest event, read receipts) always takes precedence.
20
21use std::{collections::HashSet, ops::ControlFlow, time::Duration};
22
23use matrix_sdk_base::sleep::sleep;
24use ruma::{MilliSecondsSinceUnixEpoch, OwnedRoomId, time::Instant};
25use tracing::{debug, info, trace, warn};
26
27use super::{
28    EventCache,
29    back_pagination_queue::{
30        BATCH_SIZE, BackPaginationQueue, BackPaginationRequest, BackPaginationStopReason, Priority,
31    },
32    caches::pagination::BackPaginationOutcome,
33};
34use crate::Client;
35
36/// A week.
37const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
38
39/// How far back a search backfill goes: ~3 months, one week at a time.
40const MAX_BACKFILL_WEEKS: u32 = 13;
41
42/// How many rooms are enqueued and drained together before the next batch is
43/// taken, within a week of the search backfill sweep.
44const ROOM_BATCH: usize = 100;
45
46/// Number of paginations allowed per room, per week, in a search backfill.
47///
48/// Bounds how long a single very active room can occupy a concurrency slot: a
49/// room that doesn't reach the week's floor within this many batches is retried
50/// on the next sweep, rather than blocking higher-priority work indefinitely
51/// (there's no preemption, so requests must be self-limiting).
52const SEARCH_MAX_BATCHES_PER_ROOM: usize = 10;
53
54/// How aggressively a search backfill runs.
55#[derive(Clone, Copy, Debug)]
56#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
57pub enum SearchBackfillStrategy {
58    /// The app is in the foreground: pause between paginations so this doesn't
59    /// compete with interactive traffic.
60    Foreground,
61    /// A time-boxed background task (e.g. iOS `BGAppRefreshTask`) where there's
62    /// no interactive traffic to protect.
63    Background,
64}
65
66impl SearchBackfillStrategy {
67    /// How long to wait between introducing successive rooms into a search
68    /// sweep (not between a single room's own pagination batches).
69    fn enqueue_delay(self) -> Option<Duration> {
70        match self {
71            Self::Foreground => Some(Duration::from_secs(1)),
72            Self::Background => None,
73        }
74    }
75}
76
77impl EventCache {
78    /// Sweep every room, back-paginating message history down to a 3 months
79    /// floor (`MAX_BACKFILL_WEEKS`) to populate the search index (and the event
80    /// cache).
81    ///
82    /// Coverage is front-loaded by recency: the last week is filled for all
83    /// rooms first, then the previous week, and so on, in batches of rooms.
84    ///
85    /// `strategy` paces how fast new rooms are introduced into the sweep:
86    /// [`SearchBackfillStrategy::Foreground`] spaces them out so this doesn't
87    /// compete with interactive traffic. [`SearchBackfillStrategy::Background`]
88    /// introduces them as fast as the concurrency cap allows.
89    ///
90    /// No-ops if automatic back-pagination is disabled.
91    pub async fn run_search_backfill(&self, strategy: SearchBackfillStrategy) {
92        let Some(queue) = self.back_pagination_queue() else {
93            return;
94        };
95        let Ok(client) = self.inner.client() else {
96            return;
97        };
98
99        let enqueue_delay = strategy.enqueue_delay();
100        let started = Instant::now();
101
102        let mut rooms = rooms_by_relevancy(&client);
103        let total_rooms = rooms.len();
104        let target_age = MAX_BACKFILL_WEEKS * WEEK;
105        info!(
106            ?strategy,
107            total_rooms,
108            weeks = MAX_BACKFILL_WEEKS,
109            ?target_age,
110            "search backfill started"
111        );
112
113        // Rooms that reached the start of their timeline.
114        let mut drained: HashSet<OwnedRoomId> = HashSet::new();
115
116        for week in 1..=MAX_BACKFILL_WEEKS {
117            let max_age = week * WEEK;
118
119            rooms.retain(|room_id| !drained.contains(room_id));
120            debug!(
121                week,
122                of = MAX_BACKFILL_WEEKS,
123                ?max_age,
124                number_of_rooms_to_process = rooms.len(),
125                "search backfill week"
126            );
127
128            for chunk in rooms.chunks(ROOM_BATCH) {
129                // Enqueue the batch then wait for it to drain before moving to
130                // the next batch / deeper week. The queue bounds actual
131                // concurrency.
132                let mut handles = Vec::new();
133                for room_id in chunk {
134                    if !handles.is_empty()
135                        && let Some(delay) = enqueue_delay
136                    {
137                        sleep(delay).await;
138                    }
139                    debug!(%room_id, "started search backfill");
140                    match enqueue(&queue, room_id.clone(), max_age) {
141                        Ok(handle) => handles.push((room_id.clone(), handle)),
142                        Err(err) => {
143                            warn!(%room_id, "couldn't enqueue a search backfill request: {err}")
144                        }
145                    }
146                }
147
148                for (room_id, handle) in handles {
149                    let reason = handle.join().await.reason;
150                    trace!(
151                        %room_id,
152                        week,
153                        of = MAX_BACKFILL_WEEKS,
154                        ?reason,
155                        "finished search backpagination request"
156                    );
157                    if reason == BackPaginationStopReason::ReachedTimelineStart {
158                        drained.insert(room_id);
159                    }
160                }
161            }
162        }
163
164        info!(
165            total_rooms,
166            rooms_drained = drained.len(),
167            reached_age = ?target_age,
168            elapsed = ?started.elapsed(),
169            "search backfill finished"
170        );
171    }
172}
173
174/// Joined room ids, most-recently-active first.
175fn rooms_by_relevancy(client: &Client) -> Vec<OwnedRoomId> {
176    let mut rooms = client.joined_rooms();
177    rooms.sort_by_key(|room| std::cmp::Reverse(room.recency_stamp()));
178    rooms.into_iter().map(|room| room.room_id().to_owned()).collect()
179}
180
181/// Enqueue one room's search back-pagination: lowest priority, stopping once a
182/// batch is `max_age` old, capped so a very active room is retried on the next
183/// sweep rather than left to run indefinitely.
184fn enqueue(
185    queue: &BackPaginationQueue,
186    room_id: OwnedRoomId,
187    max_age: Duration,
188) -> Result<
189    super::back_pagination_queue::BackPaginationHandle,
190    super::back_pagination_queue::BackPaginationQueueError,
191> {
192    queue.enqueue(BackPaginationRequest {
193        room_id,
194        priority: Priority::Low,
195        stop: Box::new(stop_when_older_than(max_age)),
196        batch_size: BATCH_SIZE,
197        max_batches: Some(SEARCH_MAX_BATCHES_PER_ROOM),
198    })
199}
200
201/// A stop predicate that fires once a batch's oldest event is at least
202/// `max_age` old.
203fn stop_when_older_than(
204    max_age: Duration,
205) -> impl FnMut(&BackPaginationOutcome) -> ControlFlow<()> + Send + 'static {
206    move |outcome| {
207        let old_enough =
208            oldest_event_timestamp(outcome).and_then(age_of).is_some_and(|age| age >= max_age);
209
210        if old_enough { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
211    }
212}
213
214/// The oldest event timestamp in a batch, if any.
215///
216/// The batch is in reverse topological order, so the oldest event comes last.
217fn oldest_event_timestamp(outcome: &BackPaginationOutcome) -> Option<MilliSecondsSinceUnixEpoch> {
218    outcome.events.iter().rev().find_map(|event| event.timestamp())
219}
220
221/// How long ago an event's timestamp was. `None` when it's in the future or out
222/// of range, i.e. clock skew between the sending server and this device; such
223/// an event never satisfies an age-based stop condition.
224fn age_of(ts: MilliSecondsSinceUnixEpoch) -> Option<Duration> {
225    ts.to_system_time()?.elapsed().ok()
226}
227
228#[cfg(all(test, not(target_arch = "wasm32")))]
229mod tests {
230    use std::time::Duration;
231
232    use assert_matches::assert_matches;
233    use eyeball_im::VectorDiff;
234    use matrix_sdk_test::{BOB, JoinedRoomBuilder, async_test, event_factory::EventFactory};
235    use ruma::{MilliSecondsSinceUnixEpoch, event_id, room_id, time::SystemTime};
236
237    use super::{SearchBackfillStrategy, WEEK, stop_when_older_than};
238    use crate::{
239        assert_let_timeout,
240        event_cache::{BackPaginationOutcome, EventsOrigin, RoomEventCacheUpdate},
241        test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},
242    };
243
244    /// A timestamp `age` in the past.
245    fn ts_ago(age: Duration) -> MilliSecondsSinceUnixEpoch {
246        MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now() - age).unwrap()
247    }
248
249    /// The age predicate breaks once the oldest event in a batch is at least
250    /// that old.
251    #[test]
252    fn test_stop_when_older_than() {
253        let f = EventFactory::new().room(room_id!("!omelette:fromage.fr")).sender(*BOB);
254        let outcome = BackPaginationOutcome {
255            reached_start: false,
256            events: vec![
257                f.text_msg("recent").server_ts(ts_ago(Duration::from_secs(60))).into_event(),
258                f.text_msg("older").server_ts(ts_ago(WEEK)).into_event(),
259            ],
260        };
261
262        // Oldest event is a week old, under the two-week bound → keep going.
263        assert!(stop_when_older_than(2 * WEEK)(&outcome).is_continue());
264        // At/over the bound → stop.
265        assert!(stop_when_older_than(WEEK)(&outcome).is_break());
266        assert!(stop_when_older_than(Duration::from_secs(3600))(&outcome).is_break());
267    }
268
269    /// A search backfill sweeps the rooms, back-paginating each until it
270    /// reaches the start of the timeline.
271    #[async_test]
272    async fn test_search_backfill_drains_room() {
273        let server = MatrixMockServer::new().await;
274        let client = server
275            .client_builder()
276            .on_builder(|builder| builder.with_enable_automatic_back_pagination(true))
277            .build()
278            .await;
279
280        let event_cache = client.event_cache();
281        event_cache.subscribe().unwrap();
282
283        let room_id = room_id!("!omelette:fromage.fr");
284        let f = EventFactory::new().room(room_id).sender(*BOB);
285
286        let room = server.sync_joined_room(&client, room_id).await;
287        let (room_event_cache, _drop_handles) = room.event_cache().await.unwrap();
288        let (room_events, mut room_cache_updates) = room_event_cache.subscribe().await.unwrap();
289        assert!(room_events.is_empty());
290
291        server
292            .sync_room(
293                &client,
294                JoinedRoomBuilder::new(room_id)
295                    .set_timeline_limited()
296                    .set_timeline_prev_batch("prev_batch"),
297            )
298            .await;
299
300        assert_let_timeout!(
301            Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) = room_cache_updates.recv()
302        );
303        assert_matches!(update.diffs[0], VectorDiff::Clear);
304
305        // `/messages` returns two events and no end token → start of timeline
306        // reached.
307        server
308            .mock_room_messages()
309            .match_from("prev_batch")
310            .ok(RoomMessagesResponseTemplate::default().events(vec![
311                f.text_msg("comté").event_id(event_id!("$2")),
312                f.text_msg("beaufort").event_id(event_id!("$1")),
313            ]))
314            .mock_once()
315            .mount()
316            .await;
317
318        // The single room drains on the first week and is skipped afterwards,
319        // so only one `/messages` call happens (guaranteed by `mock_once`).
320        event_cache.run_search_backfill(SearchBackfillStrategy::Foreground).await;
321
322        assert_let_timeout!(
323            Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) = room_cache_updates.recv()
324        );
325        assert_matches!(update.origin, EventsOrigin::Pagination);
326
327        let mut room_events = room_events.into();
328        for diff in update.diffs {
329            diff.apply(&mut room_events);
330        }
331        assert_eq!(room_events.len(), 2);
332        assert_eq!(room_events[0].event_id().unwrap(), event_id!("$1"));
333        assert_eq!(room_events[1].event_id().unwrap(), event_id!("$2"));
334    }
335}