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    /// Before that, the events already saved in the event cache store are
91    /// indexed, once.
92    ///
93    /// No-ops if automatic back-pagination is disabled.
94    pub async fn run_search_backfill(&self, strategy: SearchBackfillStrategy) {
95        let Some(queue) = self.back_pagination_queue() else {
96            return;
97        };
98        let Ok(client) = self.inner.client() else {
99            return;
100        };
101
102        #[cfg(feature = "experimental-search")]
103        self.reindex_stored_events(&client).await;
104
105        let enqueue_delay = strategy.enqueue_delay();
106        let started = Instant::now();
107
108        let mut rooms = rooms_by_relevancy(&client);
109        let total_rooms = rooms.len();
110        let target_age = MAX_BACKFILL_WEEKS * WEEK;
111        info!(
112            ?strategy,
113            total_rooms,
114            weeks = MAX_BACKFILL_WEEKS,
115            ?target_age,
116            "search backfill started"
117        );
118
119        // Rooms that reached the start of their timeline.
120        let mut drained: HashSet<OwnedRoomId> = HashSet::new();
121
122        for week in 1..=MAX_BACKFILL_WEEKS {
123            let max_age = week * WEEK;
124
125            rooms.retain(|room_id| !drained.contains(room_id));
126            debug!(
127                week,
128                of = MAX_BACKFILL_WEEKS,
129                ?max_age,
130                number_of_rooms_to_process = rooms.len(),
131                "search backfill week"
132            );
133
134            for chunk in rooms.chunks(ROOM_BATCH) {
135                // Enqueue the batch then wait for it to drain before moving to
136                // the next batch / deeper week. The queue bounds actual
137                // concurrency.
138                let mut handles = Vec::new();
139                for room_id in chunk {
140                    if !handles.is_empty()
141                        && let Some(delay) = enqueue_delay
142                    {
143                        sleep(delay).await;
144                    }
145                    debug!(%room_id, "started search backfill");
146                    match enqueue(&queue, room_id.clone(), max_age) {
147                        Ok(handle) => handles.push((room_id.clone(), handle)),
148                        Err(err) => {
149                            warn!(%room_id, "couldn't enqueue a search backfill request: {err}")
150                        }
151                    }
152                }
153
154                for (room_id, handle) in handles {
155                    let reason = handle.join().await.reason;
156                    trace!(
157                        %room_id,
158                        week,
159                        of = MAX_BACKFILL_WEEKS,
160                        ?reason,
161                        "finished search backpagination request"
162                    );
163                    // Skipped rooms won't change on the next week, so don't
164                    // retry them.
165                    if matches!(
166                        reason,
167                        BackPaginationStopReason::ReachedTimelineStart
168                            | BackPaginationStopReason::Skipped
169                    ) {
170                        drained.insert(room_id);
171                    }
172                }
173            }
174        }
175
176        info!(
177            total_rooms,
178            rooms_drained = drained.len(),
179            reached_age = ?target_age,
180            elapsed = ?started.elapsed(),
181            "search backfill finished"
182        );
183    }
184}
185
186impl EventCache {
187    /// Index the events saved in the event cache store, once: the search index
188    /// may have missed some, or have been recreated.
189    #[cfg(feature = "experimental-search")]
190    async fn reindex_stored_events(&self, client: &Client) {
191        use crate::search_index::REINDEX_MARKER;
192
193        let dir = client.search_index().directory();
194        if dir.is_some_and(|dir| dir.join(REINDEX_MARKER).exists()) {
195            return;
196        }
197
198        let started = Instant::now();
199        let rooms = client.joined_rooms();
200        info!(total_rooms = rooms.len(), "reindexing stored events");
201
202        for room in rooms {
203            let room_id = room.room_id();
204
205            let loaded = async {
206                let caches = self.inner.all_caches_for_room(room_id).await?;
207                Ok::<_, super::EventCacheError>((
208                    caches.room().clone(),
209                    caches.stored_events().await?,
210                ))
211            };
212            let Ok((room_cache, events)) = loaded.await.inspect_err(|err| {
213                warn!(%room_id, "couldn't load the room's stored events: {err}");
214            }) else {
215                continue;
216            };
217
218            let redaction_rules = room.clone_info().room_version_rules_or_default().redaction;
219            if let Err(err) = client
220                .search_index()
221                .bulk_handle_timeline_event(
222                    events.into_iter(),
223                    &room_cache,
224                    room_id,
225                    &redaction_rules,
226                )
227                .await
228            {
229                warn!(%room_id, "couldn't index the room's stored events: {err}");
230            }
231        }
232
233        // Only now, so an interrupted reindex starts over next time.
234        if let Some(dir) = dir
235            && let Err(err) = std::fs::create_dir_all(dir)
236                .and_then(|()| std::fs::write(dir.join(REINDEX_MARKER), b""))
237        {
238            warn!("couldn't write the reindex marker: {err}");
239        }
240
241        info!(elapsed = ?started.elapsed(), "reindexed stored events");
242    }
243}
244
245/// Joined room ids, most-recently-active first.
246fn rooms_by_relevancy(client: &Client) -> Vec<OwnedRoomId> {
247    let mut rooms = client.joined_rooms();
248    rooms.sort_by_key(|room| std::cmp::Reverse(room.recency_stamp()));
249    rooms.into_iter().map(|room| room.room_id().to_owned()).collect()
250}
251
252/// Enqueue one room's search back-pagination: lowest priority, stopping once a
253/// batch is `max_age` old, capped so a very active room is retried on the next
254/// sweep rather than left to run indefinitely.
255fn enqueue(
256    queue: &BackPaginationQueue,
257    room_id: OwnedRoomId,
258    max_age: Duration,
259) -> Result<
260    super::back_pagination_queue::BackPaginationHandle,
261    super::back_pagination_queue::BackPaginationQueueError,
262> {
263    queue.enqueue(BackPaginationRequest {
264        room_id,
265        priority: Priority::Low,
266        stop: Box::new(stop_when_older_than(max_age)),
267        batch_size: BATCH_SIZE,
268        max_batches: Some(SEARCH_MAX_BATCHES_PER_ROOM),
269    })
270}
271
272/// A stop predicate that fires once a batch's oldest event is at least
273/// `max_age` old.
274fn stop_when_older_than(
275    max_age: Duration,
276) -> impl FnMut(&BackPaginationOutcome) -> ControlFlow<()> + Send + 'static {
277    move |outcome| {
278        let old_enough =
279            oldest_event_timestamp(outcome).and_then(age_of).is_some_and(|age| age >= max_age);
280
281        if old_enough { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }
282    }
283}
284
285/// The oldest event timestamp in a batch, if any.
286///
287/// The batch is in reverse topological order, so the oldest event comes last.
288fn oldest_event_timestamp(outcome: &BackPaginationOutcome) -> Option<MilliSecondsSinceUnixEpoch> {
289    outcome.events.iter().rev().find_map(|event| event.timestamp())
290}
291
292/// How long ago an event's timestamp was. `None` when it's in the future or out
293/// of range, i.e. clock skew between the sending server and this device; such
294/// an event never satisfies an age-based stop condition.
295fn age_of(ts: MilliSecondsSinceUnixEpoch) -> Option<Duration> {
296    ts.to_system_time()?.elapsed().ok()
297}
298
299#[cfg(all(test, not(target_arch = "wasm32")))]
300mod tests {
301    use std::time::Duration;
302
303    use assert_matches::assert_matches;
304    use eyeball_im::VectorDiff;
305    use matrix_sdk_test::{JoinedRoomBuilder, User, async_test, event_factory::EventFactory};
306    use ruma::{MilliSecondsSinceUnixEpoch, event_id, room_id, time::SystemTime};
307    use tokio::task::yield_now;
308
309    use super::{SearchBackfillStrategy, WEEK, stop_when_older_than};
310    use crate::{
311        assert_let_timeout,
312        event_cache::{BackPaginationOutcome, EventsOrigin, RoomEventCacheUpdate},
313        test_utils::mocks::{MatrixMockServer, RoomMessagesResponseTemplate},
314    };
315
316    /// A timestamp `age` in the past.
317    fn ts_ago(age: Duration) -> MilliSecondsSinceUnixEpoch {
318        MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now() - age).unwrap()
319    }
320
321    /// The age predicate breaks once the oldest event in a batch is at least
322    /// that old.
323    #[test]
324    fn test_stop_when_older_than() {
325        let f = EventFactory::new().room(room_id!("!omelette:fromage.fr")).sender(User::Bob);
326        let outcome = BackPaginationOutcome {
327            reached_start: false,
328            events: vec![
329                f.text_msg("recent").server_ts(ts_ago(Duration::from_secs(60))).into_event(),
330                f.text_msg("older").server_ts(ts_ago(WEEK)).into_event(),
331            ],
332        };
333
334        // Oldest event is a week old, under the two-week bound → keep going.
335        assert!(stop_when_older_than(2 * WEEK)(&outcome).is_continue());
336        // At/over the bound → stop.
337        assert!(stop_when_older_than(WEEK)(&outcome).is_break());
338        assert!(stop_when_older_than(Duration::from_secs(3600))(&outcome).is_break());
339    }
340
341    /// A search backfill sweeps the rooms, back-paginating each until it
342    /// reaches the start of the timeline.
343    #[async_test]
344    async fn test_search_backfill_drains_room() {
345        let server = MatrixMockServer::new().await;
346        let client = server
347            .client_builder()
348            .on_builder(|builder| builder.with_enable_automatic_back_pagination(true))
349            .build()
350            .await;
351
352        let event_cache = client.event_cache().unwrap();
353
354        let room_id = room_id!("!omelette:fromage.fr");
355        let f = EventFactory::new().room(room_id).sender(User::Bob);
356
357        server.sync_joined_room(&client, room_id).await;
358
359        let (room_event_cache, _drop_handles) = event_cache.room(room_id).await.unwrap();
360        let (room_events, mut room_cache_updates) = room_event_cache.subscribe().await.unwrap();
361        assert!(room_events.is_empty());
362
363        server
364            .sync_room(
365                &client,
366                JoinedRoomBuilder::new(room_id)
367                    .set_timeline_limited()
368                    .set_timeline_prev_batch("prev_batch"),
369            )
370            .await;
371
372        assert_let_timeout!(
373            Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) = room_cache_updates.recv()
374        );
375        assert_matches!(update.diffs[0], VectorDiff::Clear);
376
377        // `/messages` returns two events and no end token → start of timeline
378        // reached.
379        server
380            .mock_room_messages()
381            .match_from("prev_batch")
382            .ok(RoomMessagesResponseTemplate::default().events(vec![
383                f.text_msg("comté").event_id(event_id!("$2")),
384                f.text_msg("beaufort").event_id(event_id!("$1")),
385            ]))
386            .mock_once()
387            .mount()
388            .await;
389
390        // The single room drains on the first week and is skipped afterwards,
391        // so only one `/messages` call happens (guaranteed by `mock_once`).
392        event_cache.run_search_backfill(SearchBackfillStrategy::Foreground).await;
393
394        assert_let_timeout!(
395            Ok(RoomEventCacheUpdate::UpdateTimelineEvents(update)) = room_cache_updates.recv()
396        );
397        assert_matches!(update.origin, EventsOrigin::Pagination);
398
399        let mut room_events = room_events.into();
400        for diff in update.diffs {
401            diff.apply(&mut room_events);
402        }
403        assert_eq!(room_events.len(), 2);
404        assert_eq!(room_events[0].event_id().unwrap(), event_id!("$1"));
405        assert_eq!(room_events[1].event_id().unwrap(), event_id!("$2"));
406    }
407
408    /// A search backfill first indexes the events saved in the store, once.
409    #[cfg(feature = "experimental-search")]
410    #[async_test]
411    async fn test_search_backfill_reindexes_stored_events_once() {
412        use futures_util::TryStreamExt as _;
413        use matrix_sdk_base::cross_process_lock::MappedCrossProcessLockState;
414
415        use crate::search_index::SearchIndexStoreKind;
416
417        let index_dir = tempfile::tempdir().unwrap();
418        let server = MatrixMockServer::new().await;
419        let client = server
420            .client_builder()
421            .on_builder(|builder| {
422                builder.with_enable_automatic_back_pagination(true).search_index_store(
423                    SearchIndexStoreKind::UnencryptedDirectory(index_dir.path().to_path_buf()),
424                )
425            })
426            .build()
427            .await;
428
429        let room_id = room_id!("!omelette:fromage.fr");
430        let f = EventFactory::new().room(room_id).sender(User::Bob);
431        server.sync_joined_room(&client, room_id).await;
432
433        // Save events straight into the store: the indexer never sees them.
434        let save = async |body: &str, event_id| {
435            let store = match client.event_cache_store().lock().await.unwrap() {
436                MappedCrossProcessLockState::Clean(store)
437                | MappedCrossProcessLockState::Dirty(store) => store,
438            };
439            store.save_event(room_id, f.text_msg(body).event_id(event_id).into()).await.unwrap();
440        };
441        let search = async |query: &str| -> usize {
442            let results: Vec<_> =
443                client.search_messages(query.to_owned()).build().try_concat().await.unwrap();
444            results.len()
445        };
446
447        save("comté", event_id!("$1")).await;
448        assert_eq!(search("comté").await, 0);
449
450        client.event_cache().unwrap().run_search_backfill(SearchBackfillStrategy::Background).await;
451        assert_eq!(search("comté").await, 1);
452
453        // The reindex ran already: it doesn't run again.
454        save("beaufort", event_id!("$2")).await;
455        client.event_cache().unwrap().run_search_backfill(SearchBackfillStrategy::Background).await;
456        assert_eq!(search("beaufort").await, 0);
457    }
458
459    /// A search backfill doesn't leave the history it loaded in memory: a room
460    /// nobody is subscribed to is shrunk back to its last chunk, so the next
461    /// subscriber (e.g. a timeline) doesn't receive the whole history at once.
462    #[async_test]
463    async fn test_search_backfill_shrinks_rooms_without_subscribers() {
464        let server = MatrixMockServer::new().await;
465        let client = server
466            .client_builder()
467            .on_builder(|builder| builder.with_enable_automatic_back_pagination(true))
468            .build()
469            .await;
470
471        let event_cache = client.event_cache().unwrap();
472
473        let room_id = room_id!("!omelette:fromage.fr");
474        let f = EventFactory::new().room(room_id).sender(User::Bob);
475
476        // Receive a single event as the first chunk
477        server
478            .sync_room(
479                &client,
480                JoinedRoomBuilder::new(room_id)
481                    .add_timeline_event(f.text_msg("emmental").event_id(event_id!("$3")))
482                    .set_timeline_limited()
483                    .set_timeline_prev_batch("prev_batch"),
484            )
485            .await;
486
487        // Back paginate and load a couple more events in a different chunk
488        server
489            .mock_room_messages()
490            .match_from("prev_batch")
491            .ok(RoomMessagesResponseTemplate::default().events(vec![
492                f.text_msg("comté").event_id(event_id!("$2")),
493                f.text_msg("beaufort").event_id(event_id!("$1")),
494            ]))
495            .mock_once()
496            .mount()
497            .await;
498
499        event_cache.run_search_backfill(SearchBackfillStrategy::Background).await;
500
501        // Give some time for the auto-shrinking to run after the back
502        // pagination
503        yield_now().await;
504
505        // Check the cache only contains a single chunk, so it was properly
506        // shrunk
507        let (room_event_cache, _drop_handles) = event_cache.room(room_id).await.unwrap();
508        let (room_events, _room_cache_updates) = room_event_cache.subscribe().await.unwrap();
509        assert_eq!(room_events.len(), 1, "the backfilled history stayed in memory");
510        assert_eq!(room_events[0].event_id().unwrap(), event_id!("$3"));
511    }
512
513    /// A search backfill running while a room is open, and while the user
514    /// back-paginates it, doesn't shrink that room's timeline.
515    #[async_test]
516    async fn test_search_backfill_doesnt_shrink_rooms_with_subscribers() {
517        let server = MatrixMockServer::new().await;
518        let client = server
519            .client_builder()
520            .on_builder(|builder| builder.with_enable_automatic_back_pagination(true))
521            .build()
522            .await;
523
524        let event_cache = client.event_cache().unwrap();
525
526        let room_id = room_id!("!omelette:fromage.fr");
527        let f = EventFactory::new().room(room_id).sender(User::Bob);
528
529        // Back paginating loads a couple more events in a different chunk.
530        // Mounted before the sync, as the sync already starts a read-receipt
531        // back-pagination.
532        server
533            .mock_room_messages()
534            .match_from("prev_batch")
535            .ok(RoomMessagesResponseTemplate::default().events(vec![
536                f.text_msg("comté").event_id(event_id!("$2")),
537                f.text_msg("beaufort").event_id(event_id!("$1")),
538            ]))
539            .mock_once()
540            .mount()
541            .await;
542
543        // Receive a single event as the first chunk
544        server
545            .sync_room(
546                &client,
547                JoinedRoomBuilder::new(room_id)
548                    .add_timeline_event(f.text_msg("emmental").event_id(event_id!("$3")))
549                    .set_timeline_limited()
550                    .set_timeline_prev_batch("prev_batch"),
551            )
552            .await;
553
554        // The user opens the room
555        let (room_event_cache, _drop_handles) = event_cache.room(room_id).await.unwrap();
556        let (room_events, _room_cache_updates) = room_event_cache.subscribe().await.unwrap();
557        assert_eq!(room_events.len(), 1);
558
559        // The user back-paginates while the search backfill runs
560        let pagination = room_event_cache.pagination();
561        let (outcome, ()) = futures_util::future::join(
562            pagination.run_backwards_once(20),
563            event_cache.run_search_backfill(SearchBackfillStrategy::Background),
564        )
565        .await;
566        outcome.unwrap();
567
568        // Give some time for a (wrong) auto-shrinking to run
569        yield_now().await;
570
571        // The open room kept everything it loaded
572        let room_events = room_event_cache.events().await.unwrap();
573        assert_eq!(room_events.len(), 3, "the open room was shrunk");
574    }
575}