Skip to main content

xmtp_mls_core/worker/
disappearing_messages.rs

1use crate::context::CoreContext;
2use crate::events::InternalEvent;
3use crate::worker::{
4    BoxedWorker, EVENT_BUFFER, NeedsDbReconnect, NewWorker, Worker, WorkerFactory,
5};
6use crate::worker::{WorkerKind, WorkerResult};
7use futures::TryFutureExt;
8use std::sync::Arc;
9use std::time::Duration;
10use thiserror::Error;
11use xmtp_common::time::now_ns;
12use xmtp_db::{
13    StorageError, XmtpMlsStorageProvider, group_message::EXPIRED_MESSAGE_BATCH, prelude::*,
14};
15use xmtp_events::{EventFilter, EventKind, Subscription};
16
17/// Default cap on how long the worker parks between deadline recomputes, used
18/// when [`WorkerConfig`](crate::worker::WorkerConfig) supplies no override. With
19/// the event subscription this is a fallback when no message is scheduled.
20const FALLBACK_INTERVAL: Duration = Duration::from_secs(24 * 3600);
21
22#[derive(Debug, Error)]
23pub enum DisappearingMessagesCleanerError {
24    #[error("storage error: {0}")]
25    Storage(#[from] StorageError),
26    #[error("failed to delete expired messages: {0}")]
27    DeleteExpired(StorageError),
28}
29
30impl NeedsDbReconnect for DisappearingMessagesCleanerError {
31    #[deny(clippy::wildcard_enum_match_arm)]
32    fn needs_db_reconnect(&self) -> bool {
33        match self {
34            Self::Storage(s) | Self::DeleteExpired(s) => s.db_needs_connection(),
35        }
36    }
37}
38
39pub struct DisappearingMessagesWorker<Context> {
40    context: Context,
41    subscription: Option<Arc<Subscription<InternalEvent>>>,
42}
43
44struct Factory<Context> {
45    context: Context,
46}
47
48impl<Context> WorkerFactory for Factory<Context>
49where
50    Context: CoreContext + 'static,
51{
52    fn create(&self) -> BoxedWorker {
53        Box::new(DisappearingMessagesWorker::new(self.context.clone()))
54    }
55
56    fn kind(&self) -> WorkerKind {
57        WorkerKind::DisappearingMessages
58    }
59
60    fn event_interest(&self) -> Option<(EventFilter<InternalEvent>, Option<usize>)> {
61        let filter =
62            EventFilter::new([EventKind::ConversationMetadataChanged]).with_internal(|event| {
63                matches!(
64                    event,
65                    InternalEvent::MessageStored {
66                        expires_at_ns: Some(_),
67                        ..
68                    }
69                )
70            });
71        Some((filter, Some(EVENT_BUFFER)))
72    }
73}
74
75#[xmtp_common::async_trait]
76impl<Context> Worker for DisappearingMessagesWorker<Context>
77where
78    Context: CoreContext + 'static,
79{
80    fn kind(&self) -> WorkerKind {
81        WorkerKind::DisappearingMessages
82    }
83
84    fn set_subscription(&mut self, subscription: Arc<Subscription<InternalEvent>>) {
85        self.subscription = Some(subscription);
86    }
87
88    async fn run_tasks(&mut self) -> WorkerResult<()> {
89        self.run().map_err(|e| Box::new(e) as Box<_>).await
90    }
91}
92
93impl<Context> NewWorker<Context> for DisappearingMessagesWorker<Context>
94where
95    Context: CoreContext + 'static,
96{
97    fn factory(context: Context) -> impl WorkerFactory + 'static {
98        Factory { context }
99    }
100}
101
102impl<Context> DisappearingMessagesWorker<Context>
103where
104    Context: CoreContext + 'static,
105{
106    pub fn new(context: Context) -> Self {
107        Self {
108            context,
109            subscription: None,
110        }
111    }
112}
113
114impl<Context> DisappearingMessagesWorker<Context>
115where
116    Context: CoreContext + 'static,
117{
118    /// Event-driven loop: sleep until the soonest message expiry, deleting the
119    /// batch when the deadline arrives. A stored-message fact makes it read the
120    /// next deadline again.
121    async fn run(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
122        // Resolve the fallback cap (and optional jitter) from WorkerConfig, the
123        // same knobs the other workers honor.
124        let (fallback, jitter) = self
125            .context
126            .worker_interval(WorkerKind::DisappearingMessages, FALLBACK_INTERVAL);
127        let subscription = self
128            .subscription
129            .as_ref()
130            .expect("runner installs subscription")
131            .clone();
132        loop {
133            subscription.drain();
134
135            let next = self
136                .context
137                .mls_storage()
138                .db()
139                .min_expire_at_ns()
140                .map_err(|e| DisappearingMessagesCleanerError::Storage(e.into()))?;
141            // A real expiry drives a precise deadline (no jitter — we don't want
142            // to delay actual deletions). Only the idle/fallback wake is jittered,
143            // to de-synchronize a fleet of clients booted together.
144            let dur = match next {
145                Some(expire_at) => {
146                    Duration::from_nanos((expire_at - now_ns()).max(0) as u64).min(fallback)
147                }
148                None => fallback.saturating_add(xmtp_common::time::rand_offset(jitter)),
149            };
150
151            tokio::select! {
152                event = subscription.next() => {
153                    if event.is_none() { break Ok(()); }
154                }
155                // Deadline reached (or fallback); delete whatever is now expired.
156                () = xmtp_common::time::sleep(dur) => {
157                    self.delete_expired_messages().await?;
158                }
159            }
160        }
161    }
162
163    /// Iterate on the list of groups and delete expired messages
164    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
165    async fn delete_expired_messages(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
166        // implements: META-063
167        let pass_start = now_ns();
168        let mut total = 0;
169        loop {
170            let deleted = crate::state_tx::state_write_with_events(
171                self.context.mls_storage(),
172                self.context.events(),
173                |tx, events| {
174                    let storage = tx.storage();
175                    let db = storage.db();
176                    let deleted = db.delete_expired_messages(pass_start)?;
177                    let n = deleted.len();
178                    crate::events::emit_expired_messages(events, deleted, &db)?;
179                    Ok::<_, StorageError>(xmtp_db::TransactionOutcome::Continue(n))
180                },
181            )
182            .map_err(DisappearingMessagesCleanerError::DeleteExpired)?
183            .into_continued();
184            total += deleted;
185            if deleted < EXPIRED_MESSAGE_BATCH as usize {
186                break;
187            }
188            // Let waiting writers in between batches.
189            xmtp_common::task::yield_now().await;
190        }
191
192        if total > 0 {
193            tracing::info!("Successfully deleted {} expired messages", total);
194        }
195
196        Ok(())
197    }
198}
199
200#[cfg(any(test, feature = "test-utils"))]
201#[doc(hidden)]
202pub mod test_support {
203    use super::{DisappearingMessagesCleanerError, DisappearingMessagesWorker};
204    use crate::context::CoreContext;
205
206    /// Run one cleanup pass of `worker`, as its schedule runs it.
207    pub async fn delete_expired_messages<Context>(
208        worker: &mut DisappearingMessagesWorker<Context>,
209    ) -> Result<(), DisappearingMessagesCleanerError>
210    where
211        Context: CoreContext + 'static,
212    {
213        worker.delete_expired_messages().await
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::test_context::{real_context, stored_msg};
221
222    use std::collections::HashSet;
223    use xmtp_db::group_message::StoredGroupMessage;
224    use xmtp_events::{ClientEvent, EventBus, EventFilter, EventKind, EventWriter};
225
226    use xmtp_proto::types::Cursor;
227    use xmtp_proto::types::GroupId;
228
229    // verifies: EVENT-001, META-063
230    #[xmtp_common::test(unwrap_try = true)]
231    async fn expired_message_worker_emits_once_after_deletion() {
232        let context = real_context().await;
233        let group_id = GroupId::from([9; 16]);
234        let events = context
235            .events()
236            .subscribe_app(EventFilter::new([EventKind::MessageExpired]))?;
237        let message_id = store_expiring_message(&context, group_id)?;
238        wait_until_expired(&context, &message_id).await;
239        let db = context.db();
240        // A cohort larger than one batch: one pass must still delete all of it.
241        let template = db.get_group_message(&message_id)?.unwrap();
242        let mut expired_ids = HashSet::from([message_id]);
243        for _ in 0..EXPIRED_MESSAGE_BATCH {
244            let extra = StoredGroupMessage {
245                id: xmtp_common::rand_vec::<32>(),
246                ..template.clone()
247            };
248            extra.store(&db)?;
249            expired_ids.insert(extra.id);
250        }
251
252        let mut worker = DisappearingMessagesWorker::new(context.clone());
253        worker.delete_expired_messages().await?;
254        for id in &expired_ids {
255            assert!(db.get_group_message(id)?.is_none());
256        }
257        let emitted: Vec<_> = events
258            .drain()
259            .into_iter()
260            .filter_map(|event| match event.client {
261                Some(ClientEvent::MessageExpired(e)) if e.group_id == group_id.to_vec() => {
262                    Some(e.message_id)
263                }
264                _ => None,
265            })
266            .collect();
267        assert_eq!(emitted.len(), 1_001);
268        assert_eq!(HashSet::from_iter(emitted), expired_ids);
269        worker.delete_expired_messages().await?;
270        assert!(events.drain().is_empty());
271    }
272
273    /// Store a group and an application message in it that expires one
274    /// nanosecond after it was sent, as a group with disappearing settings
275    /// of one nanosecond stores a message it sends. Return the message's ID.
276    fn store_expiring_message(
277        context: &impl CoreContext,
278        group_id: GroupId,
279    ) -> Result<Vec<u8>, StorageError> {
280        use xmtp_db::group::{GroupMembershipState, StoredGroup};
281        let db = context.db();
282        StoredGroup::builder()
283            .id(group_id)
284            .created_at_ns(now_ns())
285            .membership_state(GroupMembershipState::Allowed)
286            .added_by_inbox_id(context.inbox_id())
287            .conversation_type(xmtp_proto::types::ConversationType::Group)
288            .build()?
289            .store(&db)?;
290        let sent_at_ns = now_ns();
291        let message = StoredGroupMessage {
292            decrypted_message_bytes: b"expires".to_vec(),
293            sent_at_ns,
294            sender_inbox_id: context.inbox_id().to_owned(),
295            sender_installation_id: context.installation_id().to_vec(),
296            expire_at_ns: Some(sent_at_ns + 1),
297            ..stored_msg(Cursor(1), group_id)
298        };
299        message.store(&db)?;
300        Ok(message.id)
301    }
302
303    /// The longest a test waits for a stored expiry.
304    const EXPIRY_WAIT_LIMIT: Duration = Duration::from_secs(30);
305
306    /// Wait until the stored expiry of `id` has passed. Confirmation replaces
307    /// the local send time with the backend's, so the stored value can be
308    /// later than the local clock. A skewed backend clock fails the test
309    /// instead of hanging it.
310    async fn wait_until_expired(context: &impl CoreContext, id: &[u8]) {
311        let expire_at = context
312            .db()
313            .get_group_message(id)
314            .unwrap()
315            .and_then(|message| message.expire_at_ns)
316            .expect("an expiring message");
317        let now = now_ns();
318        let wait = Duration::from_nanos(expire_at.saturating_sub(now).max(0) as u64);
319        assert!(
320            wait <= EXPIRY_WAIT_LIMIT,
321            "stored expire_at_ns {expire_at} is {wait:?} after now_ns {now}, more than \
322             {EXPIRY_WAIT_LIMIT:?}; the backend clock is probably ahead of the local clock"
323        );
324        while now_ns() < expire_at {
325            let wait = (expire_at - now_ns()).max(0) as u64;
326            xmtp_common::time::sleep(Duration::from_nanos(wait) + Duration::from_millis(1)).await;
327        }
328    }
329
330    #[xmtp_common::test(unwrap_try = true)]
331    async fn stored_expiring_message_wakes_subscription_after_emit() {
332        let (filter, depth) =
333            DisappearingMessagesWorker::factory(crate::context::test_support::mock_context::<()>())
334                .event_interest()
335                .unwrap();
336        let bus = EventBus::new();
337        let subscription = bus.subscribe(filter, depth);
338        let group_id = xmtp_proto::types::GroupId::from([7; 16]);
339        bus.emit(
340            None,
341            Some(InternalEvent::MessageStored {
342                group_id,
343                message_id: vec![1],
344                expires_at_ns: Some(now_ns() + 1_000_000),
345                is_sync: false,
346            }),
347        );
348        let event =
349            xmtp_common::time::timeout(Duration::from_millis(100), subscription.next()).await?;
350        assert!(matches!(
351            event.unwrap().internal,
352            Some(InternalEvent::MessageStored {
353                expires_at_ns: Some(_),
354                ..
355            })
356        ));
357    }
358}