Skip to main content

xmtp_mls/worker/device_sync/
mod.rs

1use crate::{
2    client::ClientError,
3    context::XmtpSharedContext,
4    groups::{
5        GroupError, MlsGroup, PreconfiguredPolicies, send_message_opts, summary::SyncSummary,
6    },
7    identity::parse_credential,
8    mls_store::{MlsStore, MlsStoreError},
9    subscriptions::{SubscribeError, internal::InternalEvent},
10    worker::{NeedsDbReconnect, metrics::WorkerMetrics},
11};
12use openmls::prelude::{BasicCredential, Member};
13use owo_colors::OwoColorize;
14use prost::Message;
15use std::{collections::HashMap, sync::Arc};
16use thiserror::Error;
17use tokio::sync::broadcast::error::RecvError;
18use tracing::instrument;
19use worker::SyncMetric;
20use xmtp_archive::ArchiveError;
21use xmtp_common::ErrorCode;
22use xmtp_common::{NS_IN_DAY, RetryableError, time::now_ns};
23use xmtp_content_types::encoded_content_to_bytes;
24use xmtp_db::tasks::NewTask;
25use xmtp_db::{NotFound, StorageError, consent_record::ConsentState, group::GroupQueryArgs};
26use xmtp_db::{XmtpDb, group::ConversationType, prelude::*};
27use xmtp_id::{InboxIdRef, associations::DeserializationError};
28use xmtp_mls_common::group::GroupMetadataOptions;
29use xmtp_proto::types::{GroupId, InstallationId};
30use xmtp_proto::xmtp::{
31    device_sync::content::{
32        DeviceSyncContent as DeviceSyncContentProto, device_sync_content::Content as ContentProto,
33    },
34    mls::{
35        database::{
36            AddMissingInstallations as AddMissingInstallationsProto, Task as TaskProto,
37            task::Task as TaskKindProto,
38        },
39        message_contents::{ContentTypeId, EncodedContent},
40    },
41};
42
43pub mod archive;
44pub mod preference_sync;
45pub mod worker;
46
47pub use xmtp_archive::archive_options::{ArchiveOptions, BackupElementSelection};
48
49#[cfg(test)]
50mod tests;
51
52/// Encode sync content as the `EncodedContent` bytes of a sync message.
53fn sync_message_bytes(content: ContentProto) -> Vec<u8> {
54    let content = DeviceSyncContentProto {
55        content: Some(content),
56    };
57    encoded_content_to_bytes(EncodedContent {
58        r#type: Some(ContentTypeId {
59            authority_id: "xmtp.org".to_string(),
60            type_id: "application/x-protobuf".to_string(),
61            version_major: 1,
62            version_minor: 0,
63        }),
64        parameters: HashMap::new(),
65        fallback: None,
66        compression: None,
67        content: content.encode_to_vec(),
68    })
69}
70
71/// A sync group belongs to the own inbox only when that inbox added this
72/// client and every leaf node's credential names it.
73// implements: SYNC-010
74pub(crate) fn is_own_sync_group(
75    own_inbox_id: &str,
76    added_by_inbox_id: &str,
77    members: impl IntoIterator<Item = Member>,
78) -> bool {
79    added_by_inbox_id == own_inbox_id
80        && members.into_iter().all(|member| {
81            BasicCredential::try_from(member.credential)
82                .ok()
83                .and_then(|credential| parse_credential(credential.identity()).ok())
84                .is_some_and(|inbox_id| inbox_id == own_inbox_id)
85        })
86}
87
88#[derive(Debug, Error, ErrorCode)]
89pub enum DeviceSyncError {
90    /// I/O error.
91    ///
92    /// File system or network I/O failed. May be retryable.
93    #[error("IO error: {0}")]
94    IO(#[from] std::io::Error),
95    /// Serialization error.
96    ///
97    /// JSON serialization/deserialization failed. Retryable.
98    #[error("Serialization/Deserialization Error {0}")]
99    Serde(#[from] serde_json::Error),
100    #[error(transparent)]
101    #[error_code(inherit)]
102    ProtoConversion(#[from] xmtp_proto::ConversionError),
103    /// AES-GCM encryption error.
104    ///
105    /// Encryption/decryption of sync payload failed. Retryable.
106    #[error("AES-GCM encryption error")]
107    AesGcm(#[from] aes_gcm::Error),
108    #[error("storage error: {0}")]
109    #[error_code(inherit)]
110    Storage(#[from] StorageError),
111    /// Type conversion error.
112    ///
113    /// Internal type conversion failed. Retryable.
114    #[error("type conversion error")]
115    Conversion,
116    /// UTF-8 error.
117    ///
118    /// String is not valid UTF-8. Retryable.
119    #[error("utf-8 error: {0}")]
120    UTF8(#[from] std::str::Utf8Error),
121    #[error("client error: {0}")]
122    #[error_code(inherit)]
123    Client(#[from] ClientError),
124    #[error("group error: {0}")]
125    #[error_code(inherit)]
126    Group(#[from] GroupError),
127    /// Invalid payload.
128    ///
129    /// Sync message payload is malformed. Retryable.
130    #[error("invalid history message payload")]
131    InvalidPayload,
132    /// Unspecified sync kind.
133    ///
134    /// Device sync kind not specified. Not retryable.
135    #[error("unspecified device sync kind")]
136    UnspecifiedDeviceSyncKind,
137    #[error(transparent)]
138    #[error_code(inherit)]
139    Subscribe(#[from] SubscribeError),
140    /// Bincode error.
141    ///
142    /// Binary serialization failed. Retryable.
143    #[error(transparent)]
144    Bincode(#[from] bincode::Error),
145    /// Archive error.
146    ///
147    /// Sync archive operation failed. Retryable.
148    #[error(transparent)]
149    Archive(#[from] ArchiveError),
150    /// Decode error.
151    ///
152    /// Protobuf decoding failed. Retryable.
153    #[error(transparent)]
154    Decode(#[from] prost::DecodeError),
155    #[error(transparent)]
156    #[error_code(inherit)]
157    Deserialization(#[from] DeserializationError),
158    /// Missing sync group.
159    ///
160    /// Sync group not found. Not retryable.
161    #[error("Missing sync group")]
162    MissingSyncGroup,
163    #[error(transparent)]
164    #[error_code(inherit)]
165    Db(#[from] xmtp_db::ConnectionError),
166    /// Sync summary.
167    ///
168    /// Sync completed with errors. May be retryable.
169    #[error("{}", _0.to_string())]
170    Sync(#[source] Box<SyncSummary>),
171    /// MLS store error.
172    ///
173    /// OpenMLS key store operation failed. Retryable.
174    #[error(transparent)]
175    MlsStore(#[from] MlsStoreError),
176    /// Receive error.
177    ///
178    /// Channel receive failed. Retryable.
179    #[error(transparent)]
180    Recv(#[from] RecvError),
181    /// Missing field.
182    ///
183    /// Required field not present. Retryable.
184    #[error("Missing Field: {0:?} {1}")]
185    MissingField(MissingField, String),
186}
187
188#[derive(Debug)]
189pub enum MissingField {
190    Conversation(ConversationField),
191}
192#[derive(Debug)]
193pub enum ConversationField {
194    DmId,
195}
196
197impl From<SyncSummary> for DeviceSyncError {
198    fn from(value: SyncSummary) -> Self {
199        DeviceSyncError::Sync(Box::new(value))
200    }
201}
202
203impl NeedsDbReconnect for DeviceSyncError {
204    fn needs_db_reconnect(&self) -> bool {
205        match self {
206            Self::Client(s) => s.db_needs_connection(),
207            Self::Storage(s) => s.db_needs_connection(),
208            // A dropped pool can hide in these wrapped errors; forward so the
209            // worker stops instead of hot-looping (was `_ => false`).
210            Self::Db(c) => c.db_needs_connection(),
211            Self::Group(e) => e.needs_db_reconnect(),
212            Self::MlsStore(e) => e.needs_db_reconnect(),
213            Self::Subscribe(e) => e.needs_db_reconnect(),
214            _ => false,
215        }
216    }
217}
218
219impl RetryableError for DeviceSyncError {
220    fn is_retryable(&self) -> bool {
221        !matches!(
222            self,
223            Self::MissingSyncGroup | Self::UnspecifiedDeviceSyncKind
224        )
225    }
226}
227
228impl From<NotFound> for DeviceSyncError {
229    fn from(value: NotFound) -> Self {
230        DeviceSyncError::Storage(StorageError::NotFound(value))
231    }
232}
233
234#[derive(Clone)]
235pub struct DeviceSyncClient<Context> {
236    pub(crate) context: Context,
237    pub(crate) mls_store: MlsStore<Context>,
238    pub(crate) metrics: Arc<WorkerMetrics<SyncMetric>>,
239}
240
241impl<Context: XmtpSharedContext> DeviceSyncClient<Context> {
242    pub fn new(context: Context, metrics: Arc<WorkerMetrics<SyncMetric>>) -> Self {
243        Self {
244            context: context.clone(),
245            mls_store: MlsStore::new(context),
246            metrics,
247        }
248    }
249}
250
251impl<Context> DeviceSyncClient<Context>
252where
253    Context: XmtpSharedContext,
254{
255    pub fn inbox_id(&self) -> InboxIdRef<'_> {
256        self.context.identity().inbox_id()
257    }
258
259    pub fn installation_id(&self) -> InstallationId {
260        self.context.installation_id()
261    }
262
263    pub fn db(&self) -> <Context::Db as XmtpDb>::DbQuery {
264        self.context.db()
265    }
266
267    /// Blocks until the sync worker notifies that it is initialized and running.
268    pub async fn wait_for_sync_worker_init(&self) -> Result<(), xmtp_common::time::Expired> {
269        self.metrics.wait_for_init().await
270    }
271
272    /// Sends a device sync message.
273    /// If the `group_id` is `None`, the message will be sent
274    /// to the primary sync group ID.
275    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = self.context.inbox_id()), skip(self, content)))]
276    #[cfg_attr(
277        not(any(test, feature = "test-utils")),
278        tracing::instrument(level = "trace", skip(self, content))
279    )]
280    // implements: SYNC-004
281    async fn send_device_sync_message(
282        &self,
283        content: ContentProto,
284    ) -> Result<Vec<u8>, ClientError> {
285        let sync_group = self.get_sync_group().await?;
286
287        let msg = format!(
288            "Sending sync message to group {:?}",
289            xmtp_common::fmt::debug_hex(sync_group.group_id)
290        );
291        tracing::info!("{}", msg.yellow());
292
293        let content_bytes = sync_message_bytes(content);
294
295        let message_id = sync_group.prepare_message(
296            &content_bytes,
297            send_message_opts::SendMessageOpts {
298                should_push: false,
299                idempotency_key: None,
300            },
301        )?;
302
303        sync_group.sync_until_last_intent_resolved().await?;
304
305        xmtp_events::EventWriter::emit(
306            self.context.events(),
307            None,
308            Some(InternalEvent::SyncMessagePublished),
309        );
310
311        Ok(message_id)
312    }
313
314    /// The newest sync group that only the own inbox is in.
315    /// Older builds stored sync groups from other inboxes; this skips them.
316    // implements: SYNC-002, SYNC-010
317    pub(crate) fn primary_sync_group(&self) -> Result<Option<MlsGroup<Context>>, GroupError> {
318        let own_inbox_id = self.context.inbox_id();
319        for stored in self.context.db().all_sync_groups()? {
320            let group = self.mls_store.group(&stored.id)?;
321            let own = group.with_group_snapshot(|mls_group| {
322                Ok(is_own_sync_group(
323                    own_inbox_id,
324                    &stored.added_by_inbox_id,
325                    mls_group.members(),
326                ))
327            })?;
328            if own {
329                return Ok(Some(group));
330            }
331            tracing::debug!(
332                group_id = %stored.id,
333                "skipping a sync group that names another inbox"
334            );
335        }
336        Ok(None)
337    }
338
339    // implements: SYNC-001
340    #[instrument(level = "trace", skip_all)]
341    pub async fn get_sync_group(&self) -> Result<MlsGroup<Context>, GroupError> {
342        let sync_group = match self.primary_sync_group()? {
343            Some(sync_group) => sync_group,
344            None => {
345                let sync_group = MlsGroup::create_and_insert(
346                    self.context.clone(),
347                    ConversationType::Sync,
348                    PreconfiguredPolicies::default().to_policy_set(),
349                    GroupMetadataOptions::default(),
350                    None,
351                )?;
352                tracing::info!(
353                    "Creating sync group: {}",
354                    xmtp_common::fmt::debug_hex(sync_group.group_id)
355                );
356                if let Err(inline_err) = sync_group.add_missing_installations().await {
357                    // The group row is already persisted, so this add is never
358                    // re-attempted (later calls take the `Some` branch) — arm
359                    // the durable reconcile task so the TaskRunner heals it,
360                    // then surface the original error. Armed only on failure:
361                    // enqueue-first duplicated the reconcile (and its identity
362                    // fetch) on every sync-group creation.
363                    self.schedule_add_missing_installations_task(sync_group.group_id)
364                        .map_err(Box::new)?;
365                    return Err(inline_err);
366                }
367                sync_group.sync_with_conn().await?;
368
369                self.metrics.increment_metric(SyncMetric::SyncGroupCreated);
370
371                sync_group
372            }
373        };
374
375        Ok(sync_group)
376    }
377
378    /// This should be triggered when a new sync group appears,
379    /// indicating the presence of a new installation.
380    ///
381    /// Schedules one durable AddMissingInstallations task per eligible group
382    /// on the TaskRunner (deduped by payload hash) so the membership add is
383    /// retried with backoff instead of being lost if a single inline attempt
384    /// fails (e.g. identity propagation lag → MissingSequenceId).
385    #[cfg_attr(
386        any(test, feature = "test-utils"),
387        tracing::instrument(level = "info", skip_all)
388    )]
389    // implements: SYNC-014
390    pub fn schedule_add_installations_to_groups(&self) -> Result<usize, DeviceSyncError> {
391        let groups = self.mls_store.find_groups(GroupQueryArgs {
392            last_activity_after_ns: Some(now_ns() - NS_IN_DAY * 90),
393            consent_states: Some(vec![ConsentState::Allowed, ConsentState::Unknown]),
394            ..Default::default()
395        })?;
396
397        for group in &groups {
398            self.schedule_add_missing_installations_task(group.group_id)?;
399        }
400        Ok(groups.len())
401    }
402
403    /// Durably enqueue one AddMissingInstallations task for `group_id`
404    /// (deduped by payload hash against any pending row for the same group)
405    /// and wake the TaskRunner.
406    pub(crate) fn schedule_add_missing_installations_task(
407        &self,
408        group_id: GroupId,
409    ) -> Result<(), DeviceSyncError> {
410        let task = NewTask::builder()
411            .originating_message_sequence_id(0)
412            .build(TaskProto {
413                task: Some(TaskKindProto::AddMissingInstallations(
414                    AddMissingInstallationsProto {
415                        group_id: group_id.to_vec(),
416                    },
417                )),
418            })?;
419        self.context.db().create_or_ignore_task(task)?;
420        xmtp_events::EventWriter::emit(
421            self.context.events(),
422            None,
423            Some(InternalEvent::TaskScheduled),
424        );
425        Ok(())
426    }
427}
428
429/// Decode only device-sync content that this client supports.
430///
431/// Old archive transfer fields decode as an unset oneof after their field
432/// numbers became reserved. Ignore them so older installations do not make
433/// the sync worker fail or retry the message.
434fn decode_supported_content(bytes: &[u8]) -> Option<ContentProto> {
435    DeviceSyncContentProto::decode(bytes).ok()?.content
436}