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