Skip to main content

xmtp_mls/groups/
welcome_sync.rs

1use crate::context::XmtpSharedContext;
2use crate::groups::InitialMembershipValidator;
3#[cfg(test)]
4use crate::groups::ValidateGroupMembership;
5use crate::groups::XmtpWelcome;
6use crate::groups::mls_ext::ResolvedWelcome;
7use crate::groups::{GroupError, MlsGroup};
8use crate::identity_updates::{
9    IdentityDependencyError, IdentityRequirement, InstallationDiffError,
10    resolve_identity_requirement,
11};
12#[cfg(test)]
13use crate::intents::ProcessIntentError;
14#[cfg(test)]
15use crate::mls_store::MlsStore;
16use futures::stream::{self, StreamExt};
17use prost::Message;
18use std::collections::HashSet;
19#[cfg(test)]
20use xmtp_common::Event;
21use xmtp_common::RetryableError;
22use xmtp_db::incoming_envelope::{
23    IncomingRetry, NetworkEntityKind, StoredIncomingEnvelope, StreamTopic,
24};
25#[cfg(test)]
26use xmtp_db::refresh_state::EntityKind;
27use xmtp_db::{
28    consent_record::ConsentState,
29    group::{GroupMembershipState, GroupQueryArgs},
30    prelude::*,
31};
32#[cfg(test)]
33use xmtp_macro::log_event;
34use xmtp_proto::types::Topic;
35use xmtp_proto::types::{Cursor, GroupId};
36
37const WELCOME_POINTER_RETENTION_NS: i64 = 3 * xmtp_common::NS_IN_DAY;
38/// How long a Welcome this build cannot process is retained before it is
39/// completed and removed. A client that supports it within the window still
40/// joins; past the window the row must not keep the Welcome barrier, the
41/// Welcome budget, and key-package retirement blocked forever.
42const UNSUPPORTED_WELCOME_RETENTION_NS: i64 = xmtp_common::NS_IN_DAY;
43
44/// Work needed before one durable Welcome can be attempted again.
45pub(crate) enum WelcomeRequirement {
46    /// The exact inbox and sequence proof required by the staged membership.
47    Identity(IdentityRequirement),
48    /// Pointer data that must be fetched without a database writer.
49    Pointee,
50    /// An active older group must process its ordered prefix before replacement.
51    GroupPrefix {
52        /// Existing group whose active state prevents the join.
53        group_id: GroupId,
54        /// Last group log position already covered by the joined state.
55        anchor: Cursor,
56    },
57}
58
59/// Local attempt result. A waiting Welcome does not stop independent joins.
60pub(crate) enum WelcomeHeadOutcome<C> {
61    /// The pending row was already completed by another attempt.
62    Idle { cursor: Cursor },
63    /// The row remains durable with a retry or blocked diagnostic.
64    Waiting {
65        cursor: Cursor,
66        code: String,
67        /// Blocked rows are retried once per coordinator, not by a timer.
68        blocked: bool,
69    },
70    /// The scheduler must resolve a dependency outside the state writer.
71    Need {
72        cursor: Cursor,
73        requirement: WelcomeRequirement,
74    },
75    /// The join or safe rejection and pending-row completion have committed.
76    Progress {
77        cursor: Cursor,
78        result: Result<Option<MlsGroup<C>>, GroupError>,
79    },
80}
81
82fn unsupported_welcome_wire(wire: &xmtp_proto::backend_v1::ServerEnvelope) -> bool {
83    use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
84    use xmtp_proto::xmtp::mls::message_contents::{
85        WelcomePointerWrapperAlgorithm, WelcomeWrapperAlgorithm,
86    };
87    match wire
88        .envelope
89        .as_ref()
90        .and_then(|envelope| envelope.payload.as_ref())
91    {
92        Some(Payload::WelcomeMessage(welcome)) => match &welcome.version {
93            None => true,
94            Some(Version::V1(message)) => {
95                WelcomeWrapperAlgorithm::try_from(message.wrapper_algorithm).is_err()
96            }
97            Some(Version::WelcomePointer(message)) => {
98                WelcomePointerWrapperAlgorithm::try_from(message.wrapper_algorithm).is_err()
99            }
100        },
101        _ => false,
102    }
103}
104
105#[derive(Debug, Clone)]
106/// Counts returned only after all selected sync phases succeed.
107pub struct GroupSyncSummary {
108    /// Distinct selected groups, including groups found through the fixed Welcome target.
109    pub num_eligible: usize,
110    /// Selected groups that remain active after required outgoing work completes.
111    pub num_synced: usize,
112}
113
114impl GroupSyncSummary {
115    pub fn new(num_eligible: usize, num_synced: usize) -> Self {
116        Self {
117            num_eligible,
118            num_synced,
119        }
120    }
121}
122
123// Outcome of span-instrumented welcome processing: the expected
124// already-processed duplicate is an `Ok` variant so it cannot mark the
125// `mls.process_new_welcome` span as status:error.
126#[cfg(test)]
127enum WelcomeOutcome<Context> {
128    Processed(Option<MlsGroup<Context>>),
129    AlreadyProcessed(Cursor),
130}
131
132#[derive(Clone)]
133/// Welcome processing and group sync through the context's shared incoming coordinator.
134pub struct WelcomeService<Context> {
135    context: Context,
136}
137
138impl<Context> WelcomeService<Context> {
139    pub fn new(context: Context) -> Self {
140        Self { context }
141    }
142}
143
144impl<Context> WelcomeService<Context>
145where
146    Context: XmtpSharedContext,
147{
148    /// Admit a test Welcome, then atomically join or record a safe rejection.
149    // Callers still receive `Err(WelcomeAlreadyProcessed)` for the routine
150    // duplicate-delivery case, but the span lives on the inner fn where that
151    // expected outcome exits as `Ok` — so it never flags span status:error.
152    #[cfg(test)]
153    pub(crate) async fn process_new_welcome(
154        &self,
155        welcome: &xmtp_proto::types::WelcomeMessage,
156        validator: impl ValidateGroupMembership,
157    ) -> Result<Option<MlsGroup<Context>>, GroupError> {
158        match self.process_new_welcome_spanned(welcome, validator).await? {
159            WelcomeOutcome::Processed(group) => Ok(group),
160            WelcomeOutcome::AlreadyProcessed(cursor) => Err(GroupError::ProcessIntent(
161                ProcessIntentError::WelcomeAlreadyProcessed(cursor),
162            )),
163        }
164    }
165
166    #[cfg(test)]
167    #[tracing::instrument(err, skip_all, fields(operation = "mls.process_new_welcome"))]
168    async fn process_new_welcome_spanned(
169        &self,
170        welcome: &xmtp_proto::types::WelcomeMessage,
171        validator: impl ValidateGroupMembership,
172    ) -> Result<WelcomeOutcome<Context>, GroupError> {
173        let pending = self
174            .context
175            .db()
176            .pending_envelope(&self.topic(), welcome.cursor)?
177            .ok_or(ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor))?;
178        let result = XmtpWelcome::builder()
179            .context(self.context.clone())
180            .welcome(welcome)
181            .pending(pending)
182            .validator(validator)
183            .process()
184            .await;
185
186        match result {
187            Ok(mls_group) => {
188                if let Some(mls_group) = &mls_group {
189                    if let (Ok(epoch), Ok(auth)) = (
190                        mls_group.epoch().await,
191                        mls_group.epoch_authenticator().await,
192                    ) {
193                        log_event!(
194                            Event::ReceivedWelcome,
195                            self.context.installation_id(),
196                            group_id = mls_group.group_id.as_slice(),
197                            conversation_type = %mls_group.conversation_type,
198                            epoch,
199                            epoch_auth = auth
200                        );
201                    } else {
202                        tracing::warn!(
203                            "Failed to lock the mls group for logging ProcessedWelcome."
204                        );
205                    }
206                }
207
208                Ok(WelcomeOutcome::Processed(mls_group))
209            }
210            Err(err) => {
211                use crate::DuplicateItem::*;
212                use crate::StorageError::*;
213
214                if matches!(err, GroupError::Storage(Duplicate(WelcomeId(_)))) {
215                    tracing::warn!(
216                        welcome_id = %welcome.cursor,
217                        "Welcome ID already stored: {}",
218                        err
219                    );
220                    return Ok(WelcomeOutcome::AlreadyProcessed(welcome.cursor));
221                } else if let GroupError::ProcessIntent(
222                    ProcessIntentError::WelcomeAlreadyProcessed(cursor),
223                ) = err
224                {
225                    // Expected, non-retryable condition: the welcome was already
226                    // processed (e.g. duplicate delivery for a group we are already
227                    // in). It is handled gracefully upstream (cursor incremented,
228                    // welcome skipped), so log at warn rather than error to avoid
229                    // marking the span as status:error and inflating the error rate.
230                    tracing::warn!(
231                        welcome_id = %welcome.cursor,
232                        "welcome already processed, skipping: {}",
233                        err
234                    );
235                    return Ok(WelcomeOutcome::AlreadyProcessed(cursor));
236                } else {
237                    tracing::error!(
238                        "failed to create group from welcome={} created at {}: {}",
239                        welcome.cursor,
240                        welcome.created_ns.timestamp(),
241                        err
242                    );
243                }
244
245                Err(err)
246            }
247        }
248    }
249
250    fn topic(&self) -> StreamTopic {
251        StreamTopic {
252            entity_id: self.context.installation_id().to_vec(),
253            kind: NetworkEntityKind::Welcome,
254        }
255    }
256
257    /// Attempt a bounded set of independent Welcomes. This method does no network I/O.
258    pub(crate) fn process_pending_welcomes_once(
259        &self,
260    ) -> Result<Vec<WelcomeHeadOutcome<Context>>, GroupError> {
261        let settings = self.context.incoming_runtime().policy();
262        self.context
263            .db()
264            .ready_welcomes_bounded(
265                xmtp_common::time::now_ns(),
266                settings.max_fetched_rows,
267                settings.max_fetched_bytes,
268            )?
269            .into_iter()
270            .filter(|pending| pending.entity_id == self.context.installation_id())
271            .map(|pending| self.attempt_pending_welcome(pending, None, None))
272            .collect()
273    }
274
275    /// Reread one durable row after a dependency completes.
276    /// Only the exact resolver result can mark an identity reference as absent.
277    pub(crate) fn retry_pending_welcome(
278        &self,
279        cursor: Cursor,
280        missing_reference: Option<&IdentityRequirement>,
281    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
282        let Some(pending) = self.context.db().pending_envelope(&self.topic(), cursor)? else {
283            return Ok(WelcomeHeadOutcome::Idle { cursor });
284        };
285        self.attempt_pending_welcome(pending, None, missing_reference)
286    }
287
288    /// Recheck blocked Welcomes once when a coordinator starts.
289    /// Advance `after` to each returned cursor until a batch is empty.
290    /// Complete this scan before processing ready Welcomes in that coordinator.
291    pub(crate) fn retry_blocked_welcomes_after(
292        &self,
293        after: Cursor,
294    ) -> Result<Vec<WelcomeHeadOutcome<Context>>, GroupError> {
295        let settings = self.context.incoming_runtime().policy();
296        self.context
297            .db()
298            .blocked_welcomes_bounded(
299                &self.topic(),
300                after,
301                settings.max_fetched_rows,
302                settings.max_fetched_bytes,
303            )?
304            .into_iter()
305            .map(|pending| self.attempt_pending_welcome(pending, None, None))
306            .collect()
307    }
308
309    fn attempt_pending_welcome(
310        &self,
311        pending: StoredIncomingEnvelope,
312        resolved: Option<&ResolvedWelcome>,
313        missing_reference: Option<&IdentityRequirement>,
314    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
315        let cursor = Cursor(pending.sequence_id as u64);
316        let wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())
317            .map_err(|_| xmtp_db::StorageError::DbDeserialize)?;
318        if unsupported_welcome_wire(&wire) {
319            // Give an offline client one real attempt at its deadline before
320            // the row is removed: the retention window can pass while the
321            // client is not running, and expiry must not skip the attempt.
322            if pending
323                .retry_expires_at_ns
324                .is_some_and(|deadline| deadline <= xmtp_common::time::now_ns())
325            {
326                self.complete_rejected_pending(&pending, "unsupported_welcome_expired")?;
327                return Ok(WelcomeHeadOutcome::Progress {
328                    cursor,
329                    result: Err(GroupError::UnsupportedWelcomeVersion(
330                        "unsupported wrapper algorithm".into(),
331                    )),
332                });
333            }
334            // The first deadline wins; defer_pending never extends it.
335            let deadline =
336                xmtp_common::time::now_ns().saturating_add(UNSUPPORTED_WELCOME_RETENTION_NS);
337            self.defer_pending(&pending, "unsupported_welcome", true, Some(deadline))?;
338            return Ok(WelcomeHeadOutcome::Waiting {
339                cursor,
340                code: "unsupported_welcome".into(),
341                blocked: true,
342            });
343        }
344        let welcome = match xmtp_api_backend::envelope::decode_welcome_message(wire) {
345            Ok(welcome) => welcome,
346            Err(error) => {
347                let error = GroupError::WrappedApi(xmtp_api::ApiError::Envelope(error));
348                self.complete_rejected_pending(&pending, "invalid_welcome_envelope")?;
349                return Ok(WelcomeHeadOutcome::Progress {
350                    cursor,
351                    result: Err(error),
352                });
353            }
354        };
355        // Only a Welcome still waiting on its pointee expires here. A deadline
356        // written while this build could not read the wrapper must not delete a
357        // Welcome that a later build can now process: support is the recovery
358        // this retention window exists to allow.
359        if let Some(deadline) = pending.retry_expires_at_ns
360            && deadline <= xmtp_common::time::now_ns()
361            && pending.error_code.as_deref() != Some("unsupported_welcome")
362        {
363            self.complete_rejected_pending(&pending, "welcome_pointer_expired")?;
364            return Ok(WelcomeHeadOutcome::Progress {
365                cursor,
366                result: Err(GroupError::WelcomeDataNotFound("expired pointer".into())),
367            });
368        }
369        let inline = ResolvedWelcome::inline(&welcome);
370        let Some(resolved) = resolved.or(inline.as_ref()) else {
371            self.defer_pending(
372                &pending,
373                "welcome_pointee",
374                false,
375                Some(
376                    welcome
377                        .timestamp()
378                        .saturating_add(WELCOME_POINTER_RETENTION_NS),
379                ),
380            )?;
381            return Ok(WelcomeHeadOutcome::Need {
382                cursor,
383                requirement: WelcomeRequirement::Pointee,
384            });
385        };
386        let result = XmtpWelcome::builder()
387            .context(self.context.clone())
388            .welcome(&welcome)
389            .pending(pending.clone())
390            .validator(InitialMembershipValidator::new(&self.context))
391            .process_resolved(resolved, missing_reference);
392        self.classify_pending_result(&pending, result)
393    }
394
395    fn classify_pending_result(
396        &self,
397        pending: &StoredIncomingEnvelope,
398        result: Result<Option<MlsGroup<Context>>, GroupError>,
399    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
400        let cursor = Cursor(pending.sequence_id as u64);
401        match result {
402            Ok(group) => Ok(WelcomeHeadOutcome::Progress {
403                cursor,
404                result: Ok(group),
405            }),
406            Err(GroupError::InstallationDiff(InstallationDiffError::IdentityDependency(
407                IdentityDependencyError::Need(requirement),
408            ))) => {
409                self.defer_pending(pending, "identity_dependency", false, None)?;
410                Ok(WelcomeHeadOutcome::Need {
411                    cursor,
412                    requirement: WelcomeRequirement::Identity(requirement),
413                })
414            }
415            Err(GroupError::WelcomeGroupPrefixPending { group_id, anchor }) => {
416                self.defer_pending(pending, "group_prefix", false, None)?;
417                Ok(WelcomeHeadOutcome::Need {
418                    cursor,
419                    requirement: WelcomeRequirement::GroupPrefix {
420                        group_id,
421                        anchor: Cursor(anchor),
422                    },
423                })
424            }
425            Err(error) => {
426                if crate::groups::welcomes::terminal_welcome_error(&error) {
427                    self.complete_rejected_pending(pending, "invalid_welcome")?;
428                }
429                if self
430                    .context
431                    .db()
432                    .pending_envelope(&self.topic(), cursor)?
433                    .is_none()
434                {
435                    return Ok(WelcomeHeadOutcome::Progress {
436                        cursor,
437                        result: Err(error),
438                    });
439                }
440                let blocked = !error.is_retryable()
441                    || matches!(
442                        error,
443                        GroupError::UnsupportedWelcomeVersion(_)
444                            | GroupError::WelcomeError(
445                                openmls::prelude::WelcomeError::UnsupportedMlsVersion
446                                    | openmls::prelude::WelcomeError::UnsupportedExtensions
447                                    | openmls::prelude::WelcomeError::UnsupportedCapability
448                                    | openmls::prelude::WelcomeError::UnsupportedCiphersuite(_)
449                            )
450                    );
451                let code = if blocked {
452                    "welcome_blocked"
453                } else {
454                    "welcome_retry"
455                };
456                self.defer_pending(pending, code, blocked, None)?;
457                tracing::warn!(sequence_id = cursor.0, code, error = %error, "Welcome remains pending");
458                Ok(WelcomeHeadOutcome::Waiting {
459                    cursor,
460                    code: code.into(),
461                    blocked,
462                })
463            }
464        }
465    }
466
467    /// Keep retry state without extending a pointer's original expiry deadline.
468    fn defer_pending(
469        &self,
470        pending: &StoredIncomingEnvelope,
471        code: &'static str,
472        blocked: bool,
473        deadline: Option<i64>,
474    ) -> Result<(), GroupError> {
475        let delay = self
476            .context
477            .incoming_runtime()
478            .policy()
479            .active_database_poll_interval;
480        self.context.db().set_incoming_retry(
481            &self.topic(),
482            Cursor(pending.sequence_id as u64),
483            &IncomingRetry {
484                retry_at_ns: xmtp_common::time::now_ns().saturating_add(delay.as_nanos() as i64),
485                blocked,
486                error_code: Some(code.into()),
487                retry_expires_at_ns: deadline,
488            },
489        )?;
490        #[cfg(any(test, feature = "test-utils"))]
491        crate::diagnostics::record_welcome_retry(code);
492        Ok(())
493    }
494
495    /// Commit rejection progress only if the exact pending bytes still match.
496    fn complete_rejected_pending(
497        &self,
498        pending: &StoredIncomingEnvelope,
499        code: &'static str,
500    ) -> Result<(), GroupError> {
501        crate::state_tx::state_write(self.context.mls_storage(), |tx| {
502            let storage = tx.storage();
503            let db = storage.db();
504            let cursor = Cursor(pending.sequence_id as u64);
505            if db
506                .pending_envelope(&self.topic(), cursor)?
507                .is_some_and(|current| current.envelope == pending.envelope)
508            {
509                db.record_terminal_rejection(&self.topic(), cursor, code)?;
510                db.complete_pending_envelope(&self.topic(), cursor)?;
511            }
512            Ok::<_, GroupError>(xmtp_db::TransactionOutcome::Continue(()))
513        })?;
514        Ok(())
515    }
516
517    /// Resolve one Welcome's dependencies. Group-prefix work stays with the coordinator.
518    pub(crate) async fn resolve_pending_welcome(
519        &self,
520        cursor: Cursor,
521    ) -> Result<WelcomeHeadOutcome<Context>, GroupError> {
522        let Some(pending) = self.context.db().pending_envelope(&self.topic(), cursor)? else {
523            return Ok(WelcomeHeadOutcome::Idle { cursor });
524        };
525        if pending
526            .retry_expires_at_ns
527            .is_some_and(|deadline| deadline <= xmtp_common::time::now_ns())
528        {
529            return self.attempt_pending_welcome(pending, None, None);
530        }
531        let wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())
532            .map_err(|_| xmtp_db::StorageError::DbDeserialize)?;
533        let welcome = xmtp_api_backend::envelope::decode_welcome_message(wire)
534            .map_err(xmtp_api::ApiError::from)?;
535        let resolved = match ResolvedWelcome::resolve(&welcome, &self.context).await {
536            Ok(resolved) => resolved,
537            Err(GroupError::WelcomeDataNotFound(_)) => {
538                self.defer_pending(
539                    &pending,
540                    "welcome_pointee",
541                    false,
542                    Some(
543                        welcome
544                            .timestamp()
545                            .saturating_add(WELCOME_POINTER_RETENTION_NS),
546                    ),
547                )?;
548                return Ok(WelcomeHeadOutcome::Waiting {
549                    cursor,
550                    code: "welcome_pointee".into(),
551                    blocked: false,
552                });
553            }
554            Err(error) => return self.classify_pending_result(&pending, Err(error)),
555        };
556        loop {
557            let outcome = self.attempt_pending_welcome(pending.clone(), Some(&resolved), None)?;
558            let WelcomeHeadOutcome::Need {
559                requirement: WelcomeRequirement::Identity(requirement),
560                ..
561            } = outcome
562            else {
563                return Ok(outcome);
564            };
565            match resolve_identity_requirement(&self.context, &requirement).await {
566                Ok(()) => {}
567                Err(IdentityDependencyError::MissingReference(_)) => {
568                    return self.attempt_pending_welcome(
569                        pending,
570                        Some(&resolved),
571                        Some(&requirement),
572                    );
573                }
574                Err(error) => {
575                    return self.classify_pending_result(
576                        &pending,
577                        Err(InstallationDiffError::from(error).into()),
578                    );
579                }
580            }
581        }
582    }
583
584    /// Process all Welcomes through one fixed replica-visible target.
585    pub async fn sync_welcomes(&self) -> Result<Vec<MlsGroup<Context>>, GroupError> {
586        let args = GroupQueryArgs {
587            include_sync_groups: true,
588            include_duplicate_dms: true,
589            ..Default::default()
590        };
591        let before: HashSet<_> = self
592            .context
593            .db()
594            .fetch_conversation_list(args.clone())?
595            .into_iter()
596            .map(|group| group.id)
597            .collect();
598        crate::subscriptions::barrier::receive_through_current(
599            &self.context,
600            vec![Topic::new_welcome_message(self.context.installation_id())],
601        )
602        .await?;
603        Ok(self
604            .context
605            .db()
606            .fetch_conversation_list(args)?
607            .into_iter()
608            .filter(|group| !before.contains(&group.id))
609            .map(|group| {
610                MlsGroup::new(
611                    self.context.clone(),
612                    group.id,
613                    group.dm_id,
614                    group.conversation_type,
615                    group.created_at_ns,
616                )
617            })
618            .collect())
619    }
620
621    /// Publish, process fixed targets, and finish required work under one deadline.
622    /// Every selected group gets an outcome, even when another group fails.
623    pub async fn sync_all_groups(
624        &self,
625        groups: Vec<MlsGroup<Context>>,
626    ) -> Result<GroupSyncSummary, GroupError> {
627        use crate::subscriptions::incoming::{IncomingCoordinator, IncomingScope};
628        use xmtp_common::time::Instant;
629
630        let deadline = Instant::now() + self.context.incoming_runtime().policy().barrier_timeout;
631        let mut selected = HashSet::new();
632        let selected: Vec<_> = groups
633            .into_iter()
634            .filter(|group| selected.insert(group.group_id))
635            .collect();
636        let db = self.context.db();
637        let num_eligible = selected.len();
638        let mut groups = Vec::new();
639        let mut restored_count = 0;
640        for group in selected {
641            if db
642                .find_group(&group.group_id)?
643                .is_some_and(|stored| stored.membership_state == GroupMembershipState::Restored)
644            {
645                restored_count += 1;
646            } else {
647                groups.push(group);
648            }
649        }
650        if groups.is_empty() && restored_count > 0 {
651            return Err(GroupError::GroupInactive);
652        }
653        let topics = group_sync_topics(&groups);
654        // Receipt can proceed while an independent outgoing request is pending.
655        let _receipt = IncomingCoordinator::for_context(&self.context)
656            .acquire(IncomingScope::Topics(topics.clone()));
657        let concurrency = self
658            .context
659            .incoming_runtime()
660            .policy()
661            .max_dependency_requests;
662        let mut summary = super::summary::SyncSummary::default();
663        if restored_count > 0 {
664            add_group_sync_error(&mut summary, GroupError::GroupInactive);
665        }
666        let publish =
667            run_group_sync_work(&groups, GroupSyncWork::Publish, concurrency, deadline).await;
668        for error in publish.into_iter().filter_map(Result::err) {
669            summary.add_publish_err(error);
670        }
671        let received = crate::subscriptions::barrier::receive_through_current_until(
672            &self.context,
673            topics,
674            deadline,
675        )
676        .await;
677        let unfinished = unfinished_sync_topics(received.as_ref().err());
678        if let Err(error) = received {
679            add_group_sync_error(&mut summary, error.into());
680        }
681        let post_commit = run_group_sync_work(
682            &groups,
683            GroupSyncWork::PostCommit(&unfinished),
684            concurrency,
685            deadline,
686        )
687        .await;
688        finish_group_sync(summary, num_eligible, post_commit)
689    }
690
691    /// Sweep every paused group and clear the pause flag for any
692    /// whose `paused_for_version` is now satisfied by the client's
693    /// `pkg_version`. Pure local-state operation — no network calls.
694    ///
695    /// Returns the count of groups unstuck. Safe to call on any
696    /// installation regardless of whether any groups are paused
697    /// (a no-op on installations with none).
698    ///
699    /// This is the recovery path for the "user upgrades but didn't
700    /// touch a paused group" scenario: without this sweep a paused
701    /// group could stay paused indefinitely after the upgrade, since
702    /// `handle_group_paused` (which is the per-group re-evaluator)
703    /// only fires when the group is actively synced — and
704    /// `sync_all_welcomes_and_groups` filters out groups with no
705    /// new messages on the server.
706    pub async fn unstick_paused_groups(&self) -> Result<usize, GroupError> {
707        use xmtp_mls_common::libxmtp_version::LibXMTPVersion;
708
709        let paused = self.context.db().get_paused_groups_with_versions()?;
710        if paused.is_empty() {
711            return Ok(0);
712        }
713        // The client's own version is parsed once at `VersionInfo`
714        // construction; reuse it across every paused group.
715        let own_version_str = self.context.version_info().pkg_version().to_string();
716        let own_v = self.context.version_info().pkg_semver();
717
718        let mut unstuck = 0usize;
719        for (group_id, required_str) in paused {
720            // Lenient on malformed stored bytes — log and skip rather
721            // than fail the whole sweep (one corrupted row shouldn't
722            // brick recovery for all the others).
723            let Ok(required_v) = LibXMTPVersion::parse(&required_str) else {
724                tracing::warn!(
725                    group_id = hex::encode(group_id.as_ref()),
726                    required = %required_str,
727                    "skipping unparseable paused_for_version while sweeping"
728                );
729                continue;
730            };
731            if required_v <= *own_v {
732                // Same leniency as the parse-error branch above: a
733                // transient DB failure on one row shouldn't abort the
734                // sweep for the others. The next sync sweep will pick
735                // this row up again.
736                if let Err(err) = self.context.db().unpause_group(&group_id) {
737                    tracing::warn!(
738                        group_id = hex::encode(group_id.as_ref()),
739                        required = %required_str,
740                        error = %err,
741                        "failed to unpause group during sweep; will retry on next sync"
742                    );
743                    continue;
744                }
745                tracing::debug!(
746                    group_id = hex::encode(group_id.as_ref()),
747                    required = %required_str,
748                    own = %own_version_str,
749                    "unstuck previously paused group: client version now satisfies floor"
750                );
751                unstuck += 1;
752            }
753        }
754        Ok(unstuck)
755    }
756
757    /// Sync the initial groups and fixed-target Welcome discoveries under one deadline.
758    /// Later local groups and above-target Welcomes do not expand this call's scope.
759    pub async fn sync_all_welcomes_and_groups(
760        &self,
761        consent_states: Option<Vec<ConsentState>>,
762    ) -> Result<GroupSyncSummary, GroupError> {
763        use crate::subscriptions::incoming::{IncomingCoordinator, IncomingScope};
764        use xmtp_common::time::Instant;
765
766        let deadline = Instant::now() + self.context.incoming_runtime().policy().barrier_timeout;
767        // Fix the caller's initial group set before any asynchronous work.
768        let groups: Vec<_> = self
769            .context
770            .db()
771            .fetch_conversation_list(GroupQueryArgs {
772                consent_states: consent_states.clone(),
773                include_duplicate_dms: true,
774                include_sync_groups: true,
775                ..Default::default()
776            })?
777            .into_iter()
778            .filter(|group| group.membership_state != GroupMembershipState::Restored)
779            .map(|group| {
780                MlsGroup::new(
781                    self.context.clone(),
782                    group.id,
783                    group.dm_id,
784                    group.conversation_type,
785                    group.created_at_ns,
786                )
787            })
788            .collect();
789        let initial_ids = groups.iter().map(|group| group.group_id).collect();
790        let mut topics = group_sync_topics(&groups);
791        topics.push(Topic::new_welcome_message(self.context.installation_id()));
792        let _receipt =
793            IncomingCoordinator::for_context(&self.context).acquire(IncomingScope::Topics(topics));
794        let concurrency = self
795            .context
796            .incoming_runtime()
797            .policy()
798            .max_dependency_requests;
799        let mut summary = super::summary::SyncSummary::default();
800        if let Err(error) = self.unstick_paused_groups().await {
801            add_group_sync_error(&mut summary, error);
802        }
803        let publish =
804            run_group_sync_work(&groups, GroupSyncWork::Publish, concurrency, deadline).await;
805        for error in publish.into_iter().filter_map(Result::err) {
806            summary.add_publish_err(error);
807        }
808        let received = crate::subscriptions::barrier::receive_with_welcomes_until(
809            &self.context,
810            initial_ids,
811            consent_states,
812            deadline,
813        )
814        .await;
815        let unfinished = unfinished_sync_topics(received.result.as_ref().err());
816        if let Err(error) = received.result {
817            add_group_sync_error(&mut summary, error.into());
818        }
819        let mut enrolled: std::collections::HashMap<_, _> = groups
820            .into_iter()
821            .map(|group| (group.group_id, group))
822            .collect();
823        let mut enrolled_ids: HashSet<_> = enrolled.keys().copied().collect();
824        for group_id in received.group_ids {
825            if !enrolled_ids.insert(group_id) {
826                continue;
827            }
828            match MlsGroup::new_cached(self.context.clone(), &group_id) {
829                Ok((group, _)) => {
830                    enrolled.insert(group_id, group);
831                }
832                Err(error) => add_group_sync_error(&mut summary, error.into()),
833            }
834        }
835        let num_eligible = enrolled_ids.len();
836        let groups: Vec<_> = enrolled.into_values().collect();
837        let post_commit = run_group_sync_work(
838            &groups,
839            GroupSyncWork::PostCommit(&unfinished),
840            concurrency,
841            deadline,
842        )
843        .await;
844        finish_group_sync(summary, num_eligible, post_commit)
845    }
846}
847
848#[derive(Clone, Copy)]
849enum GroupSyncWork<'a> {
850    Publish,
851    PostCommit(&'a HashSet<Topic>),
852}
853
854fn unfinished_sync_topics(
855    error: Option<&crate::subscriptions::barrier::BarrierError>,
856) -> HashSet<Topic> {
857    use crate::subscriptions::barrier::BarrierError;
858    let Some(BarrierError::Incomplete { unfinished, .. }) = error else {
859        return HashSet::new();
860    };
861    unfinished
862        .iter()
863        .map(|status| status.topic.clone())
864        .collect()
865}
866
867fn group_sync_topics<Context: XmtpSharedContext>(groups: &[MlsGroup<Context>]) -> Vec<Topic> {
868    groups
869        .iter()
870        .map(|group| Topic::new_group_message(group.group_id))
871        .collect()
872}
873
874/// Every group gets an outcome, including work still queued when the deadline expires.
875async fn run_group_sync_work<Context: XmtpSharedContext>(
876    groups: &[MlsGroup<Context>],
877    work: GroupSyncWork<'_>,
878    concurrency: usize,
879    deadline: xmtp_common::time::Instant,
880) -> Vec<Result<bool, GroupError>> {
881    use xmtp_common::time::{Instant, timeout};
882
883    stream::iter(groups.iter().cloned())
884        .map(|group| async move {
885            let remaining = deadline.saturating_duration_since(Instant::now());
886            let timed_out = || GroupError::SyncFailedToWait(Box::default());
887            if remaining.is_zero() {
888                return Err(timed_out());
889            }
890            match timeout(remaining, async {
891                match work {
892                    GroupSyncWork::Publish => {
893                        let active = group.is_active()?;
894                        if active {
895                            group.publish_intents().await?;
896                        }
897                        Ok(active)
898                    }
899                    GroupSyncWork::PostCommit(unfinished) => {
900                        group.post_commit().await?;
901                        if group.is_active()?
902                            && !unfinished.contains(&Topic::new_group_message(group.group_id))
903                        {
904                            // Maintenance adds outgoing work, not a replacement fixed target.
905                            // The outer timeout keeps this work within the same deadline.
906                            group.maybe_update_installations(None).await?;
907                        }
908                        group.is_active()
909                    }
910                }
911            })
912            .await
913            {
914                Ok(result) => result,
915                Err(_) => Err(timed_out()),
916            }
917        })
918        .buffer_unordered(concurrency)
919        .collect()
920        .await
921}
922
923fn add_group_sync_error(summary: &mut super::summary::SyncSummary, error: GroupError) {
924    let error = match summary.other.take() {
925        Some(first) => combine_sync_errors(*first, error),
926        None => error,
927    };
928    summary.add_other(error);
929}
930
931fn finish_group_sync(
932    mut summary: super::summary::SyncSummary,
933    num_eligible: usize,
934    post_commit: Vec<Result<bool, GroupError>>,
935) -> Result<GroupSyncSummary, GroupError> {
936    let mut num_synced = 0;
937    for result in post_commit {
938        match result {
939            Ok(active) => num_synced += usize::from(active),
940            Err(error) => summary.add_post_commit_err(error),
941        }
942    }
943    if summary.is_errored() {
944        Err(summary.into())
945    } else {
946        Ok(GroupSyncSummary::new(num_eligible, num_synced))
947    }
948}
949
950fn combine_sync_errors(first: GroupError, second: GroupError) -> GroupError {
951    use crate::subscriptions::barrier::{BarrierError, BarrierFailure};
952    match (first, second) {
953        (
954            GroupError::StreamBarrier(BarrierError::Incomplete {
955                reason: a,
956                mut unfinished,
957            }),
958            GroupError::StreamBarrier(BarrierError::Incomplete {
959                reason: b,
960                unfinished: next,
961            }),
962        ) => {
963            unfinished.extend(next);
964            let reason = if a == BarrierFailure::Cancelled || b == BarrierFailure::Cancelled {
965                BarrierFailure::Cancelled
966            } else if a == BarrierFailure::Deadline || b == BarrierFailure::Deadline {
967                BarrierFailure::Deadline
968            } else {
969                BarrierFailure::Blocked
970            };
971            BarrierError::Incomplete { reason, unfinished }.into()
972        }
973        (first, second) => {
974            let mut summary = super::summary::SyncSummary::other(first);
975            summary.add_publish_err(second);
976            GroupError::from(summary)
977        }
978    }
979}
980
981#[cfg(test)]
982pub(crate) async fn pending_welcome_for_test(
983    context: &impl XmtpSharedContext,
984    welcome: &xmtp_proto::types::WelcomeMessage,
985) -> Result<StoredIncomingEnvelope, GroupError> {
986    let topic = StreamTopic {
987        entity_id: context.installation_id().to_vec(),
988        kind: NetworkEntityKind::Welcome,
989    };
990    let store = MlsStore::new(context.clone());
991    loop {
992        if let Some(pending) = context.db().pending_envelope(&topic, welcome.cursor)? {
993            return Ok(pending);
994        }
995        let page = store
996            .receive_topics_once(
997                &[Topic::new_welcome_message(context.installation_id())],
998                context
999                    .incoming_runtime()
1000                    .policy()
1001                    .incoming_limits(NetworkEntityKind::Welcome),
1002            )
1003            .await?;
1004        if !page.has_more {
1005            return context
1006                .db()
1007                .pending_envelope(&topic, welcome.cursor)?
1008                .ok_or_else(|| ProcessIntentError::WelcomeAlreadyProcessed(welcome.cursor).into());
1009        }
1010    }
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015    use super::*;
1016    use crate::groups::WelcomeMembership;
1017    use crate::tester;
1018    use crate::utils::test::MlsGroupExt;
1019    use rstest::*;
1020
1021    struct RejectMembership {
1022        retryable: bool,
1023    }
1024
1025    /// An unsupported Welcome is retained for a bounded window, then removed.
1026    /// It gets one real attempt at its deadline so a client that was offline
1027    /// for the window still joins if it gained support meanwhile.
1028    #[xmtp_common::test(unwrap_try = true)]
1029    async fn an_unsupported_welcome_expires_after_a_final_attempt() {
1030        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1031        use xmtp_db::ConnectionExt;
1032        use xmtp_db::schema::incoming_envelopes;
1033        use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
1034
1035        tester!(alix, disable_workers);
1036        tester!(bo, disable_workers);
1037        let group = alix.create_group(None, None)?;
1038        group.invite(&bo).await?;
1039        let welcome = bo
1040            .context
1041            .api()
1042            .query_welcome_messages(bo.context.installation_id())
1043            .await?
1044            .pop()?;
1045        let cursor = welcome.cursor;
1046        pending_welcome_for_test(&bo.context, &welcome).await?;
1047        let service = WelcomeService::new(bo.context.clone());
1048        let db = bo.context.db();
1049
1050        let pending = db.pending_envelope(&service.topic(), cursor)?.unwrap();
1051        let mut wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())?;
1052        let Payload::WelcomeMessage(message) = wire.envelope.as_mut()?.payload.as_mut()? else {
1053            panic!("expected Welcome payload");
1054        };
1055        match message.version.as_mut()? {
1056            Version::V1(message) => message.wrapper_algorithm = i32::MAX,
1057            Version::WelcomePointer(message) => message.wrapper_algorithm = i32::MAX,
1058        }
1059        let row = || {
1060            incoming_envelopes::table.find((
1061                bo.context.installation_id().to_vec(),
1062                EntityKind::Welcome,
1063                cursor.0 as i64,
1064            ))
1065        };
1066        db.raw_query(|conn| {
1067            diesel::update(row())
1068                .set(incoming_envelopes::envelope.eq(wire.encode_to_vec()))
1069                .execute(conn)
1070        })?;
1071
1072        // First pass blocks the row and records a retention deadline.
1073        service.process_pending_welcomes_once()?;
1074        let blocked = db.pending_envelope(&service.topic(), cursor)?.unwrap();
1075        assert!(blocked.blocked);
1076        let deadline = blocked.retry_expires_at_ns?;
1077        assert!(deadline > xmtp_common::time::now_ns());
1078
1079        // Before the deadline the row is retained, not removed.
1080        service.retry_blocked_welcomes_after(Cursor(0))?;
1081        assert!(db.pending_envelope(&service.topic(), cursor)?.is_some());
1082        assert_eq!(
1083            db.pending_envelope(&service.topic(), cursor)?
1084                .unwrap()
1085                .retry_expires_at_ns,
1086            Some(deadline),
1087            "a retry must not extend the first deadline"
1088        );
1089
1090        // At the deadline the final attempt runs and the row is removed, so the
1091        // Welcome barrier, the Welcome budget, and key retirement are released.
1092        db.raw_query(|conn| {
1093            diesel::update(row())
1094                .set(incoming_envelopes::retry_expires_at_ns.eq(Some(1i64)))
1095                .execute(conn)
1096        })?;
1097        service.retry_blocked_welcomes_after(Cursor(0))?;
1098        assert!(db.pending_envelope(&service.topic(), cursor)?.is_none());
1099        assert_eq!(db.topic_progress(&service.topic())?.processed, cursor);
1100        assert!(!db.has_pending_welcomes()?);
1101    }
1102
1103    /// Gaining support is the recovery this retention window exists to allow.
1104    /// A deadline written while the wrapper was unreadable must never delete a
1105    /// Welcome that the client can now process, even past the deadline.
1106    #[xmtp_common::test(unwrap_try = true)]
1107    async fn an_expired_unsupported_welcome_still_installs_once_it_is_supported() {
1108        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1109        use xmtp_db::ConnectionExt;
1110        use xmtp_db::schema::incoming_envelopes;
1111
1112        tester!(alix, disable_workers);
1113        tester!(bo, disable_workers);
1114        let group = alix.create_group(None, None)?;
1115        group.invite(&bo).await?;
1116        let welcome = bo
1117            .context
1118            .api()
1119            .query_welcome_messages(bo.context.installation_id())
1120            .await?
1121            .pop()?;
1122        let cursor = welcome.cursor;
1123        pending_welcome_for_test(&bo.context, &welcome).await?;
1124        let service = WelcomeService::new(bo.context.clone());
1125        let db = bo.context.db();
1126
1127        // The row carries an elapsed unsupported deadline, exactly as a build
1128        // that could not read the wrapper would have written it, but its bytes
1129        // are readable by this build — the post-upgrade state.
1130        db.raw_query(|conn| {
1131            diesel::update(incoming_envelopes::table.find((
1132                bo.context.installation_id().to_vec(),
1133                EntityKind::Welcome,
1134                cursor.0 as i64,
1135            )))
1136            .set((
1137                incoming_envelopes::blocked.eq(true),
1138                incoming_envelopes::error_code.eq(Some("unsupported_welcome")),
1139                incoming_envelopes::retry_expires_at_ns.eq(Some(1i64)),
1140            ))
1141            .execute(conn)
1142        })?;
1143
1144        service.retry_blocked_welcomes_after(Cursor(0))?;
1145
1146        // The row must survive: an elapsed unsupported deadline must not delete
1147        // a Welcome this build can now read. It rejoins ordinary processing,
1148        // which may still need an async dependency before it installs.
1149        let row = db
1150            .pending_envelope(&service.topic(), cursor)?
1151            .expect("a supported Welcome must not be deleted by a stale deadline");
1152        assert!(!row.blocked, "it must rejoin ordinary processing");
1153        assert_ne!(row.error_code.as_deref(), Some("unsupported_welcome"));
1154        assert!(db.topic_progress(&service.topic())?.processed < cursor);
1155    }
1156
1157    #[xmtp_common::test(unwrap_try = true)]
1158    async fn blocked_welcome_does_not_hold_an_independent_join() {
1159        use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl};
1160        use xmtp_db::ConnectionExt;
1161        use xmtp_db::schema::incoming_envelopes;
1162        use xmtp_proto::backend_v1::{client_envelope::Payload, welcome_message::Version};
1163
1164        tester!(alix, disable_workers);
1165        tester!(bo, disable_workers);
1166        let first_group = alix.create_group(None, None)?;
1167        first_group.invite(&bo).await?;
1168        let second_group = alix.create_group(None, None)?;
1169        second_group.invite(&bo).await?;
1170        let welcomes = bo
1171            .context
1172            .api()
1173            .query_welcome_messages(bo.context.installation_id())
1174            .await?;
1175        let first = welcomes.first()?.cursor;
1176        let last = welcomes.last()?.cursor;
1177        pending_welcome_for_test(&bo.context, welcomes.last()?).await?;
1178        let service = WelcomeService::new(bo.context.clone());
1179        let db = bo.context.db();
1180        let pending = db.pending_envelope(&service.topic(), first)?.unwrap();
1181        let mut wire = xmtp_proto::backend_v1::ServerEnvelope::decode(pending.envelope.as_slice())?;
1182        let Payload::WelcomeMessage(welcome) = wire.envelope.as_mut()?.payload.as_mut()? else {
1183            panic!("expected Welcome payload");
1184        };
1185        match welcome.version.as_mut()? {
1186            Version::V1(message) => message.wrapper_algorithm = i32::MAX,
1187            Version::WelcomePointer(message) => message.wrapper_algorithm = i32::MAX,
1188        }
1189        db.raw_query(|conn| {
1190            diesel::update(incoming_envelopes::table.find((
1191                bo.context.installation_id().to_vec(),
1192                EntityKind::Welcome,
1193                first.0 as i64,
1194            )))
1195            .set(incoming_envelopes::envelope.eq(wire.encode_to_vec()))
1196            .execute(conn)
1197        })?;
1198
1199        let outcomes = service.process_pending_welcomes_once()?;
1200        assert!(outcomes.iter().any(|outcome| matches!(outcome,
1201            WelcomeHeadOutcome::Waiting { cursor, blocked: true, .. } if *cursor == first)));
1202        assert!(outcomes.iter().any(|outcome| matches!(outcome,
1203            WelcomeHeadOutcome::Need { cursor, .. } | WelcomeHeadOutcome::Progress { cursor, .. } if *cursor == last)));
1204        if db.find_group(&second_group.group_id)?.is_none() {
1205            assert!(matches!(
1206                service.resolve_pending_welcome(last).await?,
1207                WelcomeHeadOutcome::Progress {
1208                    result: Ok(Some(_)),
1209                    ..
1210                }
1211            ));
1212        }
1213        assert!(db.find_group(&first_group.group_id)?.is_none());
1214        assert!(db.find_group(&second_group.group_id)?.is_some());
1215        let first_pending = db.pending_envelope(&service.topic(), first)?.unwrap();
1216        assert!(first_pending.blocked);
1217        assert_eq!(
1218            first_pending.error_code.as_deref(),
1219            Some("unsupported_welcome")
1220        );
1221        assert!(db.pending_envelope(&service.topic(), last)?.is_none());
1222        let progress = db.topic_progress(&service.topic())?;
1223        assert!(progress.processed < first);
1224        assert_eq!(progress.received, last);
1225    }
1226
1227    impl ValidateGroupMembership for RejectMembership {
1228        async fn check_initial_membership(
1229            &self,
1230            _welcome: &WelcomeMembership,
1231        ) -> Result<(), GroupError> {
1232            if self.retryable {
1233                Err(GroupError::LockUnavailable)
1234            } else {
1235                Err(GroupError::NoPSKSupport)
1236            }
1237        }
1238    }
1239
1240    #[rstest]
1241    #[case::terminal(false)]
1242    #[case::retry(true)]
1243    #[xmtp_common::test(unwrap_try = true)]
1244    async fn welcome_rejection_commits_only_terminal_progress(#[case] retryable: bool) {
1245        tester!(alix, disable_workers);
1246        tester!(bo, disable_workers);
1247        let group = alix.create_group(None, None).unwrap();
1248        group.invite(&bo).await.unwrap();
1249        let welcome = bo
1250            .context
1251            .api()
1252            .query_welcome_messages(bo.context.installation_id())
1253            .await
1254            .unwrap()
1255            .pop()
1256            .unwrap();
1257        pending_welcome_for_test(&bo.context, &welcome)
1258            .await
1259            .unwrap();
1260        let service = WelcomeService::new(bo.context.clone());
1261        let result = service
1262            .process_new_welcome(&welcome, RejectMembership { retryable })
1263            .await;
1264        assert!(result.is_err());
1265        assert!(
1266            bo.context
1267                .db()
1268                .find_group(&group.group_id)
1269                .unwrap()
1270                .is_none()
1271        );
1272        let expected = if !retryable {
1273            welcome.cursor
1274        } else {
1275            Cursor(0)
1276        };
1277        assert_eq!(
1278            bo.context
1279                .db()
1280                .get_last_cursor(bo.context.installation_id(), EntityKind::Welcome)
1281                .unwrap(),
1282            expected
1283        );
1284    }
1285
1286    #[xmtp_common::test(unwrap_try = true)]
1287    async fn later_welcome_completion_keeps_earlier_retry_pending() {
1288        tester!(alix, disable_workers);
1289        tester!(bo, disable_workers);
1290        let first_group = alix.create_group(None, None)?;
1291        first_group.invite(&bo).await?;
1292        let second_group = alix.create_group(None, None)?;
1293        second_group.invite(&bo).await?;
1294        let welcomes = bo
1295            .context
1296            .api()
1297            .query_welcome_messages(bo.context.installation_id())
1298            .await?;
1299        let first = welcomes.first()?.cursor;
1300        let last = welcomes.last()?.cursor;
1301        pending_welcome_for_test(&bo.context, welcomes.last()?).await?;
1302        let service = WelcomeService::new(bo.context.clone());
1303        for welcome in welcomes {
1304            let result = service
1305                .process_new_welcome(
1306                    &welcome,
1307                    RejectMembership {
1308                        retryable: welcome.cursor == first,
1309                    },
1310                )
1311                .await;
1312            assert!(result.is_err());
1313        }
1314        let db = bo.context.db();
1315        assert!(db.pending_envelope(&service.topic(), first)?.is_some());
1316        assert!(db.pending_envelope(&service.topic(), last)?.is_none());
1317        let progress = db.topic_progress(&service.topic())?;
1318        assert!(progress.processed < first);
1319        assert_eq!(progress.received, last);
1320    }
1321}