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
13const 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 async fn run(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
106 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 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 () = xmtp_common::time::sleep(dur) => {
141 self.delete_expired_messages().await?;
142 }
143 }
144 }
145 }
146
147 #[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 #[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 const EXPIRY_WAIT_LIMIT: Duration = Duration::from_secs(30);
225
226 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 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 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 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 #[derive(Clone, Copy, Debug)]
306 enum Removal {
307 Cleanup,
308 GroupDelete,
309 ClientDelete,
310 }
311
312 #[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 #[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 #[derive(Clone, Copy, Debug)]
431 enum Observer {
432 Admin,
434 Sender,
436 }
437
438 #[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}