Skip to main content

xmtp_mls/subscriptions/
catch_up.rs

1//! Bounded catch-up through the shared receiver and fixed processing barriers.
2
3use 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/// Committed catch-up observations; fixed processing predicates determine completion.
18#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
19pub struct CatchUpSummary {
20    /// Newly deliverable retained rows observed in enrolled groups during the run.
21    pub messages: u64,
22    /// Newly discovered non-virtual conversations within the enrolled scope.
23    pub conversations: u64,
24    /// Enrolled groups with a newly recorded terminal rejection.
25    pub failed: u64,
26    /// True only when every fixed processing obligation completed.
27    pub completed: bool,
28}
29
30#[derive(Debug, thiserror::Error, ErrorCode)]
31pub enum CatchUpError {
32    /// Target discovery or storage failed. May be retryable.
33    #[error(transparent)]
34    #[error_code(inherit)]
35    Group(#[from] GroupError),
36    /// Fixed targets remain unfinished. Partial committed progress is retained. May be retryable.
37    #[error("Catch-up did not complete: {summary:?}")]
38    Incomplete {
39        /// Partial progress remains committed even when the barrier fails.
40        summary: CatchUpSummary,
41        /// Every unfinished fixed-target obligation and its typed cause.
42        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    /// Process fixed starting targets and only the discoveries from the enrolled Welcomes.
63    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        // A client with a blocked connection sends no topic or credential to the backend.
77        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    /// A client that has never streamed or synced catches up: the pending
184    /// welcome joins the group (discovery adds an update on the same
185    /// stream) and the group's history lands in the store.
186    #[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    /// A member with a stale durable cursor gets the missed tail replayed
215    /// and stored — and a second run finds nothing new to do.
216    #[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?; // durable cursor at "now"
226
227        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        // The honesty proof for the counter: the replay of already-stored
247        // history persists nothing, so the summary must say so.
248        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    /// A client with a blocked connection fails catch-up with the cause and sends no request.
262    // verifies: CONF-075
263    #[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    /// Nothing owed at all — a fresh client's run is just the welcome-topic
305    /// update acknowledgement and the empty target set.
306    #[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}