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        incoming::{IncomingCoordinator, IncomingScope},
9        internal::{GroupOrigin, InternalEvent, PreferenceOrigin},
10    },
11    worker::{
12        BoxedWorker, DynMetrics, MetricsCasting, NeedsDbReconnect, Worker, WorkerFactory,
13        WorkerKind, WorkerResult, metrics::WorkerMetrics,
14    },
15};
16use futures::TryFutureExt;
17use parking_lot::Mutex;
18use prost::Message;
19use std::{
20    sync::{
21        Arc,
22        atomic::{AtomicU64, Ordering},
23    },
24    time::Duration,
25};
26use tokio::sync::OnceCell;
27use tracing::instrument;
28use xmtp_common::Event;
29use xmtp_db::group_message::StoredGroupMessage;
30use xmtp_db::prelude::*;
31use xmtp_events::{ClientEvent, Subscription};
32use xmtp_macro::log_event;
33use xmtp_proto::xmtp::{
34    device_sync::content::{
35        DeviceSyncAcknowledge, PreferenceUpdates as PreferenceUpdatesProto,
36        device_sync_content::Content as ContentProto,
37    },
38    mls::message_contents::EncodedContent,
39};
40
41pub(super) fn log_incoming_preference_updates(
42    updates: &[xmtp_proto::xmtp::device_sync::content::PreferenceUpdate],
43) {
44    tracing::info!(update_count = updates.len(), "Incoming preference updates");
45}
46
47const MAX_ATTEMPTS: i32 = 3;
48type PendingEvent = Arc<Mutex<Option<(u64, xmtp_events::EventEnvelope<InternalEvent>)>>>;
49
50#[cfg(test)]
51pub(crate) mod test_hooks {
52    use std::{
53        collections::{HashMap, HashSet},
54        sync::{Arc, LazyLock, Weak},
55    };
56    use tokio::sync::Notify;
57    use xmtp_db::consent_record::StoredConsentRecord;
58    use xmtp_proto::types::InstallationId;
59
60    use super::PreferenceUpdate;
61
62    pub(crate) type ConsentKey = (i32, String);
63
64    #[derive(Clone, Copy, Debug)]
65    pub(crate) enum PreferenceStage {
66        Published,
67        Received,
68        Completed,
69    }
70
71    #[derive(Clone, Debug)]
72    pub(crate) struct ConsentObservation {
73        pub(crate) installation: InstallationId,
74        pub(crate) stage: PreferenceStage,
75        pub(crate) record: StoredConsentRecord,
76    }
77
78    struct PreferenceCaptureState {
79        keys: HashSet<ConsentKey>,
80        records: parking_lot::Mutex<Vec<ConsentObservation>>,
81        changed: Notify,
82    }
83
84    static PREFERENCE_CAPTURES: LazyLock<
85        parking_lot::Mutex<HashMap<InstallationId, Weak<PreferenceCaptureState>>>,
86    > = LazyLock::new(parking_lot::Mutex::default);
87
88    pub(crate) struct PreferenceCapture {
89        installations: Vec<InstallationId>,
90        state: Arc<PreferenceCaptureState>,
91    }
92
93    impl PreferenceCapture {
94        #[track_caller]
95        pub(crate) fn new(
96            installations: Vec<InstallationId>,
97            records: &[StoredConsentRecord],
98        ) -> Self {
99            let state = Arc::new(PreferenceCaptureState {
100                keys: records
101                    .iter()
102                    .map(|record| (record.entity_type as i32, record.entity.clone()))
103                    .collect(),
104                records: parking_lot::Mutex::default(),
105                changed: Notify::new(),
106            });
107            let mut captures = PREFERENCE_CAPTURES.lock();
108            for installation in &installations {
109                assert!(
110                    captures.get(installation).and_then(Weak::upgrade).is_none(),
111                    "one preference capture per installation"
112                );
113                captures.insert(*installation, Arc::downgrade(&state));
114            }
115            Self {
116                installations,
117                state,
118            }
119        }
120
121        pub(crate) fn snapshot(&self) -> Vec<ConsentObservation> {
122            self.state.records.lock().clone()
123        }
124
125        pub(crate) async fn changed(&self) {
126            self.state.changed.notified().await;
127        }
128    }
129
130    impl Drop for PreferenceCapture {
131        fn drop(&mut self) {
132            let mut captures = PREFERENCE_CAPTURES.lock();
133            for installation in &self.installations {
134                if captures
135                    .get(installation)
136                    .is_some_and(|capture| Weak::ptr_eq(capture, &Arc::downgrade(&self.state)))
137                {
138                    captures.remove(installation);
139                }
140            }
141        }
142    }
143
144    pub(crate) fn captures_preferences(installation: InstallationId) -> bool {
145        PREFERENCE_CAPTURES
146            .lock()
147            .get(&installation)
148            .and_then(Weak::upgrade)
149            .is_some()
150    }
151
152    pub(crate) fn observe_preferences(
153        installation: InstallationId,
154        stage: PreferenceStage,
155        updates: &[PreferenceUpdate],
156    ) {
157        let capture = PREFERENCE_CAPTURES
158            .lock()
159            .get(&installation)
160            .and_then(Weak::upgrade);
161        let Some(capture) = capture else {
162            return;
163        };
164        let mut records = capture.records.lock();
165        let before = records.len();
166        for update in updates {
167            if let PreferenceUpdate::Consent(record) = update
168                && capture
169                    .keys
170                    .contains(&(record.entity_type as i32, record.entity.clone()))
171            {
172                records.push(ConsentObservation {
173                    installation,
174                    stage,
175                    record: record.clone(),
176                });
177            }
178        }
179        let changed = records.len() != before;
180        drop(records);
181        if changed {
182            capture.changed.notify_one();
183        }
184    }
185
186    type BlockHook = (Vec<u8>, Arc<Notify>, Arc<Notify>);
187    pub(crate) static FAIL_NEXT_PREFERENCE_PUBLISH: parking_lot::Mutex<
188        Option<(Vec<u8>, Arc<Notify>)>,
189    > = parking_lot::Mutex::new(None);
190    pub(crate) static BLOCK_NEXT_PREFERENCE_PUBLISH: parking_lot::Mutex<Option<BlockHook>> =
191        parking_lot::Mutex::new(None);
192}
193
194#[cfg(test)]
195struct TestTickGate {
196    ready: futures::channel::oneshot::Sender<()>,
197    release: futures::channel::oneshot::Receiver<()>,
198}
199
200pub struct SyncWorker<Context> {
201    client: DeviceSyncClient<Context>,
202    subscription: Option<Arc<Subscription<InternalEvent>>>,
203    pending: PendingEvent,
204    next_event_id: Arc<AtomicU64>,
205    init: OnceCell<()>,
206    metrics: Arc<WorkerMetrics<SyncMetric>>,
207    #[cfg(test)]
208    completed_turns: Option<Arc<[AtomicU64; 2]>>,
209    #[cfg(test)]
210    received_ticks: Option<Arc<AtomicU64>>,
211    #[cfg(test)]
212    tick_gates: std::collections::VecDeque<TestTickGate>,
213}
214
215impl<Context> SyncWorker<Context>
216where
217    Context: XmtpSharedContext + 'static,
218{
219    pub fn new(
220        context: Context,
221        metrics: Option<DynMetrics>,
222        pending: PendingEvent,
223        next_event_id: Arc<AtomicU64>,
224    ) -> Self {
225        let metrics = metrics
226            .and_then(|m| m.as_sync_metrics())
227            .unwrap_or(Arc::new(WorkerMetrics::new(context.installation_id())));
228        let client = DeviceSyncClient::new(context, metrics.clone());
229
230        Self {
231            client,
232            subscription: None,
233            pending,
234            next_event_id,
235            init: OnceCell::new(),
236            metrics,
237            #[cfg(test)]
238            completed_turns: None,
239            #[cfg(test)]
240            received_ticks: None,
241            #[cfg(test)]
242            tick_gates: Default::default(),
243        }
244    }
245}
246
247struct Factory<Context> {
248    context: Context,
249    pending: PendingEvent,
250    next_event_id: Arc<AtomicU64>,
251}
252
253impl<Context> WorkerFactory for Factory<Context>
254where
255    Context: XmtpSharedContext + 'static,
256{
257    fn create(&self, metrics: Option<DynMetrics>) -> (BoxedWorker, Option<DynMetrics>) {
258        let worker = SyncWorker::new(
259            self.context.clone(),
260            metrics,
261            self.pending.clone(),
262            self.next_event_id.clone(),
263        );
264        let metrics = worker.metrics.clone();
265
266        (Box::new(worker) as Box<_>, Some(metrics as Arc<_>))
267    }
268
269    fn kind(&self) -> WorkerKind {
270        WorkerKind::DeviceSync
271    }
272}
273
274#[xmtp_common::async_trait]
275impl<Context> Worker for SyncWorker<Context>
276where
277    Context: XmtpSharedContext + 'static,
278{
279    fn kind(&self) -> WorkerKind {
280        WorkerKind::DeviceSync
281    }
282
283    fn set_subscription(&mut self, subscription: Arc<Subscription<InternalEvent>>) {
284        self.subscription = Some(subscription);
285    }
286
287    fn metrics(&self) -> Option<DynMetrics> {
288        Some(self.metrics.clone())
289    }
290
291    fn factory<C>(context: C) -> impl WorkerFactory + 'static
292    where
293        C: XmtpSharedContext + 'static,
294    {
295        Factory {
296            context,
297            pending: Arc::default(),
298            next_event_id: Arc::new(AtomicU64::new(1)),
299        }
300    }
301
302    async fn run_tasks(&mut self) -> WorkerResult<()> {
303        self.run().map_err(|e| Box::new(e) as Box<_>).await
304    }
305}
306
307impl<Context> SyncWorker<Context>
308where
309    Context: XmtpSharedContext + 'static,
310{
311    async fn run(&mut self) -> Result<(), DeviceSyncError> {
312        // Keep the large startup future off the worker poll stack.
313        Box::pin(self.sync_init()).await?;
314        // Receipt must outlive each sync call so remote updates can wake this worker.
315        let _receipt = IncomingCoordinator::for_context(&self.client.context)
316            .acquire(IncomingScope::DeviceSyncGroups);
317        self.metrics.increment_metric(SyncMetric::Init);
318
319        // Keep event futures off the containing worker poll stack.
320        Box::pin(self.run_internal()).await
321    }
322
323    async fn run_internal(&mut self) -> Result<(), DeviceSyncError> {
324        use futures::StreamExt;
325        let (base, jitter) = self
326            .client
327            .context
328            .worker_interval(WorkerKind::DeviceSync, Duration::from_secs(20));
329        let intervals = xmtp_common::time::jittered_interval_stream(base, jitter);
330        #[cfg(test)]
331        let intervals = {
332            let gates = Arc::new(Mutex::new(std::mem::take(&mut self.tick_gates)));
333            let intervals = intervals.then(move |instant| {
334                let gate = gates.lock().pop_front();
335                async move {
336                    if let Some(gate) = gate {
337                        let _ = gate.ready.send(());
338                        let _ = gate.release.await;
339                    }
340                    instant
341                }
342            });
343            xmtp_common::wasm_or_native! {
344                native => { intervals.boxed() },
345                wasm => { intervals.boxed_local() },
346            }
347        };
348        let mut intervals = intervals;
349        // Poll events while native startup jitter delays the first tick.
350        let mut skip_initial_tick = cfg!(not(target_arch = "wasm32"));
351        let subscription = self
352            .subscription
353            .as_ref()
354            .expect("runner installs subscription")
355            .clone();
356        loop {
357            let pending_event = { self.pending.lock().clone() };
358            if let Some((id, event)) = pending_event {
359                self.handle_pending_event(id, event).await?;
360                continue;
361            }
362            tokio::select! {
363                event = subscription.next() => {
364                    let Some(event) = event else { break; };
365                    let id = self.next_event_id.fetch_add(1, Ordering::Relaxed);
366                    *self.pending.lock() = Some((id, event.clone()));
367                    self.handle_pending_event(id, event).await?;
368                }
369                _ = intervals.next() => {
370                    #[cfg(test)]
371                    if let Some(ticks) = &self.received_ticks {
372                        ticks.fetch_add(1, Ordering::SeqCst);
373                    }
374                    if skip_initial_tick {
375                        skip_initial_tick = false;
376                        continue;
377                    }
378                    self.evt_new_sync_group_msg(true).await?;
379                },
380            }
381        }
382        Ok(())
383    }
384
385    async fn handle_pending_event(
386        &mut self,
387        id: u64,
388        event: xmtp_events::EventEnvelope<InternalEvent>,
389    ) -> Result<(), DeviceSyncError> {
390        // Pending state stays owned here until the event succeeds.
391        Box::pin(self.handle_event(event.clone())).await?;
392        #[cfg(test)]
393        if let Some(InternalEvent::PreferencesChanged {
394            updates,
395            origin: PreferenceOrigin::Local,
396        }) = &event.internal
397        {
398            test_hooks::observe_preferences(
399                self.client.context.installation_id(),
400                test_hooks::PreferenceStage::Completed,
401                updates,
402            );
403        }
404        let mut pending = self.pending.lock();
405        if pending
406            .as_ref()
407            .is_some_and(|(pending_id, _)| *pending_id == id)
408        {
409            *pending = None;
410        }
411        Ok(())
412    }
413
414    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
415    async fn handle_event(
416        &mut self,
417        event: xmtp_events::EventEnvelope<InternalEvent>,
418    ) -> Result<(), DeviceSyncError> {
419        if matches!(
420            event.client,
421            Some(ClientEvent::IdentityOwnInstallationRevoked(_))
422        ) {
423            self.evt_cycle_hmac().await?;
424        }
425        match event.internal {
426            Some(InternalEvent::GroupJoined {
427                is_sync: true,
428                origin: GroupOrigin::Welcomed,
429                ..
430            }) => self.evt_new_sync_group_from_welcome().await,
431            Some(
432                InternalEvent::MessageStored { is_sync: true, .. }
433                | InternalEvent::SyncMessagePublished,
434            ) => self.evt_new_sync_group_msg(false).await,
435            Some(InternalEvent::PreferencesChanged {
436                updates,
437                origin: PreferenceOrigin::Local,
438            }) => self.evt_sync_preferences(updates).await,
439            _ => Ok(()),
440        }
441    }
442
443    /// Initialize the sync group when the client is registered.
444    #[instrument(level = "trace", skip_all)]
445    async fn sync_init(&mut self) -> Result<(), DeviceSyncError> {
446        let Self { init, client, .. } = &self;
447
448        init.get_or_try_init(|| async {
449            log_event!(
450                Event::DeviceSyncInitializing,
451                self.client.context.installation_id()
452            );
453
454            // The only thing that sync init really does right now is ensures that there's a sync group.
455            if client.primary_sync_group()?.is_none() {
456                log_event!(
457                    Event::DeviceSyncNoPrimarySyncGroup,
458                    self.client.context.installation_id()
459                );
460                // Sync-group creation polls membership publication below this call.
461                let sync_group = Box::pin(client.get_sync_group()).await?;
462                log_event!(
463                    Event::DeviceSyncCreatedPrimarySyncGroup,
464                    self.client.context.installation_id(),
465                    group_id = sync_group.group_id
466                );
467            }
468
469            log_event!(
470                Event::DeviceSyncInitializingFinished,
471                self.client.context.installation_id()
472            );
473
474            Ok(())
475        })
476        .await
477        .copied()
478    }
479
480    async fn evt_new_sync_group_from_welcome(&self) -> Result<(), DeviceSyncError> {
481        tracing::info!("New sync group from welcome detected.");
482
483        // A new sync group from a welcome indicates a new installation.
484        // Schedule durable per-group reconciliation on the TaskRunner —
485        // a one-shot inline add here is lost forever if it fails once.
486        self.client.schedule_add_installations_to_groups()?;
487
488        self.metrics
489            .increment_metric(SyncMetric::SyncGroupWelcomesProcessed);
490
491        // Cycle the HMAC
492        self.client.cycle_hmac().await?;
493
494        // Consent can be stored before this primary group is adopted.
495        let updates = self
496            .client
497            .db()
498            .consent_records()?
499            .into_iter()
500            .map(PreferenceUpdate::Consent)
501            .collect::<Vec<_>>();
502        if !updates.is_empty() {
503            self.client.sync_preferences(updates).await?;
504        }
505
506        Ok(())
507    }
508
509    async fn evt_new_sync_group_msg(&self, is_tick: bool) -> Result<(), DeviceSyncError> {
510        let unprocessed_messages = self.client.context.db().unprocessed_sync_group_messages()?;
511
512        if !is_tick || !unprocessed_messages.is_empty() {
513            tracing::info!("Processing {} messages.", unprocessed_messages.len());
514        }
515
516        let result = self
517            .client
518            .process_sync_group_messages(&self.metrics, unprocessed_messages)
519            .await;
520        #[cfg(test)]
521        if result.is_ok()
522            && let Some(turns) = &self.completed_turns
523        {
524            turns[usize::from(is_tick)].fetch_add(1, Ordering::SeqCst);
525        }
526        result
527    }
528
529    async fn evt_sync_preferences(
530        &self,
531        updates: Vec<PreferenceUpdate>,
532    ) -> Result<(), DeviceSyncError> {
533        #[cfg(test)]
534        {
535            let blocked = {
536                let mut hook = test_hooks::BLOCK_NEXT_PREFERENCE_PUBLISH.lock();
537                if hook.as_ref().is_some_and(|(installation, _, _)| {
538                    installation.as_slice()
539                        == self.client.context.installation_id().to_vec().as_slice()
540                }) {
541                    hook.take()
542                } else {
543                    None
544                }
545            };
546            if let Some((_, entered, release)) = blocked {
547                entered.notify_one();
548                release.notified().await;
549            }
550            let failed = {
551                let mut hook = test_hooks::FAIL_NEXT_PREFERENCE_PUBLISH.lock();
552                if hook.as_ref().is_some_and(|(installation, _)| {
553                    installation.as_slice()
554                        == self.client.context.installation_id().to_vec().as_slice()
555                }) {
556                    hook.take()
557                } else {
558                    None
559                }
560            };
561            if let Some((_, observed)) = failed {
562                observed.notify_one();
563                return Err(DeviceSyncError::IO(std::io::Error::other(
564                    "test preference publication failure",
565                )));
566            }
567        }
568        self.client.sync_preferences(updates).await?;
569        Ok(())
570    }
571
572    async fn evt_cycle_hmac(&self) -> Result<(), DeviceSyncError> {
573        self.client.cycle_hmac().await?;
574        Ok(())
575    }
576}
577
578impl<Context> DeviceSyncClient<Context>
579where
580    Context: XmtpSharedContext,
581{
582    pub(super) async fn process_sync_group_messages(
583        &self,
584        handle: &WorkerMetrics<SyncMetric>,
585        messages: Vec<StoredGroupMessage>,
586    ) -> Result<(), DeviceSyncError>
587    where
588        Context::Db: 'static,
589    {
590        let installation_id = self.installation_id();
591
592        for msg in messages {
593            // Older builds stored sync groups from other inboxes.
594            // implements: SYNC-011
595            if msg.sender_inbox_id != self.context.inbox_id() {
596                tracing::warn!(
597                    group_id = %msg.group_id,
598                    sender_inbox_id = %msg.sender_inbox_id,
599                    "dropping a sync message from another inbox"
600                );
601                self.context
602                    .db()
603                    .mark_device_sync_msg_as_processed(&msg.id)?;
604                continue;
605            }
606            let content = EncodedContent::decode(&*msg.decrypted_message_bytes)
607                .ok()
608                .and_then(|content| decode_supported_content(&content.content));
609            let Some(content) = content else {
610                // Older installations can send archive transfer content. Its
611                // reserved oneof fields decode as unsupported content here.
612                // Mark it complete so the worker does not retry it forever.
613                self.context
614                    .db()
615                    .mark_device_sync_msg_as_processed(&msg.id)?;
616                continue;
617            };
618            let is_external = msg.sender_installation_id != installation_id;
619
620            let msg_type = match &content {
621                ContentProto::PreferenceUpdates(_) => "PreferenceUpdates",
622                ContentProto::Acknowledge(_) => "Acknowledge",
623            };
624
625            log_event!(
626                Event::DeviceSyncProcessingMessages,
627                self.context.installation_id(),
628                msg_type,
629                external = is_external,
630                message_id = #msg.id,
631                group_id = msg.group_id
632            );
633
634            if let Err(err) = self.process_message(handle, &msg, content).await {
635                // A failed message is non-fatal (log + bump attempt), but a
636                // dropped pool must stop the worker; bubble it.
637                if err.needs_db_reconnect() {
638                    return Err(err);
639                }
640                log_event!(
641                    Event::DeviceSyncMessageProcessingError,
642                    self.context.installation_id(),
643                    error = %err,
644                    message_id = #msg.id
645                );
646                self.context
647                    .db()
648                    .increment_device_sync_msg_attempt(&msg.id, MAX_ATTEMPTS)?;
649            } else {
650                self.context
651                    .db()
652                    .mark_device_sync_msg_as_processed(&msg.id)?;
653            }
654        }
655
656        Ok(())
657    }
658
659    async fn process_message(
660        &self,
661        handle: &WorkerMetrics<SyncMetric>,
662        msg: &StoredGroupMessage,
663        content: ContentProto,
664    ) -> Result<(), DeviceSyncError>
665    where
666        Context::Db: 'static,
667    {
668        let installation_id = self.context.installation_id();
669        let is_external = msg.sender_installation_id != installation_id;
670
671        match content {
672            ContentProto::PreferenceUpdates(PreferenceUpdatesProto { updates }) => {
673                #[cfg(test)]
674                let observed_updates =
675                    test_hooks::captures_preferences(installation_id).then(|| updates.clone());
676                if is_external {
677                    log_incoming_preference_updates(&updates);
678                }
679                // implements: PROC-036
680                tracing::info!(update_count = updates.len(), "storing preference updates");
681                // We'll process even our own messages here. The sync group message ordering takes authority over our own here.
682                crate::state_tx::state_write_with_events(
683                    self.context.mls_storage(),
684                    self.context.events(),
685                    |tx, events| {
686                        let storage = tx.storage();
687                        let db = storage.db();
688                        let updated = store_preference_updates(updates.clone(), &db, handle)?;
689                        crate::subscriptions::internal::emit_preference_updates_with_public(
690                            events,
691                            updated.public,
692                            updated.legacy.clone(),
693                            PreferenceOrigin::Sync,
694                            &db,
695                        )?;
696                        if !updated.legacy.is_empty() {
697                            self.context.task_channels().mark_notification_changed();
698                        }
699                        Ok::<_, xmtp_db::StorageError>(xmtp_db::TransactionOutcome::Continue(
700                            updated.legacy,
701                        ))
702                    },
703                )?
704                .into_continued();
705                #[cfg(test)]
706                if let Some(observed_updates) = observed_updates {
707                    test_hooks::observe_preferences(
708                        installation_id,
709                        test_hooks::PreferenceStage::Received,
710                        &observed_updates
711                            .into_iter()
712                            .map(|update| {
713                                PreferenceUpdate::try_from(update)
714                                    .expect("valid observed preference update")
715                            })
716                            .collect::<Vec<_>>(),
717                    );
718                }
719            }
720            ContentProto::Acknowledge(DeviceSyncAcknowledge { .. }) => {
721                return Ok(());
722            }
723        }
724
725        Ok(())
726    }
727}
728
729#[derive(PartialEq, Eq, Hash, Clone, Copy, Debug)]
730pub enum SyncMetric {
731    Init,
732    SyncGroupCreated,
733    SyncGroupWelcomesProcessed,
734    HmacSent,
735    HmacReceived,
736    ConsentSent,
737    ConsentReceived,
738}
739
740impl WorkerMetrics<SyncMetric> {
741    pub async fn wait_for_init(&self) -> Result<(), xmtp_common::time::Expired> {
742        self.register_interest(SyncMetric::Init, 1).wait().await
743    }
744}
745
746#[cfg(test)]
747mod startup_tests {
748    use super::*;
749    use crate::{tester, worker::WorkerConfig};
750    use futures::FutureExt;
751    use xmtp_events::EventWriter;
752
753    #[cfg(not(target_arch = "wasm32"))]
754    #[xmtp_common::test(unwrap_try = true)]
755    async fn consent_published_before_primary_adoption_reaches_new_installation() {
756        use xmtp_db::consent_record::{ConsentState, ConsentType, StoredConsentRecord};
757
758        tester!(source, disable_workers);
759        let source_sync = source.device_sync_client();
760        let old_primary = source_sync.get_sync_group().await?;
761        let group = source.create_group(None, None)?;
762        let record = StoredConsentRecord {
763            entity_type: ConsentType::ConversationId,
764            state: ConsentState::Denied,
765            entity: hex::encode(group.group_id),
766            consented_at_ns: xmtp_common::time::now_ns(),
767        };
768        source
769            .set_consent_states(std::slice::from_ref(&record))
770            .await?;
771        let record = source
772            .context
773            .db()
774            .get_consent_record(record.entity.clone(), record.entity_type)?
775            .unwrap();
776
777        tester!(copy, from: source, disable_workers);
778        let copy_sync = copy.device_sync_client();
779        let new_primary = copy_sync.get_sync_group().await?;
780        assert_ne!(old_primary.group_id, new_primary.group_id);
781        assert_eq!(
782            source_sync.primary_sync_group()?.unwrap().group_id,
783            old_primary.group_id
784        );
785        assert!(
786            copy.context
787                .db()
788                .find_group(&old_primary.group_id)?
789                .is_none()
790        );
791
792        // Publish before the source can adopt the new installation's group.
793        source_sync
794            .sync_preferences(vec![PreferenceUpdate::Consent(record.clone())])
795            .await?;
796        source.sync_welcomes().await?;
797        assert_eq!(
798            source_sync.primary_sync_group()?.unwrap().group_id,
799            new_primary.group_id
800        );
801        assert!(
802            copy.context
803                .db()
804                .get_consent_record(record.entity.clone(), record.entity_type)?
805                .is_none()
806        );
807
808        // Run the actual welcome handler, then consume its primary-group messages.
809        let source_worker = SyncWorker::new(
810            source.context.clone(),
811            None,
812            Arc::new(Mutex::new(None)),
813            Arc::new(AtomicU64::new(0)),
814        );
815        source_worker.evt_new_sync_group_from_welcome().await?;
816        new_primary.sync().await?;
817        let copy_worker = SyncWorker::new(
818            copy.context.clone(),
819            None,
820            Arc::new(Mutex::new(None)),
821            Arc::new(AtomicU64::new(0)),
822        );
823        copy_worker.evt_new_sync_group_msg(false).await?;
824        let received = copy
825            .context
826            .db()
827            .get_consent_record(record.entity.clone(), record.entity_type)?;
828        assert_eq!(
829            received.as_ref(),
830            Some(&record),
831            "a consent published before primary adoption must reach the new installation"
832        );
833        assert_eq!(received.unwrap().consented_at_ns, record.consented_at_ns);
834        source.close().await?;
835        copy.close().await?;
836    }
837
838    #[cfg(not(target_arch = "wasm32"))]
839    #[xmtp_common::test(unwrap_try = true)]
840    async fn worker_turn_span_omits_hmac_key() {
841        tester!(alix, disable_workers);
842        let mut worker = SyncWorker::new(
843            alix.context.clone(),
844            None,
845            Arc::new(Mutex::new(None)),
846            Arc::new(AtomicU64::new(0)),
847        );
848        let key = (1_u8..=42).collect::<Vec<_>>();
849        let key_debug = format!("{key:?}");
850        let event = xmtp_events::EventEnvelope::new(
851            None,
852            Some(InternalEvent::PreferencesChanged {
853                updates: vec![PreferenceUpdate::Hmac {
854                    key,
855                    cycled_at_ns: 1,
856                }],
857                origin: PreferenceOrigin::Sync,
858            }),
859            Default::default(),
860        );
861        let (result, spans) = xmtp_logging::test_logging::with_trace_layer(true, || {
862            worker
863                .handle_event(event)
864                .now_or_never()
865                .expect("the Sync-origin handler must finish without IO")
866        });
867        result?;
868        let span = spans
869            .iter()
870            .find(|span| span.name == "handle_event")
871            .expect("the actual worker handler must export its span");
872        let fields = format!("{:?}", span.attributes);
873        assert!(fields.contains("worker_turn"));
874        assert!(fields.contains("DeviceSync"));
875        assert!(
876            !fields.contains(&key_debug),
877            "worker span must not export the HMAC root key"
878        );
879        alix.close().await?;
880    }
881
882    #[xmtp_common::test(unwrap_try = true)]
883    async fn queued_event_precedes_first_periodic_turn() {
884        const PERIOD: Duration = Duration::from_secs(4);
885        const EVENT_WAIT: Duration = Duration::from_secs(1);
886        const PERIOD_WAIT: Duration = Duration::from_secs(6);
887        let config = WorkerConfig {
888            interval_overrides: [(WorkerKind::DeviceSync, PERIOD.as_nanos() as u64)]
889                .into_iter()
890                .collect(),
891            ..Default::default()
892        };
893        tester!(alix, disable_workers, worker_config: config);
894        let turns = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]);
895        let mut worker = SyncWorker::new(
896            alix.context.clone(),
897            None,
898            Arc::new(Mutex::new(None)),
899            Arc::new(AtomicU64::new(0)),
900        );
901        worker.completed_turns = Some(turns.clone());
902        let (filter, depth) = crate::worker::worker_event_filter(WorkerKind::DeviceSync).unwrap();
903        let subscription = Arc::new(alix.context.events().subscribe(filter, depth));
904        worker.set_subscription(subscription.clone());
905        alix.context
906            .events()
907            .emit(None, Some(InternalEvent::SyncMessagePublished));
908
909        {
910            let run = worker.run_internal().fuse();
911            let observe = async {
912                xmtp_common::time::timeout(
913                    EVENT_WAIT,
914                    xmtp_common::wait_for_eq(|| async { turns[0].load(Ordering::SeqCst) }, 1),
915                )
916                .await
917                .expect("queued event must complete before the first periodic tick")
918                .expect("queued event wait must complete");
919                assert_eq!(turns[1].load(Ordering::SeqCst), 0);
920                xmtp_common::time::timeout(
921                    PERIOD_WAIT,
922                    xmtp_common::wait_for_eq(
923                        || async { turns[1].load(Ordering::SeqCst) >= 1 },
924                        true,
925                    ),
926                )
927                .await
928                .expect("periodic reconciliation must still complete")
929                .expect("periodic turn wait must complete");
930                assert_eq!(turns[0].load(Ordering::SeqCst), 1);
931            }
932            .fuse();
933            futures::pin_mut!(run, observe);
934            futures::select! {
935                () = observe => {},
936                result = run => panic!("worker ended before observed turns: {result:?}"),
937            }
938        }
939        subscription.close();
940        alix.close().await?;
941    }
942    struct TickControl {
943        gate: TestTickGate,
944        ready: futures::channel::oneshot::Receiver<()>,
945        release: futures::channel::oneshot::Sender<()>,
946    }
947
948    impl TickControl {
949        fn new() -> Self {
950            let (ready_tx, ready) = futures::channel::oneshot::channel();
951            let (release, release_rx) = futures::channel::oneshot::channel();
952            Self {
953                gate: TestTickGate {
954                    ready: ready_tx,
955                    release: release_rx,
956                },
957                ready,
958                release,
959            }
960        }
961    }
962
963    fn jitter_config() -> WorkerConfig {
964        const PERIOD: Duration = Duration::from_millis(40);
965        const JITTER: Duration = Duration::from_millis(20);
966        WorkerConfig {
967            interval_overrides: [(WorkerKind::DeviceSync, PERIOD.as_nanos() as u64)]
968                .into_iter()
969                .collect(),
970            jitter_overrides: [(WorkerKind::DeviceSync, JITTER.as_nanos() as u64)]
971                .into_iter()
972                .collect(),
973            ..Default::default()
974        }
975    }
976
977    #[xmtp_common::test(unwrap_try = true)]
978    async fn jittered_initial_tick_does_not_block_events_or_change_first_turn() {
979        const WAIT: Duration = Duration::from_secs(1);
980        tester!(alix, disable_workers, worker_config: jitter_config());
981        let turns = Arc::new([AtomicU64::new(0), AtomicU64::new(0)]);
982        let ticks = Arc::new(AtomicU64::new(0));
983        let mut worker = SyncWorker::new(
984            alix.context.clone(),
985            None,
986            Arc::new(Mutex::new(None)),
987            Arc::new(AtomicU64::new(0)),
988        );
989        worker.completed_turns = Some(turns.clone());
990        worker.received_ticks = Some(ticks.clone());
991        let first = TickControl::new();
992        let second = TickControl::new();
993        worker.tick_gates.extend([first.gate, second.gate]);
994        let (filter, depth) = crate::worker::worker_event_filter(WorkerKind::DeviceSync).unwrap();
995        let subscription = Arc::new(alix.context.events().subscribe(filter, depth));
996        worker.set_subscription(subscription.clone());
997        {
998            let run = worker.run_internal().fuse();
999            let observe = async {
1000                xmtp_common::time::timeout(WAIT, first.ready)
1001                    .await
1002                    .expect("actual jittered first tick must reach the private gate")
1003                    .expect("first tick gate must remain owned");
1004                alix.context
1005                    .events()
1006                    .emit(None, Some(InternalEvent::SyncMessagePublished));
1007                xmtp_common::time::timeout(
1008                    WAIT,
1009                    xmtp_common::wait_for_eq(|| async { turns[0].load(Ordering::SeqCst) }, 1),
1010                )
1011                .await
1012                .expect("queued event must complete while the initial tick is pending")
1013                .expect("queued event wait must complete");
1014                assert_eq!(turns[1].load(Ordering::SeqCst), 0);
1015                first.release.send(()).expect("release the first tick");
1016                xmtp_common::time::timeout(WAIT, second.ready)
1017                    .await
1018                    .expect("actual jittered second tick must reach the private gate")
1019                    .expect("second tick gate must remain owned");
1020                assert_eq!(ticks.load(Ordering::SeqCst), 1);
1021                let first_periodic = u64::from(cfg!(target_arch = "wasm32"));
1022                assert_eq!(
1023                    turns[1].load(Ordering::SeqCst),
1024                    first_periodic,
1025                    "native skips its first tick; Wasm reconciles its first tick"
1026                );
1027                second.release.send(()).expect("release the second tick");
1028                xmtp_common::time::timeout(
1029                    WAIT,
1030                    xmtp_common::wait_for_eq(
1031                        || async { turns[1].load(Ordering::SeqCst) > first_periodic },
1032                        true,
1033                    ),
1034                )
1035                .await
1036                .expect("periodic reconciliation must complete after the second tick")
1037                .expect("periodic reconciliation wait must complete");
1038            }
1039            .fuse();
1040            futures::pin_mut!(run, observe);
1041            futures::select! {
1042                () = observe => {},
1043                result = run => panic!("worker ended before observed turns: {result:?}"),
1044            }
1045        }
1046        subscription.close();
1047        alix.close().await?;
1048    }
1049
1050    #[xmtp_common::test(unwrap_try = true)]
1051    async fn closing_subscription_stops_worker_with_initial_tick_pending() {
1052        const WAIT: Duration = Duration::from_secs(1);
1053        tester!(alix, disable_workers, worker_config: jitter_config());
1054        let mut worker = SyncWorker::new(
1055            alix.context.clone(),
1056            None,
1057            Arc::new(Mutex::new(None)),
1058            Arc::new(AtomicU64::new(0)),
1059        );
1060        let first = TickControl::new();
1061        worker.tick_gates.push_back(first.gate);
1062        let (filter, depth) = crate::worker::worker_event_filter(WorkerKind::DeviceSync).unwrap();
1063        let subscription = Arc::new(alix.context.events().subscribe(filter, depth));
1064        worker.set_subscription(subscription.clone());
1065        let (result, ()) = xmtp_common::time::timeout(WAIT, async {
1066            futures::join!(worker.run_internal(), async {
1067                first
1068                    .ready
1069                    .await
1070                    .expect("actual first tick reaches the gate");
1071                subscription.close();
1072            })
1073        })
1074        .await
1075        .expect("closed subscription must stop while the initial tick is pending");
1076        result?;
1077        assert!(
1078            first.release.is_canceled(),
1079            "ending the worker must drop the pending tick receiver"
1080        );
1081        alix.close().await?;
1082    }
1083}