Skip to main content

xmtp_mls/worker/
disappearing_messages.rs

1use crate::context::XmtpSharedContext;
2use crate::worker::{BoxedWorker, NeedsDbReconnect, Worker, WorkerFactory};
3use crate::worker::{WorkerKind, WorkerResult};
4use futures::TryFutureExt;
5use std::sync::Arc;
6use std::time::Duration;
7use thiserror::Error;
8use xmtp_common::time::now_ns;
9use xmtp_db::{StorageError, prelude::*};
10
11/// Default cap on how long the worker parks between deadline recomputes, used
12/// when [`WorkerConfig`](crate::worker::WorkerConfig) supplies no override. With
13/// the dedicated non-lossy re-arm channel this should never be the trigger in
14/// practice; it bounds the worst case only against a never-emitted or lost
15/// re-arm, and is the sleep when no disappearing messages are scheduled.
16const FALLBACK_INTERVAL: Duration = Duration::from_secs(24 * 3600);
17
18/// Dedicated wake channel for the disappearing-messages worker.
19///
20/// A **capacity-1** mpsc carrying unit `()` "recompute the next expiry deadline"
21/// nudges. Capacity 1 is deliberate: the worker drains and re-queries the DB on
22/// every wake, so a single pending nudge already captures "something changed" —
23/// more would be redundant. It also bounds memory to one slot even when no worker
24/// is consuming the channel (e.g. the disappearing worker is disabled or
25/// `disable_workers` is set), so `rearm()` can never accumulate without bound.
26#[derive(Clone)]
27pub struct DisappearingChannels {
28    sender: tokio::sync::mpsc::Sender<()>,
29    pub receiver: Arc<tokio::sync::Mutex<tokio::sync::mpsc::Receiver<()>>>,
30}
31
32impl Default for DisappearingChannels {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38impl DisappearingChannels {
39    pub fn new() -> Self {
40        let (sender, receiver) = tokio::sync::mpsc::channel(1);
41        Self {
42            sender,
43            receiver: Arc::new(tokio::sync::Mutex::new(receiver)),
44        }
45    }
46
47    /// Wake the worker to recompute its next-expiry deadline. Best-effort and
48    /// non-blocking: if a nudge is already queued (slot full) or no worker is
49    /// consuming, the send is dropped — the worker recomputes from the DB on its
50    /// next wake regardless, so a dropped duplicate nudge changes nothing.
51    pub fn rearm(&self) {
52        let _ = self.sender.try_send(());
53    }
54}
55
56#[derive(Debug, Error)]
57pub enum DisappearingMessagesCleanerError {
58    #[error("storage error: {0}")]
59    Storage(#[from] StorageError),
60    #[error("failed to delete expired messages: {0}")]
61    DeleteExpired(StorageError),
62}
63
64impl NeedsDbReconnect for DisappearingMessagesCleanerError {
65    fn needs_db_reconnect(&self) -> bool {
66        match self {
67            Self::Storage(s) | Self::DeleteExpired(s) => s.db_needs_connection(),
68        }
69    }
70}
71
72pub struct DisappearingMessagesWorker<Context> {
73    context: Context,
74}
75
76struct Factory<Context> {
77    context: Context,
78}
79
80impl<Context> WorkerFactory for Factory<Context>
81where
82    Context: XmtpSharedContext + 'static,
83{
84    fn create(
85        &self,
86        metrics: Option<crate::worker::DynMetrics>,
87    ) -> (BoxedWorker, Option<crate::worker::DynMetrics>) {
88        let worker = Box::new(DisappearingMessagesWorker::new(self.context.clone())) as Box<_>;
89        (worker, metrics)
90    }
91
92    fn kind(&self) -> WorkerKind {
93        WorkerKind::DisappearingMessages
94    }
95}
96
97#[xmtp_common::async_trait]
98impl<Context> Worker for DisappearingMessagesWorker<Context>
99where
100    Context: XmtpSharedContext + 'static,
101{
102    fn kind(&self) -> WorkerKind {
103        WorkerKind::DisappearingMessages
104    }
105
106    async fn run_tasks(&mut self) -> WorkerResult<()> {
107        self.run().map_err(|e| Box::new(e) as Box<_>).await
108    }
109
110    fn factory<C>(context: C) -> impl WorkerFactory + 'static
111    where
112        Self: Sized,
113        C: XmtpSharedContext + 'static,
114    {
115        Factory { context }
116    }
117}
118
119impl<Context> DisappearingMessagesWorker<Context>
120where
121    Context: XmtpSharedContext + 'static,
122{
123    pub fn new(context: Context) -> Self {
124        Self { context }
125    }
126}
127
128impl<Context> DisappearingMessagesWorker<Context>
129where
130    Context: XmtpSharedContext + 'static,
131{
132    /// Event-driven loop: sleep until the soonest message expiry, deleting the
133    /// batch when the deadline arrives. A re-arm signal (sent post-commit when a
134    /// disappearing message is stored) wakes the loop early to recompute the
135    /// deadline. With no disappearing messages scheduled, parks for `FALLBACK_MAX`.
136    async fn run(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
137        // Resolve the fallback cap (and optional jitter) from WorkerConfig, the
138        // same knobs the other workers honor.
139        let (fallback, jitter) = self
140            .context
141            .worker_interval(WorkerKind::DisappearingMessages, FALLBACK_INTERVAL);
142        let receiver = self.context.disappearing_channels().receiver.clone();
143        let mut receiver = receiver.lock().await;
144        loop {
145            // Coalesce any pending re-arm signals so we recompute the deadline once.
146            while receiver.try_recv().is_ok() {}
147
148            let next = self
149                .context
150                .db()
151                .min_expire_at_ns()
152                .map_err(|e| DisappearingMessagesCleanerError::Storage(e.into()))?;
153            // A real expiry drives a precise deadline (no jitter — we don't want
154            // to delay actual deletions). Only the idle/fallback wake is jittered,
155            // to de-synchronize a fleet of clients booted together.
156            let dur = match next {
157                Some(expire_at) => {
158                    Duration::from_nanos((expire_at - now_ns()).max(0) as u64).min(fallback)
159                }
160                None => fallback.saturating_add(xmtp_common::time::rand_offset(jitter)),
161            };
162
163            tokio::select! {
164                // A disappearing message was stored; loop to recompute the deadline.
165                _ = receiver.recv() => {}
166                // Deadline reached (or fallback); delete whatever is now expired.
167                () = xmtp_common::time::sleep(dur) => {
168                    self.delete_expired_messages().await?;
169                }
170            }
171        }
172    }
173
174    /// Iterate on the list of groups and delete expired messages
175    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
176    async fn delete_expired_messages(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
177        let db = self.context.db();
178        // Propagated to the supervisor, which is the sole logger for worker errors.
179        let deleted_messages = db
180            .delete_expired_messages()
181            .map_err(|e| DisappearingMessagesCleanerError::DeleteExpired(e.into()))?;
182
183        if !deleted_messages.is_empty() {
184            tracing::info!(
185                "Successfully deleted {} expired messages",
186                deleted_messages.len()
187            );
188
189            // Emit a single event for all deleted messages
190            // this avoids a hot loop that may starve async tasks.
191            let _ =
192                self.context
193                    .local_events()
194                    .send(crate::subscriptions::LocalEvents::MsgsDeleted(
195                        deleted_messages,
196                    ));
197        }
198
199        Ok(())
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[xmtp_common::test(unwrap_try = true)]
208    async fn rearm_delivers_a_signal() {
209        let ch = DisappearingChannels::new();
210        ch.rearm();
211        let mut rx = ch.receiver.lock().await;
212        assert!(rx.recv().await.is_some());
213    }
214}