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 Box::pin(self.sync_init()).await?;
314 let _receipt = IncomingCoordinator::for_context(&self.client.context)
316 .acquire(IncomingScope::DeviceSyncGroups);
317 self.metrics.increment_metric(SyncMetric::Init);
318
319 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 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 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 #[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 if client.primary_sync_group()?.is_none() {
456 log_event!(
457 Event::DeviceSyncNoPrimarySyncGroup,
458 self.client.context.installation_id()
459 );
460 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 self.client.schedule_add_installations_to_groups()?;
487
488 self.metrics
489 .increment_metric(SyncMetric::SyncGroupWelcomesProcessed);
490
491 self.client.cycle_hmac().await?;
493
494 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 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 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 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 tracing::info!(update_count = updates.len(), "storing preference updates");
681 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 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 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}