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
186#[cfg(feature = "experimental-search")]
190const REINDEX_MARKER: &str = "reindexed-v1";
191
192impl EventCache {
193 #[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 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
249fn 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
256fn 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
276fn 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
289fn oldest_event_timestamp(outcome: &BackPaginationOutcome) -> Option<MilliSecondsSinceUnixEpoch> {
293 outcome.events.iter().rev().find_map(|event| event.timestamp())
294}
295
296fn 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 fn ts_ago(age: Duration) -> MilliSecondsSinceUnixEpoch {
322 MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now() - age).unwrap()
323 }
324
325 #[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 assert!(stop_when_older_than(2 * WEEK)(&outcome).is_continue());
340 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 #[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 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 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 #[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 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 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 #[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 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 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 yield_now().await;
508
509 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 #[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 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 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 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 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 yield_now().await;
574
575 let room_events = room_event_cache.events().await.unwrap();
577 assert_eq!(room_events.len(), 3, "the open room was shrunk");
578 }
579}