Skip to main content

xmtp_mls/worker/device_sync/
worker.rs

1use super::{
2    DeviceSyncClient, DeviceSyncError, decode_supported_content,
3    preference_sync::{PreferenceUpdate, store_preference_updates},
4};
5use crate::{
6    context::XmtpSharedContext,
7    subscriptions::{
8        LocalEvents, SyncWorkerEvent,
9        incoming::{IncomingCoordinator, IncomingScope},
10    },
11    worker::{
12        BoxedWorker, DynMetrics, MetricsCasting, NeedsDbReconnect, Worker, WorkerFactory,
13        WorkerKind, WorkerResult, metrics::WorkerMetrics,
14    },
15};
16use futures::TryFutureExt;
17use prost::Message;
18use std::{sync::Arc, time::Duration};
19use tokio::sync::{OnceCell, broadcast};
20use tracing::instrument;
21use xmtp_common::Event;
22use xmtp_db::group_message::StoredGroupMessage;
23use xmtp_db::prelude::*;
24use xmtp_macro::log_event;
25use xmtp_proto::xmtp::{
26    device_sync::content::{
27        DeviceSyncAcknowledge, PreferenceUpdates as PreferenceUpdatesProto,
28        device_sync_content::Content as ContentProto,
29    },
30    mls::message_contents::EncodedContent,
31};
32
33const MAX_ATTEMPTS: i32 = 3;
34
35pub struct SyncWorker<Context> {
36    client: DeviceSyncClient<Context>,
37    receiver: broadcast::Receiver<SyncWorkerEvent>,
38    init: OnceCell<()>,
39    metrics: Arc<WorkerMetrics<SyncMetric>>,
40}
41
42impl<Context> SyncWorker<Context>
43where
44    Context: XmtpSharedContext + 'static,
45{
46    pub fn new(context: Context, metrics: Option<DynMetrics>) -> Self {
47        let receiver = context.worker_events().subscribe();
48        let metrics = metrics
49            .and_then(|m| m.as_sync_metrics())
50            .unwrap_or(Arc::new(WorkerMetrics::new(context.installation_id())));
51        let client = DeviceSyncClient::new(context, metrics.clone());
52
53        Self {
54            client,
55            receiver,
56            init: OnceCell::new(),
57            metrics,
58        }
59    }
60}
61
62struct Factory<Context> {
63    context: Context,
64}
65
66impl<Context> WorkerFactory for Factory<Context>
67where
68    Context: XmtpSharedContext + 'static,
69{
70    fn create(&self, metrics: Option<DynMetrics>) -> (BoxedWorker, Option<DynMetrics>) {
71        let worker = SyncWorker::new(self.context.clone(), metrics);
72        let metrics = worker.metrics.clone();
73
74        (Box::new(worker) as Box<_>, Some(metrics as Arc<_>))
75    }
76
77    fn kind(&self) -> WorkerKind {
78        WorkerKind::DeviceSync
79    }
80}
81
82#[xmtp_common::async_trait]
83impl<Context> Worker for SyncWorker<Context>
84where
85    Context: XmtpSharedContext + 'static,
86{
87    fn kind(&self) -> WorkerKind {
88        WorkerKind::DeviceSync
89    }
90
91    fn metrics(&self) -> Option<DynMetrics> {
92        Some(self.metrics.clone())
93    }
94
95    fn factory<C>(context: C) -> impl WorkerFactory + 'static
96    where
97        C: XmtpSharedContext + 'static,
98    {
99        Factory { context }
100    }
101
102    async fn run_tasks(&mut self) -> WorkerResult<()> {
103        self.run().map_err(|e| Box::new(e) as Box<_>).await
104    }
105}
106
107impl<Context> SyncWorker<Context>
108where
109    Context: XmtpSharedContext + 'static,
110{
111    async fn run(&mut self) -> Result<(), DeviceSyncError> {
112        self.sync_init().await?;
113        // Receipt must outlive each sync call so remote updates can wake this worker.
114        let _receipt = IncomingCoordinator::for_context(&self.client.context)
115            .acquire(IncomingScope::DeviceSyncGroups);
116        self.metrics.increment_metric(SyncMetric::Init);
117
118        let tick_fut = Self::tick(self.client.context.clone());
119        let run_fut = self.run_internal();
120
121        tokio::select! {
122            _ = tick_fut => Ok(()),
123            res = run_fut => res,
124        }
125    }
126
127    async fn run_internal(&mut self) -> Result<(), DeviceSyncError> {
128        use tokio::sync::broadcast::error::RecvError;
129        loop {
130            let event = match self.receiver.recv().await {
131                Ok(event) => event,
132                Err(RecvError::Lagged(skipped)) => {
133                    // The skipped events may have included NewSyncGroupFromWelcome,
134                    // whose durable task rows were never created. Re-scheduling is
135                    // cheap and deduped, so recover level-triggered instead of
136                    // losing the edge; a Tick-equivalent sweep covers skipped
137                    // NewSyncGroupMsg events the same way.
138                    tracing::warn!(
139                        skipped,
140                        "sync worker receiver lagged; re-scheduling installation reconciliation"
141                    );
142                    self.client.schedule_add_installations_to_groups()?;
143                    self.evt_new_sync_group_msg(true).await?;
144                    continue;
145                }
146                Err(RecvError::Closed) => break,
147            };
148
149            // Tick is the internal timer heartbeat (every 20s): no real work, so
150            // dispatch it directly without opening a worker_turn span.
151            if matches!(event, SyncWorkerEvent::Tick) {
152                self.evt_new_sync_group_msg(true).await?;
153                continue;
154            }
155
156            tracing::info!(
157                installation_id = %self.client.context.installation_id(),
158                "new sync worker event: {event:?}",
159            );
160            self.handle_event(event).await?;
161        }
162        Ok(())
163    }
164
165    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn", event = ?event))]
166    async fn handle_event(&mut self, event: SyncWorkerEvent) -> Result<(), DeviceSyncError> {
167        match event {
168            SyncWorkerEvent::NewSyncGroupFromWelcome(_group_id) => {
169                self.evt_new_sync_group_from_welcome().await
170            }
171            SyncWorkerEvent::NewSyncGroupMsg => self.evt_new_sync_group_msg(false).await,
172            SyncWorkerEvent::SyncPreferences(preference_updates) => {
173                self.evt_sync_preferences(preference_updates).await
174            }
175            SyncWorkerEvent::CycleHMAC => self.evt_cycle_hmac().await,
176            // Tick is intentionally filtered out in `run_internal` before reaching
177            // here, so it never opens a worker_turn span.
178            SyncWorkerEvent::Tick => unreachable!("Tick is handled before dispatch"),
179        }
180    }
181
182    async fn tick(ctx: Context) {
183        use futures::StreamExt;
184        let (base, jitter) = ctx.worker_interval(
185            crate::worker::WorkerKind::DeviceSync,
186            Duration::from_secs(20),
187        );
188        let mut intervals = xmtp_common::time::jittered_interval_stream(base, jitter);
189        // The interval stream yields immediately on its first poll; skip that
190        // so the first Tick is sent only after a full interval, preserving the
191        // original sleep-then-send cadence.
192        let _ = intervals.next().await;
193        while intervals.next().await.is_some() {
194            // We don't need to worry about a mutex lock for device sync
195            // to ensure that a sync payload is not being processed by two
196            // threads at once because there should only ever be one sync worker
197            // and the sync worker processes all events in series.
198            let _ = ctx.worker_events().send(SyncWorkerEvent::Tick);
199        }
200    }
201
202    /// Initialize the sync group when the client is registered.
203    #[instrument(level = "trace", skip_all)]
204    async fn sync_init(&mut self) -> Result<(), DeviceSyncError> {
205        let Self { init, client, .. } = &self;
206
207        init.get_or_try_init(|| async {
208            let conn = self.client.context.db();
209            log_event!(
210                Event::DeviceSyncInitializing,
211                self.client.context.installation_id()
212            );
213
214            // The only thing that sync init really does right now is ensures that there's a sync group.
215            if conn.primary_sync_group()?.is_none() {
216                log_event!(
217                    Event::DeviceSyncNoPrimarySyncGroup,
218                    self.client.context.installation_id()
219                );
220                let sync_group = client.get_sync_group().await?;
221                log_event!(
222                    Event::DeviceSyncCreatedPrimarySyncGroup,
223                    self.client.context.installation_id(),
224                    group_id = sync_group.group_id
225                );
226            }
227
228            log_event!(
229                Event::DeviceSyncInitializingFinished,
230                self.client.context.installation_id()
231            );
232
233            Ok(())
234        })
235        .await
236        .copied()
237    }
238
239    async fn evt_new_sync_group_from_welcome(&self) -> Result<(), DeviceSyncError> {
240        tracing::info!("New sync group from welcome detected.");
241
242        // A new sync group from a welcome indicates a new installation.
243        // Schedule durable per-group reconciliation on the TaskRunner —
244        // a one-shot inline add here is lost forever if it fails once.
245        self.client.schedule_add_installations_to_groups()?;
246
247        self.metrics
248            .increment_metric(SyncMetric::SyncGroupWelcomesProcessed);
249
250        // Cycle the HMAC
251        self.client.cycle_hmac().await?;
252
253        Ok(())
254    }
255
256    async fn evt_new_sync_group_msg(&self, is_tick: bool) -> Result<(), DeviceSyncError> {
257        let unprocessed_messages = self.client.context.db().unprocessed_sync_group_messages()?;
258
259        if !is_tick || !unprocessed_messages.is_empty() {
260            tracing::info!("Processing {} messages.", unprocessed_messages.len());
261        }
262
263        self.client
264            .process_sync_group_messages(&self.metrics, unprocessed_messages)
265            .await
266    }
267
268    async fn evt_sync_preferences(
269        &self,
270        updates: Vec<PreferenceUpdate>,
271    ) -> Result<(), DeviceSyncError> {
272        let updates = self.client.sync_preferences(updates).await?;
273
274        updates.iter().for_each(|update| match update {
275            PreferenceUpdate::Consent(_) => self.metrics.increment_metric(SyncMetric::ConsentSent),
276            PreferenceUpdate::Hmac { .. } => self.metrics.increment_metric(SyncMetric::HmacSent),
277        });
278        Ok(())
279    }
280
281    async fn evt_cycle_hmac(&self) -> Result<(), DeviceSyncError> {
282        self.client.cycle_hmac().await?;
283        Ok(())
284    }
285}
286
287impl<Context> DeviceSyncClient<Context>
288where
289    Context: XmtpSharedContext,
290{
291    async fn process_sync_group_messages(
292        &self,
293        handle: &WorkerMetrics<SyncMetric>,
294        messages: Vec<StoredGroupMessage>,
295    ) -> Result<(), DeviceSyncError>
296    where
297        Context::Db: 'static,
298    {
299        let installation_id = self.installation_id();
300
301        for msg in messages {
302            let content = EncodedContent::decode(&*msg.decrypted_message_bytes)
303                .ok()
304                .and_then(|content| decode_supported_content(&content.content));
305            let Some(content) = content else {
306                // Older installations can send archive transfer content. Its
307                // reserved oneof fields decode as unsupported content here.
308                // Mark it complete so the worker does not retry it forever.
309                self.context
310                    .db()
311                    .mark_device_sync_msg_as_processed(&msg.id)?;
312                continue;
313            };
314            let is_external = msg.sender_installation_id != installation_id;
315
316            let msg_type = match &content {
317                ContentProto::PreferenceUpdates(_) => "PreferenceUpdates",
318                ContentProto::Acknowledge(_) => "Acknowledge",
319            };
320
321            log_event!(
322                Event::DeviceSyncProcessingMessages,
323                self.context.installation_id(),
324                msg_type,
325                external = is_external,
326                message_id = #msg.id,
327                group_id = msg.group_id
328            );
329
330            if let Err(err) = self.process_message(handle, &msg, content).await {
331                // A failed message is non-fatal (log + bump attempt), but a
332                // dropped pool must stop the worker; bubble it.
333                if err.needs_db_reconnect() {
334                    return Err(err);
335                }
336                log_event!(
337                    Event::DeviceSyncMessageProcessingError,
338                    self.context.installation_id(),
339                    error = %err,
340                    message_id = #msg.id
341                );
342                self.context
343                    .db()
344                    .increment_device_sync_msg_attempt(&msg.id, MAX_ATTEMPTS)?;
345            } else {
346                self.context
347                    .db()
348                    .mark_device_sync_msg_as_processed(&msg.id)?;
349            }
350        }
351
352        Ok(())
353    }
354
355    async fn process_message(
356        &self,
357        handle: &WorkerMetrics<SyncMetric>,
358        msg: &StoredGroupMessage,
359        content: ContentProto,
360    ) -> Result<(), DeviceSyncError>
361    where
362        Context::Db: 'static,
363    {
364        let conn = self.context.db();
365        let installation_id = self.context.installation_id();
366        let is_external = msg.sender_installation_id != installation_id;
367
368        match content {
369            ContentProto::PreferenceUpdates(PreferenceUpdatesProto { updates }) => {
370                if is_external {
371                    tracing::info!("Incoming preference updates: {updates:?}");
372                }
373                tracing::info!(
374                    "{} storing preference updates",
375                    self.context.installation_id()
376                );
377                // We'll process even our own messages here. The sync group message ordering takes authority over our own here.
378                let updated = store_preference_updates(updates.clone(), &conn, handle)?;
379                if !updated.is_empty() {
380                    self.context.task_channels().wake_notifications();
381                    let _ = self
382                        .context
383                        .local_events()
384                        .send(LocalEvents::PreferencesChanged(updated));
385                }
386            }
387            ContentProto::Acknowledge(DeviceSyncAcknowledge { .. }) => {
388                return Ok(());
389            }
390        }
391
392        Ok(())
393    }
394}
395
396#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)]
397pub enum SyncMetric {
398    Init,
399    SyncGroupCreated,
400    SyncGroupWelcomesProcessed,
401    HmacSent,
402    HmacReceived,
403    ConsentSent,
404    ConsentReceived,
405}
406
407impl WorkerMetrics<SyncMetric> {
408    pub async fn wait_for_init(&self) -> Result<(), xmtp_common::time::Expired> {
409        self.register_interest(SyncMetric::Init, 1).wait().await
410    }
411}