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