Skip to main content

xmtp_mls/groups/mls_sync/
sync_driver.rs

1//! Sync entry points and the intent-resolution loop.
2
3use super::*;
4
5impl<Context> MlsGroup<Context>
6where
7    Context: XmtpSharedContext,
8{
9    #[xmtp_common::mls_span]
10    pub async fn sync(&self) -> Result<SyncSummary, GroupError> {
11        // CFG-051 and CFG-061: a latched client makes no further calls.
12        self.context.server_configuration().check()?;
13        let conn = self.context.db();
14
15        let epoch = self.epoch().await?;
16        tracing::debug!(
17            inbox_id = self.context.inbox_id(),
18            installation_id = %self.context.installation_id(),
19            group_id = self.group_id.short_hex(),
20            epoch,
21            "syncing group",
22        );
23
24        // Also sync the "stitched DMs", if any...
25        for other_dm in conn.other_dms(&self.group_id)? {
26            let other_dm = Self::new_from_arc(
27                self.context.clone(),
28                other_dm.id,
29                other_dm.dm_id.clone(),
30                other_dm.conversation_type,
31                other_dm.created_at_ns,
32            );
33
34            other_dm.sync_with_conn().await?;
35            other_dm.maybe_update_installations(None).await?;
36        }
37
38        let sync_summary = self.sync_with_conn().await.map_err(GroupError::from)?;
39        self.maybe_update_installations(None).await?;
40        Ok(sync_summary)
41    }
42
43    fn handle_group_paused(&self) -> Result<(), GroupError> {
44        // Check if group is paused and try to unpause if version requirements are met
45        let group_id_typed = self.group_id;
46        if let Some(required_min_version_str) = self
47            .context
48            .db()
49            .get_group_paused_version(&group_id_typed)?
50        {
51            tracing::info!(
52                "Group is paused until version: {}",
53                required_min_version_str
54            );
55            let current_version_str = self.context.version_info().pkg_version();
56            let current_version = self.context.version_info().pkg_semver();
57            let required_min_version = LibXMTPVersion::parse(&required_min_version_str)?;
58
59            if required_min_version <= *current_version {
60                tracing::info!(
61                    "Unpausing group since version requirements are met. \
62                     Group ID: {}",
63                    hex::encode(self.group_id),
64                );
65                self.context.db().unpause_group(&group_id_typed)?;
66            } else {
67                tracing::warn!(
68                    "Skipping sync for paused group since version requirements are not met. \
69                    Group ID: {}, \
70                    Required version: {}, \
71                    Current version: {}",
72                    hex::encode(self.group_id),
73                    required_min_version_str,
74                    current_version_str
75                );
76                // Skip sync for paused groups
77                return Err(GroupError::GroupPausedUntilUpdate(required_min_version_str));
78            }
79        }
80        Ok(())
81    }
82
83    /// Sync from the network with the 'conn' (local database).
84    /// must return a summary of all messages synced, whether they were
85    /// successful or not.
86    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(err, fields(inbox_id = %self.context.inbox_id(), operation = "sync_with_conn")))]
87    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
88    pub async fn sync_with_conn(&self) -> Result<SyncSummary, SyncSummary> {
89        // App-data changes observed while processing are collected here and
90        // dispatched below, *after* the per-group mutex is released. The block
91        // exists to bound the guard's lifetime: a host callback is expected to
92        // react by publishing its merged value, which re-enters
93        // `sync_with_conn` and would deadlock on a guard still held here.
94        let mut app_data_changes = Vec::new();
95        let result = self.sync_with_conn_locked(&mut app_data_changes).await;
96        self.dispatch_app_data_changes(app_data_changes).await;
97        result
98    }
99
100    /// The body of [`Self::sync_with_conn`], holding the per-group mutex for
101    /// its whole duration. Never dispatch host callbacks from in here.
102    async fn sync_with_conn_locked(
103        &self,
104        app_data_changes: &mut Vec<AppDataChange>,
105    ) -> Result<SyncSummary, SyncSummary> {
106        let _mutex = self.mutex.lock().await;
107        let mut summary = SyncSummary::default();
108
109        if !self.is_active().map_err(SyncSummary::other)? {
110            log_event!(
111                Event::GroupSyncGroupInactive,
112                self.context.installation_id(),
113                group_id = self.group_id
114            );
115            return Ok(summary);
116        }
117
118        if let Err(e) = self.handle_group_paused() {
119            return Err(SyncSummary::other(e));
120        }
121
122        // Even if publish fails, continue to receiving
123        let result = self.publish_intents().await;
124        if let Err(e) = result {
125            tracing::error!("Sync: error publishing intents {e:?}",);
126            summary.add_publish_err(e);
127        }
128
129        // Even if receiving fails, we continue to post_commit
130        // Errors are collected in the summary.
131        let result = self.receive().await;
132        match result {
133            Ok(mut s) => {
134                app_data_changes.append(&mut s.app_data_changes);
135                summary.add_process(s)
136            }
137            Err(e) => {
138                summary.add_other(e);
139                // We don't return an error if receive fails, because it's possible this is caused
140                // by malicious data sent over the network, or messages from before the user was
141                // added to the group
142            }
143        }
144
145        let result = self.post_commit().await;
146        if let Err(e) = result {
147            tracing::error!("post commit error {e:?}",);
148            summary.add_post_commit_err(e);
149        }
150
151        if summary.is_errored() {
152            Err(summary)
153        } else {
154            Ok(summary)
155        }
156    }
157
158    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip_all))]
159    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
160    pub(crate) async fn sync_until_last_intent_resolved(&self) -> Result<SyncSummary, GroupError> {
161        // Filter to kinds this build understands: after a downgrade,
162        // rows written by a newer build would otherwise fail `FromSql`
163        // and poison the whole query (see `IntentKind::all`).
164        let intents = self.context.db().find_group_intents(
165            self.group_id,
166            Some(vec![
167                IntentState::ToPublish,
168                IntentState::Published,
169                IntentState::Committed,
170            ]),
171            Some(IntentKind::all().collect()),
172        )?;
173
174        let Some(intent) = intents.last() else {
175            return Ok(Default::default());
176        };
177
178        self.sync_until_intent_resolved(intent.id).await
179    }
180
181    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(err, level = "info", fields(inbox_id = %self.context.inbox_id(), operation = "intent"), skip(self)))]
182    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
183    /**
184     * Sync the group and wait for the intent to be deleted
185     * Group syncing may involve picking up messages unrelated to the intent, so simply checking for errors
186     * does not give a clear signal as to whether the intent was successfully completed or not.
187     *
188     * Failed or stalled rounds use `xmtp_configuration::MAX_GROUP_SYNC_RETRIES`.
189     * Completing an earlier state change does not consume that retry budget.
190     * The same overall deadline bounds all rounds.
191     */
192
193    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
194    #[cfg_attr(
195        not(any(test, feature = "test-utils")),
196        tracing::instrument(level = "trace", skip(self))
197    )]
198    pub(crate) async fn sync_until_intent_resolved(
199        &self,
200        intent_id: ID,
201    ) -> Result<SyncSummary, GroupError> {
202        log_event!(
203            Event::GroupSyncStart,
204            self.context.installation_id(),
205            group_id = self.group_id
206        );
207
208        let result = self.sync_until_intent_resolved_inner(intent_id).await;
209        let summary = match &result {
210            Ok(summary) => Some(summary),
211            Err(GroupError::Sync(summary)) => Some(&**summary),
212            Err(GroupError::SyncFailedToWait(summary)) => Some(&**summary),
213            _ => None,
214        };
215
216        log_event!(
217            Event::GroupSyncFinished,
218            self.context.installation_id(),
219            group_id = self.group_id,
220            summary = ?summary,
221            success = result.is_ok()
222        );
223
224        result
225    }
226
227    async fn sync_until_intent_resolved_inner(
228        &self,
229        intent_id: ID,
230    ) -> Result<SyncSummary, GroupError> {
231        let mut summary = SyncSummary::default();
232        let db = self.context.db();
233
234        let time_spent = xmtp_common::time::Instant::now();
235        let backoff = ExponentialBackoff::builder()
236            .duration(Duration::from_millis(SYNC_BACKOFF_WAIT_MS.into()))
237            .total_wait_max(Duration::from_secs(SYNC_BACKOFF_TOTAL_WAIT_MAX_SECS.into()))
238            .max_jitter(Duration::from_millis(SYNC_JITTER_MS.into()))
239            .build();
240
241        // Return the last error to the caller if we fail to sync
242        let mut attempt = 0;
243        while attempt < MAX_GROUP_SYNC_RETRIES {
244            let remaining = self
245                .context
246                .incoming_runtime()
247                .policy()
248                .barrier_timeout
249                .saturating_sub(time_spent.elapsed());
250            if remaining.is_zero() {
251                break;
252            }
253            let predecessor = db
254                .find_group_intents(
255                    self.group_id,
256                    Some(vec![
257                        IntentState::ToPublish,
258                        IntentState::Published,
259                        IntentState::Committed,
260                    ]),
261                    Some(IntentKind::all().collect()),
262                )?
263                .into_iter()
264                .find(|intent| intent.id < intent_id && intent.kind != IntentKind::SendMessage)
265                .map(|intent| intent.id);
266            let wait_for = backoff
267                .backoff(attempt + 1, time_spent)
268                .unwrap_or(Duration::from_millis(50));
269
270            log_event!(
271                Event::GroupSyncAttempt,
272                self.context.installation_id(),
273                group_id = self.group_id,
274                attempt,
275                backoff = ?wait_for
276            );
277
278            // Accumulate each attempt's outcome into `summary`. The terminal
279            // GroupSyncFinished event (in sync_until_intent_resolved) is the
280            // single place the summary is logged — no per-attempt logging here.
281            let mut round_succeeded = false;
282            match xmtp_common::time::timeout(
283                remaining,
284                self.sync_intent_round(intent_id, remaining),
285            )
286            .await
287            {
288                Ok(Ok(s)) => {
289                    round_succeeded = !s.is_errored();
290                    summary.extend(s);
291                }
292                Ok(Err(error @ GroupError::PublishedButUnconfirmed { .. })) => return Err(error),
293                Ok(Err(error)) => summary.add_other(error),
294                Err(_) => break,
295            }
296            let current = Fetch::<StoredGroupIntent>::fetch(&db, &intent_id);
297            let waiting_to_publish = matches!(
298                &current,
299                Ok(Some(intent)) if intent.state == IntentState::ToPublish
300            );
301            match current {
302                Ok(Some(StoredGroupIntent {
303                    state: IntentState::Processed,
304                    ..
305                })) => {
306                    // This is expected, we mark intents as processed on success.
307                    return Ok(summary);
308                }
309                Ok(None) => {
310                    return Err(NotFound::IntentById(intent_id).into());
311                }
312
313                // Terminal: the guard no longer matched, so the intent will
314                // never publish. Returning here rather than looping is what
315                // keeps a superseded write from spinning until the sync
316                // retry budget runs out. `update_app_data` inspects the state
317                // and translates this into `AppDataSuperseded`.
318                Ok(Some(StoredGroupIntent {
319                    state: IntentState::Superseded,
320                    kind,
321                    ..
322                })) => {
323                    log_event!(
324                        Event::GroupSyncIntentErrored,
325                        self.context.installation_id(),
326                        level = warn,
327                        group_id = self.group_id, intent_id = intent_id,
328                        intent_kind = ?kind
329                    );
330                    return Err(GroupError::from(summary));
331                }
332
333                Ok(Some(StoredGroupIntent {
334                    state: IntentState::Error,
335                    kind,
336                    ..
337                })) => {
338                    // The summary itself is logged once by GroupSyncFinished;
339                    // this event only marks which intent errored.
340                    log_event!(
341                        Event::GroupSyncIntentErrored,
342                        self.context.installation_id(),
343                        level = warn,
344                        group_id = self.group_id, intent_id = intent_id,
345                        intent_kind = ?kind
346                    );
347                    summary.extend(self.rejected_intent_summary(intent_id)?);
348                    return Err(GroupError::from(summary));
349                }
350                Ok(Some(StoredGroupIntent { state, kind, .. })) => {
351                    log_event!(
352                        Event::GroupSyncIntentRetry,
353                        self.context.installation_id(),
354                        level = warn, group_id = self.group_id,
355                        intent_id = intent_id, state = ?state, intent_kind = ?kind
356                    );
357                }
358                Err(err) => {
359                    tracing::error!(
360                        group_id = %self.group_id,
361                        intent_id,
362                        attempt,
363                        "database error fetching intent {err:?}"
364                    );
365                    summary.add_other(GroupError::Storage(err));
366                }
367            };
368            // A round can finish a queued state change without publishing the
369            // requested intent. That is progress, not a failed send attempt.
370            if round_succeeded
371                && waiting_to_publish
372                && let Some(predecessor) = predecessor
373                && Fetch::<StoredGroupIntent>::fetch(&db, &predecessor)?
374                    .is_some_and(|intent| intent.state == IntentState::Processed)
375            {
376                continue;
377            }
378            attempt += 1;
379            if attempt < MAX_GROUP_SYNC_RETRIES {
380                let remaining = self
381                    .context
382                    .incoming_runtime()
383                    .policy()
384                    .barrier_timeout
385                    .saturating_sub(time_spent.elapsed());
386                xmtp_common::time::sleep(wait_for.min(remaining)).await;
387            }
388        }
389        if Fetch::<StoredGroupIntent>::fetch(&db, &intent_id)?
390            .is_some_and(|intent| intent.state == IntentState::Processed)
391        {
392            return Ok(summary);
393        }
394        if self.published_intent_target(intent_id)?.is_some() {
395            return Err(GroupError::PublishedButUnconfirmed {
396                intent_id,
397                cause: None,
398            });
399        }
400        Err(GroupError::SyncFailedToWait(Box::new(summary)))
401    }
402
403    /// Observe the current attempt at its accepted receipt target, not a later topic head.
404    async fn sync_intent_round(
405        &self,
406        intent_id: ID,
407        timeout: Duration,
408    ) -> Result<SyncSummary, GroupError> {
409        use xmtp_proto::types::Topic;
410        let started = xmtp_common::time::Instant::now();
411        let mut summary = SyncSummary::default();
412        match xmtp_common::time::timeout(timeout, self.publish_intents()).await {
413            Ok(Ok(())) => {}
414            Ok(Err(error)) => summary.add_publish_err(error),
415            Err(_) => return Err(GroupError::SyncFailedToWait(Box::new(summary))),
416        }
417        let receipt = self.published_intent_target(intent_id)?;
418        let topic = Topic::new_group_message(self.group_id);
419        let targets = match receipt {
420            Some(target) => [(topic, target)].into(),
421            None => self.context.api().newest_topic_cursors(vec![topic]).await?,
422        };
423        if let Err(cause) = crate::subscriptions::barrier::wait_through(
424            &self.context,
425            targets,
426            Some(timeout.saturating_sub(started.elapsed())),
427        )
428        .await
429        {
430            return Err(if receipt.is_some() {
431                GroupError::PublishedButUnconfirmed {
432                    intent_id,
433                    cause: Some(Box::new(cause)),
434                }
435            } else {
436                cause.into()
437            });
438        }
439        if let Err(error) = self.post_commit().await {
440            summary.add_post_commit_err(error);
441        }
442        Ok(summary)
443    }
444
445    pub(super) fn validate_message_epoch(
446        inbox_id: InboxIdRef<'_>,
447        intent_id: i32,
448        group_epoch: GroupEpoch,
449        message_epoch: GroupEpoch,
450        max_past_epochs: usize,
451    ) -> Result<(), GroupMessageProcessingError> {
452        #[cfg(any(test, feature = "test-utils"))]
453        crate::utils::test_mocks_helpers::maybe_mock_future_epoch_for_tests()?;
454
455        if message_epoch.as_u64() + max_past_epochs as u64 <= group_epoch.as_u64() {
456            tracing::warn!(
457                inbox_id,
458                message_epoch = message_epoch.as_u64(),
459                group_epoch = group_epoch.as_u64(),
460                intent_id,
461                "[{}] message epoch {} is {} or more less than the group epoch {} for intent {}. Retrying message",
462                inbox_id,
463                message_epoch,
464                max_past_epochs,
465                group_epoch.as_u64(),
466                intent_id
467            );
468            return Err(GroupMessageProcessingError::OldEpoch(
469                message_epoch.as_u64(),
470                group_epoch.as_u64(),
471            ));
472        } else if message_epoch.as_u64() > group_epoch.as_u64() {
473            // Should not happen, logging proactively
474            tracing::error!(
475                inbox_id,
476                message_epoch = message_epoch.as_u64(),
477                group_epoch = group_epoch.as_u64(),
478                intent_id,
479                "[{}] message epoch {} is greater than group epoch {} for intent {}. Retrying message",
480                inbox_id,
481                message_epoch,
482                group_epoch,
483                intent_id
484            );
485            return Err(GroupMessageProcessingError::FutureEpoch(
486                message_epoch.as_u64(),
487                group_epoch.as_u64(),
488            ));
489        }
490        Ok(())
491    }
492}