Skip to main content

xmtp_mls/groups/mls_sync/
sync_driver.rs

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