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
6fn log_publish_error(error: &GroupError) {
7    if let GroupError::FailedToVerifyInstallations(failed) = error {
8        tracing::error!(
9            error_kind = "FailedToVerifyInstallations",
10            failed_installation_count = failed.0.len(),
11            "Sync: error publishing intents"
12        );
13    } else {
14        tracing::error!("Sync: error publishing intents {error:?}");
15    }
16}
17
18impl<Context> MlsGroup<Context>
19where
20    Context: XmtpSharedContext,
21{
22    #[xmtp_common::mls_span]
23    pub async fn sync(&self) -> Result<SyncSummary, GroupError> {
24        self.sync_once()
25            .await
26            .map_err(|error| self.context.server_configuration().blocked_or(error))
27    }
28
29    /// Sync this group, then every other group of its stitched DM.
30    ///
31    /// The result is this group's own. Siblings sync even when this group
32    /// fails, and a sibling's failure is logged, never returned, so one paused
33    /// or failing group cannot stop the rest of the conversation. A paused
34    /// sibling fails in `handle_group_paused` before it processes anything,
35    /// so it stays held. If the siblings cannot be loaded, the call fails:
36    /// with this group's error when it has one, else with the load error.
37    // implements: DMS-019
38    async fn sync_once(&self) -> Result<SyncSummary, GroupError> {
39        // A client with a blocked connection makes no further calls.
40        self.context.server_configuration().check()?;
41
42        let conn = self.context.db();
43        let result = async {
44            if conn
45                .find_group(&self.group_id)?
46                .is_some_and(|group| group.membership_state == GroupMembershipState::Restored)
47            {
48                return Err(GroupError::GroupInactive);
49            }
50            let epoch = self.epoch().await?;
51            tracing::debug!(
52                inbox_id = self.context.inbox_id(),
53                installation_id = self.context.installation_id().as_slice().snippet(),
54                group_id = self.group_id.short_hex(),
55                epoch,
56                "syncing group",
57            );
58            let sync_summary = self.sync_with_conn().await.map_err(GroupError::from)?;
59            self.maybe_update_installations(None).await?;
60            Ok(sync_summary)
61        }
62        .await;
63
64        let siblings = match conn.other_dms(&self.group_id) {
65            Ok(siblings) => siblings,
66            // The siblings were not synced, so the call does not succeed. The
67            // named group's own failure still wins.
68            Err(error) => {
69                result?;
70                return Err(error.into());
71            }
72        };
73        for sibling in siblings {
74            if sibling.membership_state == GroupMembershipState::Restored {
75                continue;
76            }
77            let sibling = Self::new_from_arc(
78                self.context.clone(),
79                sibling.id,
80                sibling.dm_id,
81                sibling.conversation_type,
82                sibling.created_at_ns,
83            );
84            if let Err(error) = sibling.sync_with_conn().await {
85                tracing::warn!(
86                    group_id = sibling.group_id.short_hex(),
87                    ?error,
88                    "stitched DM sibling sync failed"
89                );
90                continue;
91            }
92            if let Err(error) = sibling.maybe_update_installations(None).await {
93                tracing::warn!(
94                    group_id = sibling.group_id.short_hex(),
95                    ?error,
96                    "stitched DM sibling installation update failed"
97                );
98            }
99        }
100
101        result
102    }
103
104    fn handle_group_paused(&self) -> Result<(), GroupError> {
105        // Check if group is paused and try to unpause if version requirements are met
106        let group_id_typed = self.group_id;
107        if let Some(required_min_version_str) = self
108            .context
109            .db()
110            .get_group_paused_version(&group_id_typed)?
111        {
112            tracing::info!(
113                "Group is paused until version: {}",
114                required_min_version_str
115            );
116            let current_version_str = self.context.version_info().pkg_version();
117            let current_version = self.context.version_info().pkg_semver();
118            let required_min_version = LibXMTPVersion::parse(&required_min_version_str)
119                .map_err(CommitValidationError::from)?;
120
121            if required_min_version <= *current_version {
122                tracing::info!(
123                    "Unpausing group since version requirements are met. \
124                     Group ID: {}",
125                    hex::encode(self.group_id),
126                );
127                self.context.db().unpause_group(&group_id_typed)?;
128            } else {
129                tracing::warn!(
130                    "Skipping sync for paused group since version requirements are not met. \
131                    Group ID: {}, \
132                    Required version: {}, \
133                    Current version: {}",
134                    hex::encode(self.group_id),
135                    required_min_version_str,
136                    current_version_str
137                );
138                // Skip sync for paused groups
139                return Err(GroupError::GroupPausedUntilUpdate(required_min_version_str));
140            }
141        }
142        Ok(())
143    }
144
145    /// Sync from the network with the 'conn' (local database).
146    /// must return a summary of all messages synced, whether they were
147    /// successful or not.
148    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(err, fields(inbox_id = %self.context.inbox_id(), operation = "sync_with_conn")))]
149    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
150    pub async fn sync_with_conn(&self) -> Result<SyncSummary, SyncSummary> {
151        // App-data changes observed while processing are collected here and
152        // dispatched below, *after* the per-group mutex is released. The block
153        // exists to bound the guard's lifetime: a host callback is expected to
154        // react by publishing its merged value, which re-enters
155        // `sync_with_conn` and would deadlock on a guard still held here.
156        let mut app_data_changes = Vec::new();
157        let result = self.sync_with_conn_locked(&mut app_data_changes).await;
158        self.dispatch_app_data_changes(app_data_changes).await;
159        result
160    }
161
162    /// The body of [`Self::sync_with_conn`], holding the per-group mutex for
163    /// its whole duration. Never dispatch host callbacks from in here.
164    async fn sync_with_conn_locked(
165        &self,
166        app_data_changes: &mut Vec<AppDataChange>,
167    ) -> Result<SyncSummary, SyncSummary> {
168        let _mutex = self.mutex.lock().await;
169        let mut summary = SyncSummary::default();
170
171        if self
172            .context
173            .db()
174            .find_group(&self.group_id)
175            .map_err(|error| SyncSummary::other(error.into()))?
176            .is_some_and(|group| group.membership_state == GroupMembershipState::Restored)
177        {
178            return Err(SyncSummary::other(GroupError::GroupInactive));
179        }
180
181        if !self.is_active().map_err(SyncSummary::other)? {
182            log_event!(
183                Event::GroupSyncGroupInactive,
184                self.context.installation_id(),
185                group_id = self.group_id
186            );
187            return Ok(summary);
188        }
189
190        if let Err(e) = self.handle_group_paused() {
191            return Err(SyncSummary::other(e));
192        }
193
194        // Even if publish fails, continue to receiving
195        let result = self.publish_intents().await;
196        if let Err(e) = result {
197            log_publish_error(&e);
198            summary.add_publish_err(e);
199        }
200
201        // Even if receiving fails, we continue to post_commit
202        // Errors are collected in the summary.
203        let result = self.receive().await;
204        match result {
205            Ok(mut s) => {
206                app_data_changes.append(&mut s.app_data_changes);
207                summary.add_process(s)
208            }
209            Err(e) => {
210                summary.add_other(e);
211                // We don't return an error if receive fails, because it's possible this is caused
212                // by malicious data sent over the network, or messages from before the user was
213                // added to the group
214            }
215        }
216
217        let result = self.post_commit().await;
218        if let Err(e) = result {
219            tracing::error!("post commit error {e:?}",);
220            summary.add_post_commit_err(e);
221        }
222
223        if summary.is_errored() {
224            Err(summary)
225        } else {
226            Ok(summary)
227        }
228    }
229
230    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip_all))]
231    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
232    pub(crate) async fn sync_until_last_intent_resolved(&self) -> Result<SyncSummary, GroupError> {
233        // Filter to kinds this build understands: after a downgrade,
234        // rows written by a newer build would otherwise fail `FromSql`
235        // and poison the whole query (see `IntentKind::all`).
236        let intents = self.context.db().find_group_intents(
237            self.group_id,
238            Some(vec![
239                IntentState::ToPublish,
240                IntentState::Published,
241                IntentState::Committed,
242            ]),
243            Some(IntentKind::all().collect()),
244        )?;
245
246        let Some(intent) = intents.last() else {
247            return Ok(Default::default());
248        };
249
250        self.sync_until_intent_resolved(intent.id).await
251    }
252
253    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(err, level = "info", fields(inbox_id = %self.context.inbox_id(), operation = "intent"), skip(self)))]
254    #[cfg_attr(not(any(test, feature = "test-utils")), xmtp_common::mls_span)]
255    /**
256     * Sync the group and wait for the intent to be deleted
257     * Group syncing may involve picking up messages unrelated to the intent, so simply checking for errors
258     * does not give a clear signal as to whether the intent was successfully completed or not.
259     *
260     * Failed or stalled rounds use `xmtp_configuration::MAX_GROUP_SYNC_RETRIES`.
261     * Completing an earlier state change does not consume that retry budget.
262     * The same overall deadline bounds all rounds.
263     */
264
265    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip(self)))]
266    #[cfg_attr(
267        not(any(test, feature = "test-utils")),
268        tracing::instrument(level = "trace", skip(self))
269    )]
270    pub(crate) async fn sync_until_intent_resolved(
271        &self,
272        intent_id: ID,
273    ) -> Result<SyncSummary, GroupError> {
274        log_event!(
275            Event::GroupSyncStart,
276            self.context.installation_id(),
277            group_id = self.group_id
278        );
279
280        // Keep the round future off the containing group sync poll stack.
281        let result = async {
282            let waited = async {
283                let mut summary =
284                    Box::pin(self.sync_until_intent_resolved_inner(intent_id)).await?;
285                // A split target can become a no-op after another member completes
286                // it. Its already-applied children can still owe Welcome delivery.
287                for child in self.pending_membership_children(intent_id)? {
288                    summary.extend(Box::pin(self.sync_until_intent_resolved_inner(child)).await?);
289                }
290                Ok::<_, GroupError>(summary)
291            }
292            .await;
293            // implements: SEND-025
294            // A part can end its batch in a round of a wait above, while the
295            // original target is still publishing, and then no wait reads it.
296            // That outcome wins over any error of another part's wait: the
297            // membership change is applied, but its Welcomes were not sent.
298            if self.membership_child_follow_up_failed(intent_id)? {
299                return Err(super::publish::OutgoingPreparationError::WelcomesUnsendable.into());
300            }
301            waited
302        }
303        .await;
304        let summary = match &result {
305            Ok(summary) => Some(summary),
306            Err(GroupError::Sync(summary)) => Some(&**summary),
307            Err(GroupError::SyncFailedToWait(summary)) => Some(&**summary),
308            _ => None,
309        };
310
311        log_event!(
312            Event::GroupSyncFinished,
313            self.context.installation_id(),
314            group_id = self.group_id,
315            summary = ?summary,
316            success = result.is_ok()
317        );
318
319        result
320    }
321
322    async fn sync_until_intent_resolved_inner(
323        &self,
324        intent_id: ID,
325    ) -> Result<SyncSummary, GroupError> {
326        let mut summary = SyncSummary::default();
327        let db = self.context.db();
328
329        let time_spent = xmtp_common::time::Instant::now();
330        let backoff = ExponentialBackoff::builder()
331            .duration(Duration::from_millis(SYNC_BACKOFF_WAIT_MS.into()))
332            .total_wait_max(Duration::from_secs(SYNC_BACKOFF_TOTAL_WAIT_MAX_SECS.into()))
333            .max_jitter(Duration::from_millis(SYNC_JITTER_MS.into()))
334            .build();
335
336        // Return the last error to the caller if we fail to sync
337        let mut attempt = 0;
338        while attempt < MAX_GROUP_SYNC_RETRIES {
339            let remaining = self
340                .context
341                .incoming_runtime()
342                .policy()
343                .barrier_timeout
344                .saturating_sub(time_spent.elapsed());
345            if remaining.is_zero() {
346                break;
347            }
348            let predecessor = db
349                .find_group_intents(
350                    self.group_id,
351                    Some(vec![
352                        IntentState::ToPublish,
353                        IntentState::Published,
354                        IntentState::Committed,
355                    ]),
356                    Some(IntentKind::all().collect()),
357                )?
358                .into_iter()
359                .find(|intent| intent.id < intent_id && intent.kind != IntentKind::SendMessage)
360                .map(|intent| intent.id);
361            let wait_for = backoff
362                .backoff(attempt + 1, time_spent)
363                .unwrap_or(Duration::from_millis(50));
364            let membership_before = match Fetch::<StoredGroupIntent>::fetch(&db, &intent_id)? {
365                Some(intent)
366                    if intent.kind == IntentKind::UpdateGroupMembership
367                        && intent.state == IntentState::ToPublish =>
368                {
369                    Some((
370                        UpdateGroupMembershipIntentData::try_from(intent.data.as_slice())?,
371                        self.with_group_snapshot(|group| {
372                            extract_group_membership(group.extensions()).map_err(GroupError::from)
373                        })?,
374                    ))
375                }
376                _ => None,
377            };
378
379            log_event!(
380                Event::GroupSyncAttempt,
381                self.context.installation_id(),
382                group_id = self.group_id,
383                attempt,
384                backoff = ?wait_for
385            );
386
387            // Accumulate each attempt's outcome into `summary`. The terminal
388            // GroupSyncFinished event (in sync_until_intent_resolved) is the
389            // single place the summary is logged — no per-attempt logging here.
390            let mut round_succeeded = false;
391            // A rate-limit wait inside the round ends by the round's timeout,
392            // so the delay reaches this loop instead of being lost to it.
393            let rate_limit_delay = match xmtp_common::time::timeout(
394                remaining,
395                xmtp_common::retry::with_retry_deadline(
396                    xmtp_common::time::Instant::now() + remaining,
397                    self.sync_intent_round(intent_id, predecessor, remaining),
398                ),
399            )
400            .await
401            {
402                Ok(Ok(s)) => {
403                    round_succeeded = !s.is_errored();
404                    let delay = s.rate_limit_delay();
405                    summary.extend(s);
406                    delay
407                }
408                Ok(Err(error @ GroupError::PublishedButUnconfirmed { .. })) => return Err(error),
409                Ok(Err(error)) => {
410                    let delay = error.rate_limit_delay();
411                    summary.add_other(error);
412                    delay
413                }
414                Err(_) => break,
415            };
416            let current = Fetch::<StoredGroupIntent>::fetch(&db, &intent_id);
417            let waiting_to_publish = matches!(
418                &current,
419                Ok(Some(intent)) if intent.state == IntentState::ToPublish
420            );
421            match current {
422                Ok(Some(StoredGroupIntent {
423                    state: IntentState::Processed,
424                    ..
425                })) => {
426                    // This is expected, we mark intents as processed on success.
427                    return Ok(summary);
428                }
429                Ok(None) => {
430                    return Err(NotFound::IntentById(intent_id).into());
431                }
432
433                // Terminal: the guard no longer matched, so the intent will
434                // never publish. Returning here rather than looping is what
435                // keeps a superseded write from spinning until the sync
436                // retry budget runs out. `update_app_data` inspects the state
437                // and translates this into `AppDataSuperseded`.
438                Ok(Some(StoredGroupIntent {
439                    state: IntentState::Superseded,
440                    kind,
441                    ..
442                })) => {
443                    log_event!(
444                        Event::GroupSyncIntentErrored,
445                        self.context.installation_id(),
446                        level = warn,
447                        group_id = self.group_id, intent_id = intent_id,
448                        intent_kind = ?kind
449                    );
450                    return Err(GroupError::from(summary));
451                }
452
453                Ok(Some(StoredGroupIntent {
454                    state: IntentState::Error,
455                    kind,
456                    ..
457                })) => {
458                    // The summary itself is logged once by GroupSyncFinished;
459                    // this event only marks which intent errored.
460                    log_event!(
461                        Event::GroupSyncIntentErrored,
462                        self.context.installation_id(),
463                        level = warn,
464                        group_id = self.group_id, intent_id = intent_id,
465                        intent_kind = ?kind
466                    );
467                    summary.extend(self.rejected_intent_summary(intent_id)?);
468                    return Err(GroupError::from(summary));
469                }
470                // implements: SEND-025
471                // The commit is applied, but its Welcomes were not sent, and
472                // no later round can send them.
473                Ok(Some(StoredGroupIntent {
474                    state: IntentState::FollowUpFailed,
475                    kind,
476                    ..
477                })) => {
478                    log_event!(
479                        Event::GroupSyncIntentErrored,
480                        self.context.installation_id(),
481                        level = warn,
482                        group_id = self.group_id, intent_id = intent_id,
483                        intent_kind = ?kind
484                    );
485                    return Err(super::publish::OutgoingPreparationError::WelcomesUnsendable.into());
486                }
487                Ok(Some(StoredGroupIntent { state, kind, .. })) => {
488                    log_event!(
489                        Event::GroupSyncIntentRetry,
490                        self.context.installation_id(),
491                        level = warn, group_id = self.group_id,
492                        intent_id = intent_id, state = ?state, intent_kind = ?kind
493                    );
494                }
495                Err(err) => {
496                    tracing::error!(
497                        group_id = %self.group_id,
498                        intent_id,
499                        attempt,
500                        "database error fetching intent {err:?}"
501                    );
502                    summary.add_other(GroupError::Storage(err));
503                }
504            };
505            // A round can finish a queued state change without publishing the
506            // requested intent. That is progress, not a failed send attempt.
507            if round_succeeded && waiting_to_publish {
508                let predecessor_finished = if let Some(predecessor) = predecessor {
509                    Fetch::<StoredGroupIntent>::fetch(&db, &predecessor)?
510                        .is_some_and(|intent| intent.state == IntentState::Processed)
511                } else {
512                    false
513                };
514                // Split refresh children have later IDs. Count a round as
515                // progress only when it completes a requested inbox change;
516                // unrelated commits and repeated failures consume the budget.
517                let membership_advanced = if let Some((target, before)) = membership_before {
518                    let after = self.with_group_snapshot(|group| {
519                        extract_group_membership(group.extensions()).map_err(GroupError::from)
520                    })?;
521                    target.membership_updates.iter().any(|(inbox, target)| {
522                        before.get(inbox).is_none_or(|old| old < target)
523                            && after.get(inbox).is_some_and(|new| new >= target)
524                    }) || target
525                        .removed_members
526                        .iter()
527                        .any(|inbox| before.get(inbox).is_some() && after.get(inbox).is_none())
528                } else {
529                    false
530                };
531                if predecessor_finished || membership_advanced {
532                    continue;
533                }
534            }
535            attempt += 1;
536            if attempt < MAX_GROUP_SYNC_RETRIES {
537                let remaining = self
538                    .context
539                    .incoming_runtime()
540                    .policy()
541                    .barrier_timeout
542                    .saturating_sub(time_spent.elapsed());
543                // A rate-limited round waits out its delay, or returns the
544                // rejection when the delay does not end within this call.
545                // implements: RATE-041
546                if let Some(delay) = rate_limit_delay {
547                    let Some(wait) = rate_limited_wait(wait_for, delay, remaining) else {
548                        break;
549                    };
550                    xmtp_common::time::sleep(wait).await;
551                    continue;
552                }
553                xmtp_common::time::sleep(wait_for.min(remaining)).await;
554            }
555        }
556        // The wait can end before a round reads the intent's state.
557        match Fetch::<StoredGroupIntent>::fetch(&db, &intent_id)?.map(|intent| intent.state) {
558            Some(IntentState::Processed) => return Ok(summary),
559            // implements: SEND-025
560            Some(IntentState::FollowUpFailed) => {
561                return Err(super::publish::OutgoingPreparationError::WelcomesUnsendable.into());
562            }
563            _ => {}
564        }
565        if self.published_request_target(intent_id)?.is_some() {
566            return Err(GroupError::PublishedButUnconfirmed {
567                intent_id,
568                cause: None,
569                rate_limit_delay: summary.rate_limit_delay(),
570            });
571        }
572        Err(GroupError::SyncFailedToWait(Box::new(summary)))
573    }
574
575    /// Observe the current attempt at its accepted receipt target, not a later topic head.
576    /// Before the attempt has a receipt, observe the receipt of `predecessor`, the state
577    /// change that blocks it. A `QueryNewest` head can trail that receipt (OPS-009).
578    async fn sync_intent_round(
579        &self,
580        intent_id: ID,
581        predecessor: Option<ID>,
582        timeout: Duration,
583    ) -> Result<SyncSummary, GroupError> {
584        use xmtp_proto::types::Topic;
585        let started = xmtp_common::time::Instant::now();
586        let mut summary = SyncSummary::default();
587        match xmtp_common::time::timeout(timeout, self.publish_intents()).await {
588            Ok(Ok(())) => {}
589            Ok(Err(error)) => summary.add_publish_err(error),
590            Err(_) => return Err(GroupError::SyncFailedToWait(Box::new(summary))),
591        }
592        // A later error in this round must not hide a rate-limited publish's
593        // delay, so the round ends with the summary that holds it.
594        // implements: RATE-041
595        let publish_delay = summary.rate_limit_delay();
596        let receipt = self.request_read_target(intent_id)?;
597        let blocker = match receipt {
598            Some(_) => None,
599            None => self.blocking_target(predecessor)?,
600        };
601        let topic = Topic::new_group_message(self.group_id);
602        let targets = match receipt.or(blocker) {
603            Some(target) => [(topic, target)].into(),
604            None => match self.context.api().newest_topic_cursors(vec![topic]).await {
605                Ok(targets) => targets,
606                Err(error) if publish_delay.is_some() => {
607                    summary.add_other(error.into());
608                    return Ok(summary);
609                }
610                Err(error) => return Err(error.into()),
611            },
612        };
613        if let Err(cause) = crate::subscriptions::barrier::wait_through(
614            &self.context,
615            targets,
616            Some(timeout.saturating_sub(started.elapsed())),
617        )
618        .await
619        {
620            if self.published_request_target(intent_id)?.is_some() {
621                return Err(GroupError::PublishedButUnconfirmed {
622                    intent_id,
623                    cause: Some(Box::new(cause)),
624                    rate_limit_delay: publish_delay,
625                });
626            }
627            if publish_delay.is_some() {
628                summary.add_other(cause.into());
629                return Ok(summary);
630            }
631            return Err(cause.into());
632        }
633        if let Err(error) = self.post_commit().await {
634            summary.add_post_commit_err(error);
635        }
636        Ok(summary)
637    }
638
639    /// The receipt of the state change that holds an unpublished intent.
640    /// Nothing is prepared while a state change is published, so a published
641    /// one is the blocker, whatever its id: a commit of the pending proposals
642    /// queued for a deferred message comes after it. Otherwise it is the
643    /// predecessor, which can already be processed.
644    // implements: SEND-022
645    fn blocking_target(&self, predecessor: Option<ID>) -> Result<Option<Cursor>, GroupError> {
646        let published = self
647            .context
648            .db()
649            .find_group_intents(
650                self.group_id,
651                Some(vec![IntentState::Published]),
652                Some(
653                    IntentKind::all()
654                        .filter(|kind| *kind != IntentKind::SendMessage)
655                        .collect(),
656                ),
657            )?
658            .into_iter()
659            .map(|intent| intent.id)
660            .min();
661        match published.or(predecessor) {
662            Some(blocker) => self.published_intent_target(blocker),
663            None => Ok(None),
664        }
665    }
666
667    pub(super) fn validate_message_epoch(
668        inbox_id: InboxIdRef<'_>,
669        intent_id: i32,
670        group_epoch: GroupEpoch,
671        message_epoch: GroupEpoch,
672        max_past_epochs: usize,
673    ) -> Result<(), GroupMessageProcessingError> {
674        #[cfg(any(test, feature = "test-hooks"))]
675        super::test_mocks::maybe_mock_future_epoch_for_tests()?;
676
677        if message_epoch.as_u64() + max_past_epochs as u64 <= group_epoch.as_u64() {
678            tracing::warn!(
679                inbox_id,
680                message_epoch = message_epoch.as_u64(),
681                group_epoch = group_epoch.as_u64(),
682                intent_id,
683                "[{}] message epoch {} is {} or more less than the group epoch {} for intent {}. Retrying message",
684                inbox_id,
685                message_epoch,
686                max_past_epochs,
687                group_epoch.as_u64(),
688                intent_id
689            );
690            return Err(GroupMessageProcessingError::OldEpoch(
691                message_epoch.as_u64(),
692                group_epoch.as_u64(),
693            ));
694        } else if message_epoch.as_u64() > group_epoch.as_u64() {
695            // Should not happen, logging proactively
696            tracing::error!(
697                inbox_id,
698                message_epoch = message_epoch.as_u64(),
699                group_epoch = group_epoch.as_u64(),
700                intent_id,
701                "[{}] message epoch {} is greater than group epoch {} for intent {}. Retrying message",
702                inbox_id,
703                message_epoch,
704                group_epoch,
705                intent_id
706            );
707            return Err(GroupMessageProcessingError::FutureEpoch(
708                message_epoch.as_u64(),
709                group_epoch.as_u64(),
710            ));
711        }
712        Ok(())
713    }
714}
715
716/// The wait before the next round of a rate-limited sync: at least the delay
717/// and the backoff, never past the call's remaining time. None when the delay
718/// does not end inside the call.
719// implements: RATE-041
720fn rate_limited_wait(backoff: Duration, delay: Duration, remaining: Duration) -> Option<Duration> {
721    (delay < remaining).then(|| backoff.max(delay).min(remaining))
722}
723
724#[cfg(test)]
725mod rate_limited_wait_tests {
726    use super::*;
727
728    /// The wait is at least the delay, and never outlasts the call.
729    // verifies: RATE-041
730    #[xmtp_common::test]
731    fn a_rate_limited_wait_stays_inside_the_call() {
732        let ms = Duration::from_millis;
733        assert_eq!(rate_limited_wait(ms(500), ms(100), ms(200)), Some(ms(200)));
734        assert_eq!(rate_limited_wait(ms(50), ms(100), ms(200)), Some(ms(100)));
735        assert_eq!(rate_limited_wait(ms(50), ms(200), ms(200)), None);
736    }
737}