Skip to main content

xmtp_mls/worker/
disappearing_messages.rs

1use crate::context::XmtpSharedContext;
2use crate::subscriptions::internal::InternalEvent;
3use crate::worker::{BoxedWorker, NeedsDbReconnect, Worker, WorkerFactory};
4use crate::worker::{WorkerKind, WorkerResult};
5use futures::TryFutureExt;
6use std::sync::Arc;
7use std::time::Duration;
8use thiserror::Error;
9use xmtp_common::time::now_ns;
10use xmtp_db::{StorageError, XmtpMlsStorageProvider, prelude::*};
11use xmtp_events::Subscription;
12
13/// Default cap on how long the worker parks between deadline recomputes, used
14/// when [`WorkerConfig`](crate::worker::WorkerConfig) supplies no override. With
15/// the event subscription this is a fallback when no message is scheduled.
16const FALLBACK_INTERVAL: Duration = Duration::from_secs(24 * 3600);
17
18#[derive(Debug, Error)]
19pub enum DisappearingMessagesCleanerError {
20    #[error("storage error: {0}")]
21    Storage(#[from] StorageError),
22    #[error("failed to delete expired messages: {0}")]
23    DeleteExpired(StorageError),
24}
25
26impl NeedsDbReconnect for DisappearingMessagesCleanerError {
27    fn needs_db_reconnect(&self) -> bool {
28        match self {
29            Self::Storage(s) | Self::DeleteExpired(s) => s.db_needs_connection(),
30        }
31    }
32}
33
34pub struct DisappearingMessagesWorker<Context> {
35    context: Context,
36    subscription: Option<Arc<Subscription<InternalEvent>>>,
37}
38
39struct Factory<Context> {
40    context: Context,
41}
42
43impl<Context> WorkerFactory for Factory<Context>
44where
45    Context: XmtpSharedContext + 'static,
46{
47    fn create(
48        &self,
49        metrics: Option<crate::worker::DynMetrics>,
50    ) -> (BoxedWorker, Option<crate::worker::DynMetrics>) {
51        let worker = Box::new(DisappearingMessagesWorker::new(self.context.clone())) as Box<_>;
52        (worker, metrics)
53    }
54
55    fn kind(&self) -> WorkerKind {
56        WorkerKind::DisappearingMessages
57    }
58}
59
60#[xmtp_common::async_trait]
61impl<Context> Worker for DisappearingMessagesWorker<Context>
62where
63    Context: XmtpSharedContext + 'static,
64{
65    fn kind(&self) -> WorkerKind {
66        WorkerKind::DisappearingMessages
67    }
68
69    fn set_subscription(&mut self, subscription: Arc<Subscription<InternalEvent>>) {
70        self.subscription = Some(subscription);
71    }
72
73    async fn run_tasks(&mut self) -> WorkerResult<()> {
74        self.run().map_err(|e| Box::new(e) as Box<_>).await
75    }
76
77    fn factory<C>(context: C) -> impl WorkerFactory + 'static
78    where
79        Self: Sized,
80        C: XmtpSharedContext + 'static,
81    {
82        Factory { context }
83    }
84}
85
86impl<Context> DisappearingMessagesWorker<Context>
87where
88    Context: XmtpSharedContext + 'static,
89{
90    pub fn new(context: Context) -> Self {
91        Self {
92            context,
93            subscription: None,
94        }
95    }
96}
97
98impl<Context> DisappearingMessagesWorker<Context>
99where
100    Context: XmtpSharedContext + 'static,
101{
102    /// Event-driven loop: sleep until the soonest message expiry, deleting the
103    /// batch when the deadline arrives. A stored-message fact makes it read the
104    /// next deadline again.
105    async fn run(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
106        // Resolve the fallback cap (and optional jitter) from WorkerConfig, the
107        // same knobs the other workers honor.
108        let (fallback, jitter) = self
109            .context
110            .worker_interval(WorkerKind::DisappearingMessages, FALLBACK_INTERVAL);
111        let subscription = self
112            .subscription
113            .as_ref()
114            .expect("runner installs subscription")
115            .clone();
116        loop {
117            subscription.drain();
118
119            let next = self
120                .context
121                .mls_storage()
122                .db()
123                .min_expire_at_ns()
124                .map_err(|e| DisappearingMessagesCleanerError::Storage(e.into()))?;
125            // A real expiry drives a precise deadline (no jitter — we don't want
126            // to delay actual deletions). Only the idle/fallback wake is jittered,
127            // to de-synchronize a fleet of clients booted together.
128            let dur = match next {
129                Some(expire_at) => {
130                    Duration::from_nanos((expire_at - now_ns()).max(0) as u64).min(fallback)
131                }
132                None => fallback.saturating_add(xmtp_common::time::rand_offset(jitter)),
133            };
134
135            tokio::select! {
136                event = subscription.next() => {
137                    if event.is_none() { break Ok(()); }
138                }
139                // Deadline reached (or fallback); delete whatever is now expired.
140                () = xmtp_common::time::sleep(dur) => {
141                    self.delete_expired_messages().await?;
142                }
143            }
144        }
145    }
146
147    /// Iterate on the list of groups and delete expired messages
148    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
149    async fn delete_expired_messages(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
150        let deleted_messages = crate::state_tx::state_write_with_events(
151            self.context.mls_storage(),
152            self.context.events(),
153            |tx, events| {
154                let storage = tx.storage();
155                let db = storage.db();
156                let deleted = db.delete_expired_messages()?;
157                crate::subscriptions::internal::emit_expired_messages(
158                    events,
159                    deleted.clone(),
160                    &db,
161                )?;
162                Ok::<_, StorageError>(xmtp_db::TransactionOutcome::Continue(deleted))
163            },
164        )
165        .map_err(DisappearingMessagesCleanerError::DeleteExpired)?
166        .into_continued();
167
168        if !deleted_messages.is_empty() {
169            tracing::info!(
170                "Successfully deleted {} expired messages",
171                deleted_messages.len()
172            );
173        }
174
175        Ok(())
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::messages::decoded_message::{DecodedMessage, DeletedBy, MessageBody};
183    use crate::tester;
184    use xmtp_db::group_message::StoredGroupMessage;
185    use xmtp_events::{ClientEvent, EventBus, EventFilter, EventKind, EventWriter};
186    use xmtp_mls_common::group::GroupMetadataOptions;
187    use xmtp_mls_common::group_mutable_metadata::MessageDisappearingSettings;
188    use xmtp_proto::types::GroupId;
189
190    // verifies: EVENT-001
191    #[xmtp_common::test(unwrap_try = true)]
192    async fn expired_message_worker_emits_once_after_deletion() {
193        tester!(alix, disable_workers);
194        let group = alix.create_group(
195            None,
196            Some(GroupMetadataOptions {
197                message_disappearing_settings: Some(MessageDisappearingSettings::new(1, 1)),
198                ..Default::default()
199            }),
200        )?;
201        let events = alix
202            .context
203            .events()
204            .subscribe_app(EventFilter::new([EventKind::MessageExpired]))?;
205        let message_id = group.send_message(b"expires", Default::default()).await?;
206        wait_until_expired(&alix.context, &message_id).await;
207        let db = alix.context.db();
208
209        let mut worker = DisappearingMessagesWorker::new(alix.context.clone());
210        worker.delete_expired_messages().await?;
211        assert!(db.get_group_message(&message_id)?.is_none());
212        assert!(matches!(
213            events.drain().as_slice(),
214            [xmtp_events::EventEnvelope {
215                client: Some(ClientEvent::MessageExpired(expired)), ..
216            }] if expired.group_id == group.group_id.to_vec()
217                && expired.message_id == message_id
218        ));
219        worker.delete_expired_messages().await?;
220        assert!(events.drain().is_empty());
221    }
222
223    /// The longest a test waits for a stored expiry.
224    const EXPIRY_WAIT_LIMIT: Duration = Duration::from_secs(30);
225
226    /// Wait until the stored expiry of `id` has passed. Confirmation replaces
227    /// the local send time with the backend's, so the stored value can be
228    /// later than the local clock. A skewed backend clock fails the test
229    /// instead of hanging it.
230    async fn wait_until_expired(context: &impl XmtpSharedContext, id: &[u8]) {
231        let expire_at = context
232            .db()
233            .get_group_message(id)
234            .unwrap()
235            .and_then(|message| message.expire_at_ns)
236            .expect("an expiring message");
237        let now = now_ns();
238        let wait = Duration::from_nanos(expire_at.saturating_sub(now).max(0) as u64);
239        assert!(
240            wait <= EXPIRY_WAIT_LIMIT,
241            "stored expire_at_ns {expire_at} is {wait:?} after now_ns {now}, more than \
242             {EXPIRY_WAIT_LIMIT:?}; the backend clock is probably ahead of the local clock"
243        );
244        while now_ns() < expire_at {
245            let wait = (expire_at - now_ns()).max(0) as u64;
246            xmtp_common::time::sleep(Duration::from_nanos(wait) + Duration::from_millis(1)).await;
247        }
248    }
249
250    /// A deletion item for an expired message is the deleted-message
251    /// placeholder, so it carries no body and no host decodes an empty one.
252    fn assert_no_body(
253        deleted: &DecodedMessage,
254        group_id: &GroupId,
255        message_id: &[u8],
256        sender_inbox_id: &str,
257        deleted_by: &DeletedBy,
258    ) {
259        use crate::messages::enrichment::deleted_message_content_type;
260
261        assert_eq!(deleted.metadata.group_id, *group_id);
262        assert_eq!(deleted.metadata.id, message_id);
263        assert_eq!(deleted.metadata.sender_inbox_id, sender_inbox_id);
264        assert_eq!(
265            deleted.metadata.content_type,
266            Some(deleted_message_content_type()),
267            "the deletion item has no content type"
268        );
269        match &deleted.content {
270            MessageBody::DeletedMessage { deleted_by: actual } => {
271                assert_eq!(
272                    actual, deleted_by,
273                    "the deletion item names the wrong actor"
274                )
275            }
276            other => panic!("the deletion item carried a body: {other:?}"),
277        }
278        assert_eq!(deleted.fallback_text, None);
279    }
280
281    /// The deletion stream of `context`, not polled until the test reads it.
282    fn deletion_stream(
283        context: &impl XmtpSharedContext,
284    ) -> impl futures::Stream<Item = crate::subscriptions::Result<DecodedMessage>> {
285        use crate::subscriptions::StreamMessages;
286        context
287            .events()
288            .subscribe(
289                EventFilter::default().with_internal(InternalEvent::is_message_deletion),
290                Some(8),
291            )
292            .stream_message_deletions()
293    }
294
295    /// Encoded text with a fallback, so a test can see whether either leaks.
296    fn secret_text() -> Vec<u8> {
297        use xmtp_content_types::{ContentCodec, encoded_content_to_bytes, text::TextCodec};
298        let mut secret = TextCodec::encode("secret".into()).unwrap();
299        secret.fallback = Some("secret fallback".into());
300        encoded_content_to_bytes(secret)
301    }
302
303    /// The three ways an expired message leaves the database: expiry cleanup,
304    /// a group delete, and a local client delete.
305    #[derive(Clone, Copy, Debug)]
306    enum Removal {
307        Cleanup,
308        GroupDelete,
309        ClientDelete,
310    }
311
312    /// Every deletion of an expired message reports which message was
313    /// deleted, not what it said.
314    // verifies: META-051
315    #[rstest::rstest]
316    #[case::cleanup(Removal::Cleanup)]
317    #[case::group_delete(Removal::GroupDelete)]
318    #[case::client_delete(Removal::ClientDelete)]
319    #[xmtp_common::test(unwrap_try = true)]
320    async fn expired_message_deletion_event_carries_no_body(
321        #[case] removal: Removal,
322    ) -> Result<(), Box<dyn std::error::Error>> {
323        use futures::StreamExt;
324
325        tester!(alix, disable_workers);
326        let group = alix.create_group(
327            None,
328            Some(GroupMetadataOptions {
329                message_disappearing_settings: Some(MessageDisappearingSettings::new(1, 1)),
330                ..Default::default()
331            }),
332        )?;
333        let deletions = deletion_stream(&alix.context);
334        futures::pin_mut!(deletions);
335        let message_id = group
336            .send_message(&secret_text(), Default::default())
337            .await?;
338        wait_until_expired(&alix.context, &message_id).await;
339
340        match removal {
341            Removal::Cleanup => {
342                DisappearingMessagesWorker::new(alix.context.clone())
343                    .delete_expired_messages()
344                    .await?;
345            }
346            Removal::GroupDelete => {
347                group.delete_message(message_id.clone())?;
348            }
349            Removal::ClientDelete => {
350                assert_eq!(alix.delete_message(message_id.clone())?, 1);
351            }
352        }
353        let deleted = xmtp_common::time::timeout(Duration::from_secs(5), deletions.next())
354            .await?
355            .expect("a deletion item")?;
356        assert_no_body(
357            &deleted,
358            &group.group_id,
359            &message_id,
360            alix.inbox_id(),
361            &DeletedBy::Sender,
362        );
363        Ok(())
364    }
365
366    /// A deletion item can wait in the broadcast buffer until after its
367    /// message expired. It must not deliver the body then. The item is live
368    /// when emitted and its row is expired when read, with no wait.
369    // verifies: META-051
370    #[xmtp_common::test(unwrap_try = true)]
371    async fn deletion_that_expires_before_delivery_carries_no_body() {
372        use crate::subscriptions::{StreamMessages, internal::DeletedMessages};
373        use futures::StreamExt;
374        use xmtp_db::group_message::{ContentType, DeliveryStatus, GroupMessageKind};
375
376        let bus = EventBus::new();
377        let deletions = bus
378            .subscribe(
379                EventFilter::default().with_internal(InternalEvent::is_message_deletion),
380                Some(8),
381            )
382            .stream_message_deletions();
383        futures::pin_mut!(deletions);
384        let group_id = GroupId::from([9; 16]);
385        let message_id = vec![0xE1, 0x05];
386        let sender_inbox_id = "sender";
387        let row = StoredGroupMessage {
388            id: message_id.clone(),
389            group_id,
390            decrypted_message_bytes: secret_text(),
391            sent_at_ns: now_ns() - 2,
392            kind: GroupMessageKind::Application,
393            sender_installation_id: vec![1, 2, 3],
394            sender_inbox_id: sender_inbox_id.into(),
395            delivery_status: DeliveryStatus::Published,
396            content_type: ContentType::Text,
397            version_major: 1,
398            version_minor: 0,
399            authority_id: "xmtp.org".into(),
400            reference_id: None,
401            expire_at_ns: Some(now_ns() - 1),
402            sequence_id: 1,
403            envelope_hash: None,
404            expiry_ns: None,
405            inserted_at_ns: 0,
406            should_push: false,
407            idempotency_key: String::new(),
408        };
409        bus.emit(
410            None,
411            Some(InternalEvent::MessagesDeleted(DeletedMessages {
412                messages: vec![row],
413                deleted_by_inbox_id: Some(sender_inbox_id.into()),
414            })),
415        );
416
417        let deleted = xmtp_common::time::timeout(Duration::from_secs(5), deletions.next())
418            .await?
419            .expect("a deletion item")?;
420        assert_no_body(
421            &deleted,
422            &group_id,
423            &message_id,
424            sender_inbox_id,
425            &DeletedBy::Sender,
426        );
427    }
428
429    /// Where a super admin's delete of an expired message is observed.
430    #[derive(Clone, Copy, Debug)]
431    enum Observer {
432        /// The admin's own installation, which deletes locally.
433        Admin,
434        /// The sender's installation, which receives the delete.
435        Sender,
436    }
437
438    /// A super admin's delete of an expired message names the admin, on the
439    /// deleting installation and on a receiving one.
440    // verifies: META-051
441    #[rstest::rstest]
442    #[case::local(Observer::Admin)]
443    #[case::remote(Observer::Sender)]
444    #[xmtp_common::test(unwrap_try = true)]
445    async fn admin_delete_of_expired_message_names_the_admin(
446        #[case] observer: Observer,
447    ) -> Result<(), Box<dyn std::error::Error>> {
448        use futures::StreamExt;
449
450        tester!(alix, disable_workers);
451        tester!(bo, disable_workers);
452        let alix_group = alix.create_group(
453            None,
454            Some(GroupMetadataOptions {
455                message_disappearing_settings: Some(MessageDisappearingSettings::new(1, 1)),
456                ..Default::default()
457            }),
458        )?;
459        alix_group.add_members(&[bo.inbox_id()]).await?;
460        bo.sync_welcomes().await?;
461        let bo_group = bo.group(&alix_group.group_id)?;
462        let deletions = match observer {
463            Observer::Admin => deletion_stream(&alix.context).left_stream(),
464            Observer::Sender => deletion_stream(&bo.context).right_stream(),
465        };
466        futures::pin_mut!(deletions);
467
468        let message_id = bo_group
469            .send_message(&secret_text(), Default::default())
470            .await?;
471        alix_group.sync().await?;
472        wait_until_expired(&alix.context, &message_id).await;
473        wait_until_expired(&bo.context, &message_id).await;
474        alix_group.delete_message(message_id.clone())?;
475        if let Observer::Sender = observer {
476            alix_group.publish_messages().await?;
477            bo_group.sync().await?;
478        }
479
480        let deleted = xmtp_common::time::timeout(Duration::from_secs(5), deletions.next())
481            .await?
482            .expect("a deletion item")?;
483        assert_no_body(
484            &deleted,
485            &alix_group.group_id,
486            &message_id,
487            bo.inbox_id(),
488            &DeletedBy::Admin(alix.inbox_id().to_string()),
489        );
490        Ok(())
491    }
492
493    #[xmtp_common::test(unwrap_try = true)]
494    async fn stored_expiring_message_wakes_subscription_after_emit() {
495        let (filter, depth) =
496            crate::worker::worker_event_filter(WorkerKind::DisappearingMessages).unwrap();
497        let bus = EventBus::new();
498        let subscription = bus.subscribe(filter, depth);
499        let group_id = xmtp_proto::types::GroupId::from([7; 16]);
500        bus.emit(
501            None,
502            Some(InternalEvent::MessageStored {
503                group_id,
504                message_id: vec![1],
505                expires_at_ns: Some(now_ns() + 1_000_000),
506                is_sync: false,
507            }),
508        );
509        let event =
510            xmtp_common::time::timeout(Duration::from_millis(100), subscription.next()).await?;
511        assert!(matches!(
512            event.unwrap().internal,
513            Some(InternalEvent::MessageStored {
514                expires_at_ns: Some(_),
515                ..
516            })
517        ));
518    }
519}