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;
38const UNSUPPORTED_WELCOME_RETENTION_NS: i64 = xmtp_common::NS_IN_DAY;
43
44pub(crate) enum WelcomeRequirement {
46 Identity(IdentityRequirement),
48 Pointee,
50 GroupPrefix {
52 group_id: GroupId,
54 anchor: Cursor,
56 },
57}
58
59pub(crate) enum WelcomeHeadOutcome<C> {
61 Idle { cursor: Cursor },
63 Waiting {
65 cursor: Cursor,
66 code: String,
67 blocked: bool,
69 },
70 Need {
72 cursor: Cursor,
73 requirement: WelcomeRequirement,
74 },
75 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)]
106pub struct GroupSyncSummary {
108 pub num_eligible: usize,
110 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#[cfg(test)]
127enum WelcomeOutcome<Context> {
128 Processed(Option<MlsGroup<Context>>),
129 AlreadyProcessed(Cursor),
130}
131
132#[derive(Clone)]
133pub 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
874async 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 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 #[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 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 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 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 #[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 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 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}