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::{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    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
71/// Process fixed starting targets and only the discoveries from the enrolled Welcomes.
72pub(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    // A client with a blocked connection sends no topic or credential to the backend.
86    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    /// A client that has never streamed or synced catches up: the pending
192    /// welcome joins the group (discovery adds an update on the same
193    /// stream) and the group's history lands in the store.
194    #[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    /// A member with a stale durable cursor gets the missed tail replayed
223    /// and stored — and a second run finds nothing new to do.
224    #[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?; // durable cursor at "now"
234
235        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        // The honesty proof for the counter: the replay of already-stored
255        // history persists nothing, so the summary must say so.
256        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    /// A client with a blocked connection fails catch-up with the cause and sends no request.
270    // verifies: CONF-075
271    #[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    /// Nothing owed at all — a fresh client's run is just the welcome-topic
312    /// update acknowledgement and the empty target set.
313    #[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}