atagen

matrix-rust-sdk

public
Star0
Watch0

Commit dc40ad30111d

tarball

atagen <boss@atagen.co>2026-10-27 00:36 UTC

3 files changed+149-0
event-cache: probe behind a dropped sync token for events the server skipped

A non-limited sync for a room with retained events drops its prev_batch, so a server that under-reports limited (seen with tuwunel's simplified sliding sync) loses whatever it skipped for good, with no gap to fill. The newest such token is now kept with its batch's first event, and probe_sync_hole fetches one page behind it through the history coordinator. Unknown events go in before that event, joining on any held event, or behind a history gap if the page never reaches one.
Modified file: crates/matrix-sdk/src/event_cache/caches/room/history.rs+61 additions
@@ -99,6 +99,7 @@ enum HistoryOperation {
9999 Recent,
100100 Fill(OwnedTransactionId),
101101 SpendBudget(OwnedTransactionId),
102+ ProbeSyncHole,
102103 Append(Vec<Event>, Option<String>),
103104 Restore,
104105 Barrier,
@@ -110,6 +111,7 @@ impl HistoryOperation {
110111 Self::Recent => "recent",
111112 Self::Fill(_) => "fill",
112113 Self::SpendBudget(_) => "spend budget",
114+ Self::ProbeSyncHole => "probe sync hole",
113115 Self::Append(..) => "append",
114116 Self::Restore => "restore",
115117 Self::Barrier => "barrier",
@@ -121,6 +123,7 @@ impl HistoryOperation {
121123 enum HistoryResponse {
122124 Recent(RecentHistoryOutcome),
123125 Fill(HistoryGapFillOutcome),
126+ Probe(Option<usize>),
124127 Append(Option<HistoryGap>),
125128 Restored,
126129 }
@@ -162,6 +165,9 @@ async fn coordinate_history(
162165 HistoryOperation::SpendBudget(id) => {
163166 cache.spend_history_budget_inner(id).await.map(HistoryResponse::Fill)
164167 }
168+ HistoryOperation::ProbeSyncHole => {
169+ cache.probe_sync_hole_inner().await.map(HistoryResponse::Probe)
170+ }
165171 HistoryOperation::Append(events, cursor) => cache
166172 .append_history_section_inner(events, cursor)
167173 .await
@@ -440,6 +446,61 @@ impl RoomEventCache {
440446 Ok((chunk.events().map(|(_, event)| event.clone()).collect(), gap_positions(chunk)))
441447 }
442448
449+ #[doc = "Looks once behind the newest previous-batch token a non-limited sync handed out. Answers None when there was none, else how many unknown events it inserted."]
450+ pub async fn probe_sync_hole(&self) -> Result<Option<usize>> {
451+ match self.request_history(HistoryOperation::ProbeSyncHole).await? {
452+ HistoryResponse::Probe(outcome) => Ok(outcome),
453+ _ => unreachable!("coordinator response matches request"),
454+ }
455+ }
456+
457+ async fn probe_sync_hole_inner(&self) -> Result<Option<usize>> {
458+ let Some(hole) = self.inner.state.write().await?.take_sync_hole() else {
459+ return Ok(None);
460+ };
461+ let room = self.inner.weak_room.get().ok_or(EventCacheError::ClientDropped)?;
462+ let mut options = MessagesOptions::backward().from(hole.token.as_str());
463+ options.limit = 50u16.into();
464+ let response = room
465+ .messages(options)
466+ .await
467+ .map_err(|error| EventCacheError::PaginationError(std::sync::Arc::new(error)))?;
468+ let mut state = self.inner.state.write().await?;
469+ let mut candidate = state.history_candidate().await?;
470+ let known: HashSet<_> =
471+ candidate.events().filter_map(|(_, event)| event.event_id()).collect();
472+ let mut joined = false;
473+ let mut missing = Vec::new();
474+ for event in response.chunk {
475+ match event.event_id() {
476+ Some(id) if known.contains(&id) => {
477+ joined = true;
478+ break;
479+ }
480+ _ => missing.push(event),
481+ }
482+ }
483+ let count = missing.len();
484+ tracing::debug!(room_id = %self.room_id(), new = count, joined, "history gap probe");
485+ if missing.is_empty() {
486+ return Ok(Some(0));
487+ }
488+ missing.reverse();
489+ let gap = (!joined).then(|| response.end).flatten().map(|token| Gap {
490+ token,
491+ history: Some(HistoryGap {
492+ id: TransactionId::new(),
493+ older_boundary: None,
494+ newer_boundary: missing.iter().find_map(Event::event_id),
495+ visited_cursors: Vec::new(),
496+ status: HistoryGapStatus::Idle,
497+ }),
498+ });
499+ candidate.insert_history_before(Some(&hole.before), missing.clone(), gap);
500+ state.commit_history_candidate(candidate, missing, HistoryCommitScope::Loaded).await?;
501+ Ok(Some(count))
502+ }
503+
443504 #[doc = "Whether any event is retained, in memory or in the store."]
444505 pub async fn has_retained_events(&self) -> Result<bool> {
445506 Ok(self.inner.state.write().await?.newest_retained_event_id().await?.is_some())
Modified file: crates/matrix-sdk/src/event_cache/caches/room/state.rs+23 additions
@@ -151,6 +151,17 @@ pub struct RoomEventCacheState {
151151
152152 /// A copy of the automatic pagination API object.
153153 automatic_pagination: Option<AutomaticPagination>,
154+
155+ /// The newest previous-batch token a non-limited sync handed out and we dropped, with the
156+ /// first event of that batch. A server that under-reports `limited` hides skipped events
157+ /// behind it, so [`super::RoomEventCache::probe_sync_hole`] looks once.
158+ sync_hole: Option<SyncHole>,
159+}
160+
161+#[derive(Clone, Debug)]
162+pub(super) struct SyncHole {
163+ pub token: String,
164+ pub before: OwnedEventId,
154165 }
155166
156167 impl RoomEventCacheState {
@@ -407,6 +418,7 @@ impl LockedRoomEventCacheState {
407418 linked_chunk_update_sender,
408419 room_version_rules,
409420 waited_for_initial_prev_token: false,
421+ sync_hole: None,
410422 subscriber_count: Default::default(),
411423 pinned_event_cache: OnceLock::new(),
412424 automatic_pagination,
@@ -588,6 +600,10 @@ impl<'a> RoomEventCacheStateLockWriteGuard<'a> {
588600 self.state.waited_for_initial_prev_token
589601 }
590602
603+ pub(super) fn take_sync_hole(&mut self) -> Option<SyncHole> {
604+ self.state.sync_hole.take()
605+ }
606+
591607 /// Get a mutable reference to the `waited_for_initial_prev_token` value.
592608 pub fn waited_for_initial_prev_token_mut(&mut self) -> &mut bool {
593609 &mut self.state.waited_for_initial_prev_token
@@ -1025,6 +1041,13 @@ impl<'a> RoomEventCacheStateLockWriteGuard<'a> {
10251041 // knew about. This is slightly incorrect in the presence of
10261042 // network splits, but this has shown to be Good Enough™.
10271043 let newest_retained = self.newest_retained_event_id().await?;
1044+ if !timeline.limited && newest_retained.is_some() && !all_duplicates {
1045+ if let (Some(token), Some(before)) =
1046+ (prev_batch.clone(), events.iter().find_map(Event::event_id))
1047+ {
1048+ self.state.sync_hole = Some(SyncHole { token, before });
1049+ }
1050+ }
10281051 if !timeline.limited && newest_retained.is_some() || all_duplicates {
10291052 prev_batch = None;
10301053 }
Modified file: crates/matrix-sdk/tests/recent_history.rs+65 additions
@@ -708,3 +708,68 @@ async fn test_a_gap_whose_page_is_all_known_does_not_claim_the_start() {
708708 let outcome = cache.pagination().run_backwards_once(20).await.unwrap();
709709 assert!(outcome.reached_start);
710710 }
711+
712+#[async_test]
713+async fn test_a_probe_behind_a_non_limited_sync_brings_back_what_it_skipped() {
714+ let server = MatrixMockServer::new().await;
715+ let client = server.client_builder().build().await;
716+ client.event_cache().subscribe().unwrap();
717+ let room_id = room_id!("!hole:example.org");
718+ let room = server.sync_joined_room(&client, room_id).await;
719+ let (cache, _handles) = room.event_cache().await.unwrap();
720+ let f = event_factory(room_id);
721+ server
722+ .sync_room(
723+ &client,
724+ JoinedRoomBuilder::new(room_id)
725+ .add_timeline_event(f.text_msg("held").event_id(event_id!("$held"))),
726+ )
727+ .await;
728+ assert_eq!(cache.probe_sync_hole().await.unwrap(), None);
729+
730+ // the server skipped $skipped but still said the timeline was not limited
731+ server
732+ .sync_room(
733+ &client,
734+ JoinedRoomBuilder::new(room_id)
735+ .add_timeline_event(f.text_msg("new").event_id(event_id!("$new")))
736+ .set_timeline_prev_batch("before-new"),
737+ )
738+ .await;
739+ server
740+ .mock_room_messages()
741+ .match_from("before-new")
742+ .ok(RoomMessagesResponseTemplate::default().end_token("further").events(vec![
743+ f.text_msg("skipped").event_id(event_id!("$skipped")),
744+ f.text_msg("held").event_id(event_id!("$held")),
745+ ]))
746+ .mock_once()
747+ .mount()
748+ .await;
749+ assert_eq!(cache.probe_sync_hole().await.unwrap(), Some(1));
750+ assert_eq!(cache.probe_sync_hole().await.unwrap(), None);
751+ let ids: Vec<_> =
752+ cache.events().await.unwrap().iter().filter_map(|event| event.event_id()).collect();
753+ assert_eq!(ids, [event_id!("$held"), event_id!("$skipped"), event_id!("$new")]);
754+ assert!(cache.history_gaps().await.unwrap().is_empty());
755+
756+ // an honest server's page starts at what we hold, and nothing changes
757+ server
758+ .sync_room(
759+ &client,
760+ JoinedRoomBuilder::new(room_id)
761+ .add_timeline_event(f.text_msg("newer").event_id(event_id!("$newer")))
762+ .set_timeline_prev_batch("before-newer"),
763+ )
764+ .await;
765+ server
766+ .mock_room_messages()
767+ .match_from("before-newer")
768+ .ok(RoomMessagesResponseTemplate::default()
769+ .end_token("further")
770+ .events(vec![f.text_msg("new").event_id(event_id!("$new"))]))
771+ .mock_once()
772+ .mount()
773+ .await;
774+ assert_eq!(cache.probe_sync_hole().await.unwrap(), Some(0));
775+}