1pub mod device_sync;
2pub mod disappearing_messages;
3pub mod hmac_epoch;
4pub mod key_package_maintenance;
5pub mod metrics;
6pub(crate) mod notifications;
7pub mod tasks;
8
9use crate::context::XmtpSharedContext;
10use crate::subscriptions::internal::InternalEvent;
11use device_sync::worker::SyncMetric;
12use futures::future::{AbortHandle, Abortable};
13use futures::{StreamExt, stream::FuturesUnordered};
14use metrics::WorkerMetrics;
15use parking_lot::Mutex;
16use std::fmt::Debug;
17use std::pin::Pin;
18use std::{any::Any, collections::HashMap, hash::Hash, sync::Arc};
19use tasks::TaskWorkerChannels;
20use tokio_util::sync::CancellationToken;
21use tracing::Instrument;
22use tracing::instrument::Instrumented;
23use xmtp_common::{MaybeSend, MaybeSync, StreamHandle, if_native, if_wasm, time::Duration};
24use xmtp_configuration::WORKER_RESTART_DELAY;
25use xmtp_events::{EventFilter, EventKind, Subscription};
26
27const WORKER_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2);
31
32#[cfg(test)]
33pub(crate) mod test_hooks {
34 use std::sync::Arc;
35 use tokio::sync::Notify;
36
37 type PauseHook = (Vec<u8>, Arc<Notify>, Arc<Notify>);
38 pub(crate) static PAUSE_NEXT_SPAWN: parking_lot::Mutex<Option<PauseHook>> =
39 parking_lot::Mutex::new(None);
40}
41
42#[derive(PartialEq, Eq, Copy, Clone, Hash, Debug)]
43pub enum WorkerKind {
44 AttachmentCleanup,
45 DeviceSync,
46 DisappearingMessages,
47 KeyPackageCleaner,
48 CommitLog,
49 TaskRunner,
50 ConfigurationRefresh,
52 HmacEpoch,
53}
54
55fn worker_event_filter(kind: WorkerKind) -> Option<(EventFilter<InternalEvent>, Option<usize>)> {
56 use InternalEvent as I;
57 let filter = match kind {
58 WorkerKind::DeviceSync => EventFilter::new([EventKind::IdentityOwnInstallationRevoked])
59 .with_internal(|event| {
60 matches!(
61 event,
62 I::GroupJoined {
63 is_sync: true,
64 origin: crate::subscriptions::internal::GroupOrigin::Welcomed,
65 ..
66 } | I::MessageStored { is_sync: true, .. }
67 | I::SyncMessagePublished
68 | I::PreferencesChanged {
69 origin: crate::subscriptions::internal::PreferenceOrigin::Local,
70 ..
71 }
72 )
73 }),
74 WorkerKind::DisappearingMessages => {
75 EventFilter::new([EventKind::ConversationMetadataChanged]).with_internal(|event| {
76 matches!(
77 event,
78 I::MessageStored {
79 expires_at_ns: Some(_),
80 ..
81 }
82 )
83 })
84 }
85 WorkerKind::TaskRunner => EventFilter::new([
86 EventKind::ConsentChanged,
87 EventKind::ConversationJoined,
88 EventKind::ConversationRemoved,
89 EventKind::HmacKeysUpdated,
90 ])
91 .with_internal(|event| {
92 matches!(
93 event,
94 I::TaskScheduled
95 | I::NotificationSettingsChanged
96 | I::GroupJoined { .. }
97 | I::PreferencesChanged { .. }
98 )
99 }),
100 _ => return None,
101 };
102 Some((
103 filter,
104 if kind == WorkerKind::DeviceSync {
105 None
106 } else {
107 Some(10)
108 },
109 ))
110}
111
112#[derive(Clone, Debug, Default)]
120pub struct WorkerConfig {
121 pub default_interval_ns: Option<u64>,
124 pub interval_overrides: HashMap<WorkerKind, u64>,
126 pub jitter_overrides: HashMap<WorkerKind, u64>,
131 pub enabled: HashMap<WorkerKind, bool>,
133}
134
135impl WorkerConfig {
136 pub fn interval(&self, kind: WorkerKind, const_default: Duration) -> (Duration, Duration) {
143 let base_ns = self
144 .interval_overrides
145 .get(&kind)
146 .copied()
147 .or(self.default_interval_ns);
148 let base = match base_ns {
149 Some(0) | None => const_default,
150 Some(ns) => Duration::from_nanos(ns),
151 };
152 let jitter = self
153 .jitter_overrides
154 .get(&kind)
155 .copied()
156 .map(Duration::from_nanos)
157 .unwrap_or(Duration::ZERO);
158 (base, jitter)
159 }
160
161 pub fn worker_enabled(&self, kind: WorkerKind) -> bool {
163 self.enabled.get(&kind).copied().unwrap_or(true)
164 }
165}
166
167pub struct WorkerRunner {
168 factories: Vec<DynFactory>,
170 metrics: Arc<Mutex<HashMap<WorkerKind, DynMetrics>>>,
171 task_channels: TaskWorkerChannels,
172 handle: Mutex<Option<Box<dyn StreamHandle<StreamOutput = ()>>>>,
173 abort_handles: Mutex<Vec<AbortHandle>>,
178 subscriptions: HashMap<WorkerKind, Arc<Subscription<InternalEvent>>>,
179}
180
181impl Default for WorkerRunner {
182 fn default() -> Self {
183 Self::new()
184 }
185}
186
187impl WorkerRunner {
188 pub fn new() -> Self {
189 Self {
190 factories: Vec::new(),
191 metrics: Arc::default(),
192 task_channels: TaskWorkerChannels::default(),
193 handle: Mutex::default(),
194 abort_handles: Mutex::default(),
195 subscriptions: HashMap::new(),
196 }
197 }
198
199 pub fn metrics(&self) -> &Arc<Mutex<HashMap<WorkerKind, DynMetrics>>> {
200 &self.metrics
201 }
202
203 pub fn sync_metrics(&self) -> Option<Arc<WorkerMetrics<SyncMetric>>> {
204 self.metrics
205 .lock()
206 .get(&WorkerKind::DeviceSync)?
207 .as_sync_metrics()
208 }
209
210 pub fn task_channels(&self) -> &TaskWorkerChannels {
211 &self.task_channels
212 }
213
214 pub fn is_running(&self) -> bool {
218 self.handle.lock().is_some()
219 }
220
221 #[cfg(test)]
224 pub(crate) fn registered_kinds(&self) -> Vec<WorkerKind> {
225 self.factories.iter().map(|f| f.kind()).collect()
226 }
227
228 #[cfg(test)]
229 pub(crate) fn subscribed_kinds(&self) -> Vec<WorkerKind> {
230 self.subscriptions.keys().copied().collect()
231 }
232
233 #[cfg(test)]
234 pub(crate) fn subscription_for_test(
235 &self,
236 kind: WorkerKind,
237 ) -> Option<Arc<Subscription<InternalEvent>>> {
238 self.subscriptions.get(&kind).cloned()
239 }
240}
241
242impl WorkerRunner {
243 pub fn register_new_worker<W: Worker, C>(&mut self, ctx: C)
244 where
245 C: XmtpSharedContext + 'static,
246 {
247 let events = ctx.events().clone();
248 let factory = W::factory(ctx);
249 let kind = factory.kind();
250 if let Some((filter, depth)) = worker_event_filter(kind) {
251 self.subscriptions
252 .insert(kind, Arc::new(events.subscribe(filter, depth)));
253 }
254 self.factories.push(Arc::new(factory))
255 }
256
257 pub fn spawn<C>(self: &Arc<Self>, ctx: C)
258 where
259 C: XmtpSharedContext + 'static,
260 {
261 let mut handle_lock = self.handle.lock();
262 if let Some(handle) = handle_lock.take() {
263 handle.abort_handle().end();
264 }
265 for h in self.abort_handles.lock().drain(..) {
267 h.abort();
268 }
269
270 let this = self.clone();
271 let cancel = ctx.cancellation_token().clone();
272 let handle = xmtp_common::spawn(
273 None,
274 async move {
275 while !ctx.identity().is_ready() {
276 tokio::select! {
277 () = xmtp_common::time::sleep(Duration::from_millis(50)) => {},
278 () = cancel.cancelled() => {
279 this.close_subscriptions();
280 return;
281 }
282 }
283 }
284
285 #[cfg(test)]
286 {
287 let paused = {
288 let mut hook = test_hooks::PAUSE_NEXT_SPAWN.lock();
289 if hook.as_ref().is_some_and(|(installation, _, _)| {
290 installation.as_slice() == ctx.installation_id().to_vec().as_slice()
291 }) {
292 hook.take()
293 } else {
294 None
295 }
296 };
297 if let Some((_, entered, release)) = paused {
298 entered.notify_one();
299 release.notified().await;
300 }
301 }
302
303 let mut futs = FuturesUnordered::new();
304 let mut new_handles = Vec::with_capacity(this.factories.len());
305
306 for factory in &this.factories {
307 let metric = this.metrics.lock().get(&factory.kind()).cloned();
308 let (mut worker, metrics) = factory.create(metric);
309 if let Some(subscription) = this.subscriptions.get(&factory.kind()) {
310 worker.set_subscription(subscription.clone());
311 }
312
313 if let Some(metrics) = metrics {
314 this.metrics.lock().insert(factory.kind(), metrics);
315 }
316
317 if let Some(metrics) = worker.metrics() {
318 let mut m = this.metrics.lock();
319 m.insert(worker.kind(), metrics);
320 }
321 let (abort_handle, reg) = AbortHandle::new_pair();
322 new_handles.push(abort_handle);
323 futs.push(Abortable::new(
324 xmtp_common::bind_task_hub(worker.spawn(cancel.clone())),
325 reg,
326 ));
327 }
328 *this.abort_handles.lock() = new_handles;
329
330 while let Some(outcome) = futs.next().await {
331 match outcome {
332 Ok(kind) => tracing::warn!("Worker {kind:?} completed unexpectedly"),
333 Err(_aborted) => tracing::debug!("worker aborted during shutdown"),
334 }
335 }
336 if cancel.is_cancelled() {
337 this.close_subscriptions();
338 }
339 }
340 .instrument(tracing::debug_span!("xmtp_worker_supervisor")),
341 );
342
343 *handle_lock = Some(Box::new(handle));
344 }
345
346 pub async fn shutdown(&self) {
352 for h in self.abort_handles.lock().drain(..) {
355 h.abort();
356 }
357 let mut handle = self.handle.lock().take();
358 let Some(handle) = handle.as_mut() else {
359 self.close_subscriptions();
360 return;
361 };
362 match xmtp_common::time::timeout(WORKER_SHUTDOWN_TIMEOUT, handle.end_and_wait()).await {
363 Ok(Ok(())) => {}
364 Ok(Err(e)) => tracing::debug!("worker supervisor ended with: {e:?}"),
365 Err(_) => tracing::warn!(
366 "worker supervisor did not drain within {:?}; abandoning",
367 WORKER_SHUTDOWN_TIMEOUT
368 ),
369 }
370 self.close_subscriptions();
371 }
372
373 fn close_subscriptions(&self) {
374 for subscription in self.subscriptions.values() {
375 subscription.close();
376 }
377 }
378
379 pub async fn wait_for_sync_worker_init(&self) {
380 let handle = self
381 .metrics
382 .lock()
383 .get(&WorkerKind::DeviceSync)
384 .cloned()
385 .and_then(|h| h.as_sync_metrics());
386 if let Some(handle) = handle {
387 let _ = handle.wait_for_init().await;
388 }
389 }
390}
391
392pub type WorkerResult<T> = Result<T, Box<dyn NeedsDbReconnect>>;
393if_native! {
394 type SpawnWorkerFut = dyn Future<Output = WorkerKind> + Send;
395}
396if_wasm! {
397 type SpawnWorkerFut = dyn Future<Output = WorkerKind>;
398}
399
400#[xmtp_common::async_trait]
401pub trait Worker: MaybeSend + MaybeSync + 'static {
402 fn kind(&self) -> WorkerKind;
403
404 fn set_subscription(&mut self, _subscription: Arc<Subscription<InternalEvent>>) {}
405
406 async fn run_tasks(&mut self) -> Result<(), Box<dyn NeedsDbReconnect>>;
407
408 fn metrics(&self) -> Option<DynMetrics> {
409 None
410 }
411
412 fn factory<C>(context: C) -> impl WorkerFactory + 'static
413 where
414 Self: Sized,
415 C: XmtpSharedContext + 'static;
416
417 fn boxed(self) -> Box<dyn Worker>
419 where
420 Self: Sized,
421 {
422 Box::new(self) as Box<_>
423 }
424
425 fn spawn(
429 mut self: Box<Self>,
430 cancel: CancellationToken,
431 ) -> Instrumented<Pin<Box<SpawnWorkerFut>>> {
432 let kind_str = format!("{:?}", self.kind());
433 let fut = Box::pin(async move {
434 let kind = self.kind();
435 let worker = format!("{:?}", kind);
436 let run = async move {
437 loop {
438 let outcome = self.run_tasks().await;
443 if let Err(err) = outcome {
444 if err.needs_db_reconnect() {
445 tracing::debug!("pool disconnected. task will restart on reconnect");
447 break;
448 } else {
449 tracing::error!(worker = %worker, "{:?} worker error: {}", kind, err);
450 xmtp_common::time::sleep(WORKER_RESTART_DELAY).await;
451 tracing::info!(worker = %worker, "Restarting {:?} worker...", kind);
452 }
453 }
454 }
455 self.kind()
456 };
457
458 tokio::select! {
459 k = run => k,
460 _ = cancel.cancelled() => {
461 tracing::debug!("{:?} worker cancelled", kind);
462 kind
463 }
464 }
465 }) as Pin<Box<SpawnWorkerFut>>;
466 fut.instrument(tracing::debug_span!("libxmtp_worker", kind = kind_str))
467 }
468}
469
470#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(NeedsDbReconnect: Send + Sync))]
471#[cfg_attr(target_arch = "wasm32", trait_variant::make(NeedsDbReconnect: xmtp_common::Wasm))]
472pub trait LocalNeedsDbReconnect: std::error::Error {
473 fn needs_db_reconnect(&self) -> bool;
474}
475
476pub trait WorkerFactory: MaybeSend + MaybeSync {
477 fn kind(&self) -> WorkerKind;
478 fn create(&self, metrics: Option<DynMetrics>) -> (BoxedWorker, Option<DynMetrics>);
480}
481
482pub type BoxedWorker = Box<dyn Worker>;
483pub type DynFactory = Arc<dyn WorkerFactory>;
484
485pub type DynMetrics = Arc<dyn Any + Send + Sync>;
486
487pub trait MetricsCasting {
488 fn as_sync_metrics(&self) -> Option<Arc<WorkerMetrics<SyncMetric>>>;
489}
490
491impl MetricsCasting for DynMetrics {
492 fn as_sync_metrics(&self) -> Option<Arc<WorkerMetrics<SyncMetric>>> {
493 self.clone().downcast().ok()
494 }
495}
496
497#[cfg(test)]
498mod event_subscription_tests {
499 use super::*;
500 use crate::subscriptions::internal::{GroupOrigin, PreferenceOrigin};
501 use crate::worker::device_sync::preference_sync::PreferenceUpdate;
502 use xmtp_events::{EventBus, EventWriter};
503
504 #[xmtp_common::test(unwrap_try = true)]
505 async fn device_sync_keeps_all_local_changes_while_busy() {
506 let (filter, depth) = worker_event_filter(WorkerKind::DeviceSync).unwrap();
507 assert_eq!(depth, None);
508 let bus = EventBus::new();
509 let subscription = bus.subscribe(filter, depth);
510 for value in 0..1_000 {
511 bus.emit(
512 None,
513 Some(InternalEvent::PreferencesChanged {
514 updates: vec![PreferenceUpdate::Hmac {
515 key: vec![value as u8],
516 cycled_at_ns: value,
517 }],
518 origin: PreferenceOrigin::Local,
519 }),
520 );
521 }
522 bus.emit(
523 None,
524 Some(InternalEvent::PreferencesChanged {
525 updates: Vec::new(),
526 origin: PreferenceOrigin::Sync,
527 }),
528 );
529 assert_eq!(subscription.drain().len(), 1_000);
530 }
531
532 #[xmtp_common::test(unwrap_try = true)]
533 async fn only_a_welcomed_sync_group_requests_reconciliation() {
534 let (filter, depth) = worker_event_filter(WorkerKind::DeviceSync).unwrap();
535 let bus = EventBus::new();
536 let subscription = bus.subscribe(filter, depth);
537 let group_id = xmtp_proto::types::GroupId::from([9; 16]);
538 bus.emit(
539 None,
540 Some(InternalEvent::GroupJoined {
541 group_id,
542 is_sync: true,
543 origin: GroupOrigin::Created,
544 }),
545 );
546 assert!(subscription.drain().is_empty());
547 bus.emit(
548 None,
549 Some(InternalEvent::GroupJoined {
550 group_id,
551 is_sync: true,
552 origin: GroupOrigin::Welcomed,
553 }),
554 );
555 assert!(matches!(
556 subscription.next().await.unwrap().internal,
557 Some(InternalEvent::GroupJoined {
558 origin: GroupOrigin::Welcomed,
559 ..
560 })
561 ));
562 }
563}
564
565#[cfg(all(test, not(target_arch = "wasm32")))]
568mod disconnect_propagation_tests {
569 use super::NeedsDbReconnect;
572 use crate::groups::GroupError;
573 use crate::groups::commit_log::CommitLogError;
574 use crate::mls_store::MlsStoreError;
575 use crate::subscriptions::SubscribeError;
576 use crate::worker::device_sync::DeviceSyncError;
577 use crate::worker::key_package_maintenance::KeyPackageMaintenanceError;
578 use xmtp_db::{ConnectionError, PlatformStorageError, StorageError};
579
580 fn disconnect_storage() -> StorageError {
582 StorageError::Platform(PlatformStorageError::PoolNeedsConnection)
583 }
584
585 fn disconnect_connection() -> ConnectionError {
587 ConnectionError::Platform(PlatformStorageError::PoolNeedsConnection)
588 }
589
590 fn benign_storage() -> StorageError {
592 StorageError::InvalidHmacLength
593 }
594
595 #[xmtp_common::test]
596 fn group_error_forwards_disconnect() {
597 use crate::subscriptions::barrier::{
598 BarrierCause, BarrierError, BarrierFailure, BarrierTopic,
599 };
600 use crate::subscriptions::incoming::IncomingError;
601 use std::sync::Arc;
602 use xmtp_proto::types::{Cursor, Topic};
603
604 assert!(GroupError::Storage(disconnect_storage()).needs_db_reconnect());
605 assert!(GroupError::Db(disconnect_connection()).needs_db_reconnect());
606 assert!(
607 GroupError::MlsStore(MlsStoreError::Connection(disconnect_connection()))
608 .needs_db_reconnect()
609 );
610 assert!(!GroupError::Storage(benign_storage()).needs_db_reconnect());
612 assert!(!GroupError::InvalidGroupMembership.needs_db_reconnect());
613
614 let barrier = |cause| BarrierError::Incomplete {
615 reason: BarrierFailure::Blocked,
616 unfinished: vec![BarrierTopic {
617 topic: Topic::new_group_message([1; 32]),
618 scope_generation: None,
619 target: Some(Cursor(3)),
620 received: Cursor(2),
621 processed: Cursor(1),
622 unresolved_welcomes: Vec::new(),
623 inactive: false,
624 cause: Some(cause),
625 }],
626 };
627 assert!(
628 GroupError::StreamBarrier(barrier(BarrierCause::Storage(Arc::new(
629 disconnect_storage(),
630 ))))
631 .needs_db_reconnect()
632 );
633 assert!(
634 GroupError::PublishedButUnconfirmed {
635 intent_id: 1,
636 cause: Some(Box::new(barrier(BarrierCause::Receiver(Arc::new(
637 IncomingError::Processing(
638 crate::groups::mls_sync::GroupMessageProcessingError::Storage(
639 disconnect_storage(),
640 ),
641 ),
642 ))))),
643 }
644 .needs_db_reconnect()
645 );
646 assert!(
647 GroupError::Sync(Box::new(crate::groups::summary::SyncSummary::other(
648 GroupError::Storage(disconnect_storage()),
649 )))
650 .needs_db_reconnect()
651 );
652 assert!(
653 !GroupError::StreamBarrier(barrier(BarrierCause::Blocked("unsupported".into())))
654 .needs_db_reconnect()
655 );
656 }
657
658 #[xmtp_common::test]
659 fn mls_store_error_forwards_disconnect() {
660 assert!(MlsStoreError::Storage(disconnect_storage()).needs_db_reconnect());
661 assert!(MlsStoreError::Connection(disconnect_connection()).needs_db_reconnect());
662 assert!(!MlsStoreError::Storage(benign_storage()).needs_db_reconnect());
663 }
664
665 #[xmtp_common::test]
666 fn subscribe_error_forwards_disconnect() {
667 assert!(SubscribeError::Storage(disconnect_storage()).needs_db_reconnect());
668 assert!(SubscribeError::Db(disconnect_connection()).needs_db_reconnect());
669 assert!(
670 SubscribeError::from(GroupError::Storage(disconnect_storage())).needs_db_reconnect()
671 );
672 assert!(!SubscribeError::Storage(benign_storage()).needs_db_reconnect());
673 }
674
675 #[xmtp_common::test]
678 fn task_worker_load_group_forwards_disconnect() {
679 use crate::worker::tasks::TaskWorkerError;
680 assert!(
683 TaskWorkerError::LoadGroup(MlsStoreError::Connection(disconnect_connection()))
684 .needs_db_reconnect()
685 );
686 assert!(
687 TaskWorkerError::Group(GroupError::Storage(disconnect_storage())).needs_db_reconnect()
688 );
689 assert!(!TaskWorkerError::Group(GroupError::InvalidGroupMembership).needs_db_reconnect());
690 }
691
692 #[xmtp_common::test]
693 fn commit_log_error_forwards_disconnect() {
694 assert!(CommitLogError::Connection(disconnect_connection()).needs_db_reconnect());
695 assert!(
696 CommitLogError::GroupError(GroupError::Storage(disconnect_storage()))
697 .needs_db_reconnect()
698 );
699 assert!(
701 !CommitLogError::Connection(ConnectionError::DisconnectInTransaction)
702 .needs_db_reconnect()
703 );
704 }
705
706 #[xmtp_common::test]
707 fn device_sync_error_forwards_disconnect() {
708 assert!(DeviceSyncError::Storage(disconnect_storage()).needs_db_reconnect());
709 assert!(DeviceSyncError::Db(disconnect_connection()).needs_db_reconnect());
710 assert!(
711 DeviceSyncError::Group(GroupError::Storage(disconnect_storage())).needs_db_reconnect()
712 );
713 assert!(
714 DeviceSyncError::MlsStore(MlsStoreError::Connection(disconnect_connection()))
715 .needs_db_reconnect()
716 );
717 assert!(
718 DeviceSyncError::Subscribe(SubscribeError::Db(disconnect_connection()))
719 .needs_db_reconnect()
720 );
721 assert!(!DeviceSyncError::Storage(benign_storage()).needs_db_reconnect());
722 assert!(!DeviceSyncError::InvalidPayload.needs_db_reconnect());
723 }
724
725 #[xmtp_common::test]
726 fn key_package_maintenance_error_forwards_disconnect() {
727 use crate::identity::IdentityError;
728 assert!(KeyPackageMaintenanceError::Storage(disconnect_storage()).needs_db_reconnect());
729 assert!(
732 KeyPackageMaintenanceError::Identity(IdentityError::StorageError(disconnect_storage()))
733 .needs_db_reconnect()
734 );
735 assert!(
736 KeyPackageMaintenanceError::Identity(IdentityError::Db(disconnect_connection()))
737 .needs_db_reconnect()
738 );
739 assert!(!KeyPackageMaintenanceError::Storage(benign_storage()).needs_db_reconnect());
740 }
741}
742
743#[cfg(test)]
744mod worker_config_tests {
745 use super::{WorkerConfig, WorkerKind};
746 use std::time::Duration;
747
748 #[xmtp_common::test]
749 fn default_is_all_enabled_no_overrides() {
750 let cfg = WorkerConfig::default();
751 assert!(cfg.worker_enabled(WorkerKind::DeviceSync));
752 assert!(cfg.worker_enabled(WorkerKind::CommitLog));
753 let (base, jitter) = cfg.interval(WorkerKind::DisappearingMessages, Duration::from_secs(7));
754 assert_eq!(base, Duration::from_secs(7), "falls back to const default");
755 assert_eq!(jitter, Duration::ZERO, "no jitter by default");
756 }
757
758 #[xmtp_common::test]
759 fn per_kind_override_beats_global_default() {
760 let mut cfg = WorkerConfig {
761 default_interval_ns: Some(Duration::from_secs(3).as_nanos() as u64),
762 ..Default::default()
763 };
764 cfg.interval_overrides.insert(
765 WorkerKind::KeyPackageCleaner,
766 Duration::from_secs(9).as_nanos() as u64,
767 );
768 let (base, _) = cfg.interval(WorkerKind::KeyPackageCleaner, Duration::from_secs(99));
769 assert_eq!(base, Duration::from_secs(9), "per-kind override wins");
770 let (other, _) = cfg.interval(WorkerKind::CommitLog, Duration::from_secs(99));
771 assert_eq!(
772 other,
773 Duration::from_secs(3),
774 "global default for un-overridden worker"
775 );
776 }
777
778 #[xmtp_common::test]
779 fn zero_resolved_base_clamps_to_const() {
780 let mut cfg = WorkerConfig::default();
781 cfg.interval_overrides.insert(WorkerKind::CommitLog, 0);
782 let (base, _) = cfg.interval(WorkerKind::CommitLog, Duration::from_secs(60));
783 assert_eq!(
784 base,
785 Duration::from_secs(60),
786 "zero base clamps to const default"
787 );
788 }
789
790 #[xmtp_common::test]
791 fn per_kind_jitter_is_carried() {
792 let mut cfg = WorkerConfig::default();
793 cfg.jitter_overrides.insert(
794 WorkerKind::TaskRunner,
795 Duration::from_secs(2).as_nanos() as u64,
796 );
797 let (_, jitter) = cfg.interval(WorkerKind::TaskRunner, Duration::from_secs(1));
798 assert_eq!(jitter, Duration::from_secs(2));
799 }
800
801 #[xmtp_common::test]
802 fn jitter_is_scoped_per_worker() {
803 let mut cfg = WorkerConfig::default();
804 cfg.jitter_overrides.insert(
805 WorkerKind::CommitLog,
806 Duration::from_secs(6 * 3600).as_nanos() as u64,
807 );
808 let (_, commit_log_jitter) = cfg.interval(WorkerKind::CommitLog, Duration::from_secs(300));
810 assert_eq!(commit_log_jitter, Duration::from_secs(6 * 3600));
811 let (_, disappearing_jitter) =
813 cfg.interval(WorkerKind::DisappearingMessages, Duration::from_secs(1));
814 assert_eq!(disappearing_jitter, Duration::ZERO);
815 }
816
817 #[xmtp_common::test]
818 fn disabled_entry_reports_false() {
819 let mut cfg = WorkerConfig::default();
820 cfg.enabled.insert(WorkerKind::DeviceSync, false);
821 assert!(!cfg.worker_enabled(WorkerKind::DeviceSync));
822 assert!(
823 cfg.worker_enabled(WorkerKind::CommitLog),
824 "absent => enabled"
825 );
826 }
827}