1use 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
36const WEEK: Duration = Duration::from_secs(7 * 24 * 60 * 60);
38
39const MAX_BACKFILL_WEEKS: u32 = 13;
41
42const ROOM_BATCH: usize = 100;
45
46const SEARCH_MAX_BATCHES_PER_ROOM: usize = 10;
53
54#[derive(Clone, Copy, Debug)]
56#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
57pub enum SearchBackfillStrategy {
58 Foreground,
61 Background,
64}
65
66impl SearchBackfillStrategy {
67 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 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 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 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 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 #[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 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
245fn 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
252fn 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
272fn 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
285fn oldest_event_timestamp(outcome: &BackPaginationOutcome) -> Option<MilliSecondsSinceUnixEpoch> {
289 outcome.events.iter().rev().find_map(|event| event.timestamp())
290}
291
292fn 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 fn ts_ago(age: Duration) -> MilliSecondsSinceUnixEpoch {
318 MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now() - age).unwrap()
319 }
320
321 #[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 assert!(stop_when_older_than(2 * WEEK)(&outcome).is_continue());
336 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 #[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 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 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 #[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 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 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 #[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 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 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 yield_now().await;
504
505 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 #[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 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 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 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 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 yield_now().await;
570
571 let room_events = room_event_cache.events().await.unwrap();
573 assert_eq!(room_events.len(), 3, "the open room was shrunk");
574 }
575}