Skip to main content

xmtp_mls/
worker.rs

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
27/// Hard cap on how long `WorkerRunner::shutdown` waits for the supervisor
28/// task to drain after cancellation. Anything beyond this gets logged and
29/// the task is allowed to detach — keeps `Client::close` bounded.
30const 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    /// Re-reads what the backend publishes about itself, hourly.
51    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/// Configuration for the cadence and enablement of background workers.
113///
114/// `Default` (all-`None`, empty maps) reproduces the historical behavior:
115/// every worker enabled, each using its own compiled-in interval const, no
116/// jitter. All fields are opt-in so existing callers are unaffected.
117///
118/// Durations are nanoseconds, matching [`crate::builder::ForkRecoveryOpts`].
119#[derive(Clone, Debug, Default)]
120pub struct WorkerConfig {
121    /// Global fallback interval (ns) for any worker without a per-kind
122    /// override. `None` => each worker uses its own const default.
123    pub default_interval_ns: Option<u64>,
124    /// Per-worker interval override (ns). Wins over `default_interval_ns`.
125    pub interval_overrides: HashMap<WorkerKind, u64>,
126    /// Per-worker jitter (ns). An absent entry means `0` (deterministic).
127    /// Jitter de-synchronizes a fleet of clients; scoping it per-worker
128    /// avoids blanketing fast workers with a large jitter meant for a slow
129    /// one (e.g. the daily CommitLog worker).
130    pub jitter_overrides: HashMap<WorkerKind, u64>,
131    /// Per-worker enable flag. An absent entry means enabled.
132    pub enabled: HashMap<WorkerKind, bool>,
133}
134
135impl WorkerConfig {
136    /// Resolve `(base, jitter)` for a worker.
137    ///
138    /// Base precedence: per-kind override > global default > `const_default`.
139    /// A resolved base of `0` is clamped to `const_default` to avoid a
140    /// pathological busy-loop. Jitter is the per-kind `jitter_overrides` entry
141    /// (0 if absent).
142    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    /// `true` unless an explicit `false` entry exists for `kind`.
162    pub fn worker_enabled(&self, kind: WorkerKind) -> bool {
163        self.enabled.get(&kind).copied().unwrap_or(true)
164    }
165}
166
167pub struct WorkerRunner {
168    // When this is cloned into the Context this is empty, so the Context and Client have different views
169    factories: Vec<DynFactory>,
170    metrics: Arc<Mutex<HashMap<WorkerKind, DynMetrics>>>,
171    task_channels: TaskWorkerChannels,
172    handle: Mutex<Option<Box<dyn StreamHandle<StreamOutput = ()>>>>,
173    // Per-worker abort handles. `shutdown` calls `abort()` on each so the
174    // worker future is dropped at its next poll regardless of whether the
175    // outer loop observed the cancellation token. Belt to the token's
176    // suspenders for workers that don't yield to cancellation promptly.
177    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    /// True while the supervisor handle is held; false after a successful
215    /// `shutdown` or before the first `spawn`. Used in tests and as a
216    /// cheap liveness check.
217    pub fn is_running(&self) -> bool {
218        self.handle.lock().is_some()
219    }
220
221    /// The kinds of every registered worker factory. Used in tests to assert
222    /// which workers a builder configuration registered.
223    #[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        // Force-abort any prior worker futures still alive from a previous spawn.
266        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    /// Drain the running worker supervisor. Bounded by [`WORKER_SHUTDOWN_TIMEOUT`].
347    /// Cancellation must be signalled separately (via the shared
348    /// `CancellationToken` on the context); this method additionally aborts
349    /// each worker's future to guarantee no further DB writes happen,
350    /// independent of whether the worker's loop respects the token.
351    pub async fn shutdown(&self) {
352        // Hard-kill each worker future at its next poll. Critical for workers
353        // that sit on long sleeps and only check the token between intervals.
354        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    /// Box the worker, erasing its type
418    fn boxed(self) -> Box<dyn Worker>
419    where
420        Self: Sized,
421    {
422        Box::new(self) as Box<_>
423    }
424
425    // Wrap the outer loop (not each `run_tasks` impl) so individual workers
426    // observe cancellation by having their in-flight future dropped at the
427    // next await point — no per-impl plumbing required.
428    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                    // No span here: `run_tasks` runs until worker death, so a
439                    // wrapping span exports only on restart with an hours-long
440                    // duration. Per-tick visibility comes from `worker_turn`;
441                    // restarts from the structured logs below.
442                    let outcome = self.run_tasks().await;
443                    if let Err(err) = outcome {
444                        if err.needs_db_reconnect() {
445                            // drop the worker
446                            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    /// Create a new worker
479    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// These fixtures use native pool errors. Browser connection lifecycle tests
566// cover closed persistent handles in the WASM database module.
567#[cfg(all(test, not(target_arch = "wasm32")))]
568mod disconnect_propagation_tests {
569    //! Pins that a dropped-pool signal survives the wrapper error types each
570    //! worker surfaces from `run_tasks`, so `needs_db_reconnect()` stays `true`.
571    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    /// A `StorageError` that signals the connection pool was dropped.
581    fn disconnect_storage() -> StorageError {
582        StorageError::Platform(PlatformStorageError::PoolNeedsConnection)
583    }
584
585    /// A `ConnectionError` that signals the connection pool was dropped.
586    fn disconnect_connection() -> ConnectionError {
587        ConnectionError::Platform(PlatformStorageError::PoolNeedsConnection)
588    }
589
590    /// A storage error that is NOT a disconnect — must never trip the contract.
591    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        // A non-disconnect storage failure inside a GroupError must not stop the worker.
611        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    // Per-worker `run_tasks` error types — what the supervisor actually inspects.
676
677    #[xmtp_common::test]
678    fn task_worker_load_group_forwards_disconnect() {
679        use crate::worker::tasks::TaskWorkerError;
680        // Self-remove tasks load the MLS group (MlsStoreError) and remove members
681        // (GroupError); a dropped pool must bubble through both paths.
682        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        // A transient (non-disconnect) connection error must not stop the worker.
700        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        // Per-key-package delete returns an IdentityError; a disconnect must
730        // bubble whether it arrives as a StorageError or a bare ConnectionError.
731        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        // The jittered worker gets its jitter...
809        let (_, commit_log_jitter) = cfg.interval(WorkerKind::CommitLog, Duration::from_secs(300));
810        assert_eq!(commit_log_jitter, Duration::from_secs(6 * 3600));
811        // ...while an un-listed worker stays deterministic.
812        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}