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
17const 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 async fn run(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
122 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 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 () = xmtp_common::time::sleep(dur) => {
157 self.delete_expired_messages().await?;
158 }
159 }
160 }
161 }
162
163 #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
165 async fn delete_expired_messages(&mut self) -> Result<(), DisappearingMessagesCleanerError> {
166 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 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 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 #[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 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 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 const EXPIRY_WAIT_LIMIT: Duration = Duration::from_secs(30);
305
306 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}