1use super::barrier::{BarrierError, receive_with_welcomes_until};
4use crate::{Client, context::XmtpSharedContext, groups::GroupError};
5use std::collections::{HashMap, HashSet};
6use xmtp_common::{
7 ErrorCode, RetryableError,
8 time::{Duration, Instant, now_ns},
9};
10use xmtp_db::{
11 delivery::{DeliveryScope, QueryDelivery},
12 group::{ConversationType, GroupQueryArgs},
13 incoming_envelope::{QueryIncomingEnvelope, StreamTopic},
14 prelude::*,
15};
16
17#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub struct CatchUpSummary {
20 pub messages: u64,
22 pub conversations: u64,
24 pub failed: u64,
26 pub completed: bool,
28}
29
30#[derive(Debug, thiserror::Error, ErrorCode)]
31pub enum CatchUpError {
32 #[error(transparent)]
34 #[error_code(inherit)]
35 Group(#[from] GroupError),
36 #[error("Catch-up did not complete: {summary:?}")]
38 Incomplete {
39 summary: CatchUpSummary,
41 causes: Vec<BarrierError>,
43 },
44}
45
46impl RetryableError for CatchUpError {
47 fn is_retryable(&self) -> bool {
48 match self {
49 Self::Group(error) => error.is_retryable(),
50 Self::Incomplete { causes, .. } => causes.iter().any(RetryableError::is_retryable),
51 }
52 }
53}
54
55impl From<crate::client::ClientError> for CatchUpError {
56 fn from(error: crate::client::ClientError) -> Self {
57 Self::Group(error.into())
58 }
59}
60
61impl<C: XmtpSharedContext + 'static> Client<C> {
62 pub async fn catch_up_to_live(
64 &self,
65 timeout: Option<Duration>,
66 ) -> Result<CatchUpSummary, CatchUpError> {
67 self.catch_up_once(timeout)
68 .await
69 .map_err(|error| self.context.server_configuration().blocked_or(error))
70 }
71
72 async fn catch_up_once(
73 &self,
74 timeout: Option<Duration>,
75 ) -> Result<CatchUpSummary, CatchUpError> {
76 self.context
78 .server_configuration()
79 .check()
80 .map_err(GroupError::from)?;
81 let timeout = timeout.unwrap_or(self.context.incoming_runtime().policy().barrier_timeout);
82 let started = Instant::now();
83 let query = || GroupQueryArgs {
84 include_sync_groups: true,
85 include_duplicate_dms: true,
86 ..Default::default()
87 };
88 let db = self.context.db();
89 let mut position = db.current_delivery_cursor().map_err(GroupError::from)?;
90 let before = db.find_groups(query()).map_err(GroupError::from)?;
91 let before_ids: HashSet<_> = before.iter().map(|group| group.id).collect();
92 let previous_rejections: HashMap<_, _> = before
93 .iter()
94 .map(|group| {
95 Ok((
96 group.id,
97 db.read_last_rejection(&StreamTopic::group(group.id))?
98 .map(|entry| entry.sequence_id)
99 .unwrap_or_default(),
100 ))
101 })
102 .collect::<Result<_, xmtp_db::StorageError>>()
103 .map_err(GroupError::from)?;
104 let run = receive_with_welcomes_until(
105 &self.context,
106 before_ids.iter().copied().collect(),
107 None,
108 started + timeout,
109 )
110 .await;
111 let causes: Vec<_> = run.result.err().into_iter().collect();
112 let enrolled: HashSet<_> = run.group_ids.iter().copied().collect();
113 let groups: Vec<_> = db
114 .find_groups(query())
115 .map_err(GroupError::from)?
116 .into_iter()
117 .filter(|group| enrolled.contains(&group.id))
118 .collect();
119 let upper = db.current_delivery_cursor().map_err(GroupError::from)?;
120 let settings = self.context.incoming_runtime().policy();
121 let mut summary = CatchUpSummary {
122 conversations: groups
123 .iter()
124 .filter(|group| {
125 !before_ids.contains(&group.id)
126 && !ConversationType::virtual_types().contains(&group.conversation_type)
127 })
128 .count() as u64,
129 completed: causes.is_empty(),
130 ..Default::default()
131 };
132 for group in &groups {
133 if let Some(rejection) = db
134 .read_last_rejection(&StreamTopic::group(group.id))
135 .map_err(GroupError::from)?
136 && rejection.sequence_id
137 > previous_rejections
138 .get(&group.id)
139 .copied()
140 .unwrap_or_default()
141 {
142 summary.failed += 1;
143 }
144 }
145 loop {
146 let rows = db
147 .replay_delivery_messages_bounded(
148 position,
149 &DeliveryScope::Groups(run.group_ids.clone()),
150 now_ns(),
151 settings.max_local_read_rows,
152 settings.max_local_read_bytes,
153 )
154 .map_err(GroupError::from)?;
155 if rows.is_empty() {
156 break;
157 }
158 let mut reached_upper = false;
159 for row in rows {
160 if row.cursor.delivery_sequence > upper.delivery_sequence {
161 reached_upper = true;
162 break;
163 }
164 position = row.cursor;
165 summary.messages += 1;
166 }
167 if reached_upper || position.delivery_sequence >= upper.delivery_sequence {
168 break;
169 }
170 }
171 if !causes.is_empty() {
172 return Err(CatchUpError::Incomplete { summary, causes });
173 }
174 Ok(summary)
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::CatchUpSummary;
181 use crate::{tester, utils::MlsGroupExt};
182 use xmtp_db::group_message::MsgQueryArgs;
183 #[xmtp_common::test(unwrap_try = true)]
187 async fn catch_up_joins_pending_groups_and_stores_history() {
188 tester!(alix);
189 tester!(bo);
190
191 let group = bo.create_group(None, None)?;
192 group.invite(&alix).await?;
193 group.send_msg(b"while you were out").await;
194 group.send_msg(b"still out").await;
195
196 let summary = alix.catch_up_to_live(None).await?;
197
198 let alix_group = alix.group(&group.group_id)?;
199 let bodies: Vec<Vec<u8>> = alix_group
200 .find_messages(&MsgQueryArgs::default())?
201 .into_iter()
202 .map(|m| m.decrypted_message_bytes)
203 .collect();
204 assert!(bodies.contains(&b"while you were out".to_vec()));
205 assert!(bodies.contains(&b"still out".to_vec()));
206 assert_eq!(summary.conversations, 1, "the joined group must be counted");
207 assert!(
208 summary.messages >= 2,
209 "the stored history must be counted (got {})",
210 summary.messages
211 );
212 }
213
214 #[xmtp_common::test(unwrap_try = true)]
217 async fn catch_up_replays_the_missed_tail_idempotently() {
218 tester!(alix);
219 tester!(bo);
220
221 let group = alix.create_group(None, None)?;
222 group.invite(&bo).await?;
223 bo.sync_welcomes().await?;
224 let bo_group = bo.group(&group.group_id)?;
225 bo_group.sync().await?; group.send_msg(b"missed one").await;
228 group.send_msg(b"missed two").await;
229
230 let first = bo.catch_up_to_live(None).await?;
231 let count_after_first = bo_group.find_messages(&MsgQueryArgs::default())?.len();
232 let bodies: Vec<Vec<u8>> = bo_group
233 .find_messages(&MsgQueryArgs::default())?
234 .into_iter()
235 .map(|m| m.decrypted_message_bytes)
236 .collect();
237 assert!(bodies.contains(&b"missed one".to_vec()));
238 assert!(bodies.contains(&b"missed two".to_vec()));
239 assert!(
240 first.messages >= 2,
241 "the replayed tail must be counted (got {})",
242 first.messages
243 );
244 assert_eq!(first.conversations, 0, "no new group was joined");
245
246 let second = bo.catch_up_to_live(None).await?;
249 let count_after_second = bo_group.find_messages(&MsgQueryArgs::default())?.len();
250 assert_eq!(count_after_first, count_after_second);
251 assert_eq!(
252 second,
253 CatchUpSummary {
254 completed: true,
255 ..Default::default()
256 },
257 "a second run still reaches live, but persists nothing, so its counts are zero"
258 );
259 }
260
261 #[xmtp_common::test(unwrap_try = true)]
264 async fn blocked_connection_fails_catch_up_before_any_request() {
265 use super::CatchUpError;
266 use crate::{
267 client::ClientError, context::XmtpSharedContext, groups::GroupError,
268 server_configuration::BlockedConnection,
269 };
270 use xmtp_proto::api::HasStats;
271 tester!(alix, disable_workers);
272 alix.context.server_configuration().block_connection(
273 BlockedConnection::ClientVersionTooOld {
274 client: "1.0.0".into(),
275 minimum: "9999.0.0".into(),
276 },
277 );
278 let stats = alix.context.api().api_client.mls_stats();
279 stats.clear();
280
281 let error = alix.catch_up_to_live(None).await.unwrap_err();
282
283 assert!(
284 matches!(
285 error,
286 CatchUpError::Group(GroupError::Client(ClientError::ClientVersionTooOld {
287 ref minimum,
288 ..
289 })) if minimum == "9999.0.0"
290 ),
291 "expected the blocked connection cause, got {error:?}"
292 );
293 for (endpoint, count) in [
294 ("publish", stats.publish.get_count()),
295 ("query", stats.query.get_count()),
296 ("query_newest", stats.query_newest.get_count()),
297 ("subscribe", stats.subscribe.get_count()),
298 ("subscribe_static", stats.subscribe_static.get_count()),
299 ] {
300 assert_eq!(count, 0, "a blocked client must not call {endpoint}");
301 }
302 }
303
304 #[xmtp_common::test(unwrap_try = true)]
307 async fn catch_up_with_nothing_owed_completes() {
308 tester!(alix);
309 let summary = alix.catch_up_to_live(None).await?;
310 assert_eq!(
311 summary,
312 CatchUpSummary {
313 completed: true,
314 ..Default::default()
315 },
316 "nothing owed still completes, with zero counts"
317 );
318 }
319}