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