1use std::collections::HashSet;
2use std::hash::{DefaultHasher, Hash, Hasher};
3
4use tracing::info;
5use xmtp_db::diesel::prelude::*;
6use xmtp_db::user_preferences::StoredUserPreferences;
7use xmtp_db::{ConnectionExt, DbConnection};
8use xmtp_db::{
9 group::{ConversationType, GroupQueryArgs, QueryGroup},
10 group_message::{ContentType, MsgQueryArgs},
11 prelude::QueryGroupMessage,
12};
13
14use crate::groups::mls_sync::GroupMessageProcessingError;
15use crate::messages::decoded_message::MessageBody;
16use crate::messages::enrichment::enrich_messages;
17
18const BATCH_SIZE: i64 = 100;
19
20pub async fn perform<C>(db: DbConnection<C>)
21where
22 C: ConnectionExt,
23{
24 if let Err(err) = perform_inner(db).await {
25 tracing::error!("Duplicate cleanup task failed: {err:?}");
26 }
27}
28
29async fn perform_inner<C>(db: DbConnection<C>) -> Result<(), GroupMessageProcessingError>
30where
31 C: ConnectionExt,
32{
33 let prefs = StoredUserPreferences::load(&db)?;
34 if prefs.dm_group_updates_migrated {
35 info!("DM group updates migration has already been performed. Skipping.");
36 return Ok(());
37 }
38
39 let mut group_offset = 0;
40 let mut groups;
41 loop {
42 groups = db.find_groups_by_id_paged(
43 GroupQueryArgs {
44 conversation_type: Some(ConversationType::Dm),
45 limit: Some(BATCH_SIZE),
46 ..Default::default()
47 },
48 group_offset,
49 )?;
50
51 if groups.is_empty() {
52 break;
53 }
54
55 for group in groups {
56 let mut sent_after_ns = None;
57 let mut limit = BATCH_SIZE;
60 let mut originals: HashSet<u64> = HashSet::default();
61
62 loop {
63 let msgs = db.get_group_messages(
64 &group.id,
65 &MsgQueryArgs {
66 content_types: Some(vec![ContentType::GroupUpdated]),
67 sent_after_ns,
68 limit: Some(limit),
69 ..Default::default()
70 },
71 )?;
72
73 let full_page = msgs.len() >= limit as usize;
74 let last_value = msgs.last().map(|msg| msg.sent_at_ns);
75
76 let cutoff = last_value.filter(|_| full_page);
83 let msgs: Vec<_> = msgs
84 .into_iter()
85 .filter(|msg| Some(msg.sent_at_ns) != cutoff)
86 .collect();
87
88 let highest_consumed = msgs.last().map(|msg| msg.sent_at_ns);
89 let msgs = enrich_messages(&db, &group.id, msgs)?;
90
91 for msg in msgs {
92 let MessageBody::GroupUpdated(update) = msg.content else {
93 continue;
94 };
95
96 let mut hasher = DefaultHasher::new();
97 update.hash(&mut hasher);
98 if originals.insert(hasher.finish()) {
99 continue;
100 }
101
102 db.raw_query(|conn| {
103 xmtp_db::diesel::delete(xmtp_db::schema::group_messages::table)
104 .filter(xmtp_db::schema::group_messages::id.eq(&msg.metadata.id))
105 .execute(conn)
106 })?;
107
108 tokio::task::yield_now().await;
109 }
110
111 match highest_consumed {
112 Some(value) => {
114 sent_after_ns = Some(value);
115 limit = BATCH_SIZE;
116 }
117 None if full_page => limit = limit.saturating_mul(2),
120 None => break,
121 }
122 }
123
124 tokio::task::yield_now().await;
125 }
126
127 group_offset += BATCH_SIZE;
128 }
129
130 db.raw_query(|conn| {
131 xmtp_db::diesel::update(xmtp_db::schema::user_preferences::table)
132 .set(xmtp_db::schema::user_preferences::dm_group_updates_migrated.eq(true))
133 .execute(conn)
134 })?;
135
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use crate::tester;
142 use prost::Message;
143
144 use super::*;
145 use xmtp_common::{rand_vec, time::now_ns};
146 use xmtp_content_types::{ContentCodec, encryption::sha256, group_updated::GroupUpdatedCodec};
147 use xmtp_db::{
148 Store,
149 group_message::{DeliveryStatus, GroupMessageKind, StoredGroupMessage},
150 };
151 use xmtp_proto::xmtp::mls::message_contents::{
152 GroupUpdated,
153 group_updated::{Inbox, MetadataFieldChange},
154 };
155
156 #[xmtp_common::test(unwrap_try = true)]
157 async fn test_cleanup_works_as_expected() {
158 tester!(alix);
159 tester!(bo);
160 let mut sequence_id = 0;
161
162 let mut gen_update_msg = |group_id, payload| {
163 let mut encoded_payload = Vec::new();
164 GroupUpdatedCodec::encode(payload)?.encode(&mut encoded_payload)?;
165 sequence_id += 1;
166
167 StoredGroupMessage {
168 id: sha256(&rand_vec::<12>()),
169 group_id,
170 decrypted_message_bytes: encoded_payload,
171 sent_at_ns: now_ns(),
172 kind: GroupMessageKind::MembershipChange,
173 sender_installation_id: vec![1, 2, 3],
174 sender_inbox_id: "123".to_string(),
175 delivery_status: DeliveryStatus::Published,
176 content_type: ContentType::GroupUpdated,
177 version_major: 0,
178 version_minor: 0,
179 authority_id: "unknown".to_string(),
180 reference_id: None,
181 sequence_id,
182 envelope_hash: None,
183 expiry_ns: None,
184 expire_at_ns: None,
185 inserted_at_ns: 0,
186 should_push: true,
187 idempotency_key: String::new(),
188 }
189 };
190
191 let (dm, _) = alix.test_talk_in_dm_with(&bo).await?;
192 dm.sync().await?;
193 xmtp_common::wait_for_eq(
195 || async {
196 StoredUserPreferences::load(alix.db())
197 .unwrap()
198 .dm_group_updates_migrated
199 },
200 true,
201 )
202 .await?;
203 alix.db().raw_query(|conn| {
204 xmtp_db::diesel::update(xmtp_db::schema::user_preferences::table)
205 .set(xmtp_db::schema::user_preferences::dm_group_updates_migrated.eq(false))
206 .execute(conn)
207 })?;
208 let old_updates = dm.find_messages_v2(&MsgQueryArgs {
209 content_types: Some(vec![ContentType::GroupUpdated]),
210 ..Default::default()
211 })?;
212
213 let payload1 = GroupUpdated {
215 added_inboxes: vec![Inbox {
216 inbox_id: "123".to_string(),
217 }],
218 ..Default::default()
219 };
220 let payload2 = GroupUpdated {
221 metadata_field_changes: vec![MetadataFieldChange {
222 field_name: "expiration_setting".to_string(),
223 old_value: None,
224 new_value: Some("123".to_string()),
225 }],
226
227 ..Default::default()
228 };
229
230 let mut duplicates = vec![];
231
232 for i in 0..3 {
233 let msg1 = gen_update_msg(dm.group_id, payload1.clone());
234 msg1.store(&alix.db())?;
235 let msg2 = gen_update_msg(dm.group_id, payload2.clone());
236 msg2.store(&alix.db())?;
237
238 if i > 0 {
239 duplicates.push(msg1.id);
240 duplicates.push(msg2.id);
241 }
242 }
243
244 perform_inner(alix.db()).await?;
245
246 let msgs = dm.find_messages_v2(&MsgQueryArgs {
247 content_types: Some(vec![ContentType::GroupUpdated]),
248 ..Default::default()
249 })?;
250
251 for msg in &msgs {
252 assert!(
253 !duplicates.contains(&msg.metadata.id),
254 "A duplicate has remained in the db {:?}",
255 msg.metadata.id
256 );
257 }
258 assert_eq!(msgs.len(), old_updates.len() + 2);
260
261 let msg = gen_update_msg(dm.group_id, payload1.clone());
264 msg.store(&alix.db())?;
265 perform_inner(alix.db()).await?;
266
267 let msgs = dm.find_messages_v2(&MsgQueryArgs {
269 content_types: Some(vec![ContentType::GroupUpdated]),
270 ..Default::default()
271 })?;
272 assert!(msgs.iter().any(|m| m.metadata.id == msg.id));
273 }
274
275 #[xmtp_common::test(unwrap_try = true)]
282 async fn test_cleanup_spans_messages_sharing_one_timestamp() {
283 tester!(alix);
284 tester!(bo);
285
286 let (dm, _) = alix.test_talk_in_dm_with(&bo).await?;
287 dm.sync().await?;
288 xmtp_common::wait_for_eq(
289 || async {
290 StoredUserPreferences::load(alix.db())
291 .unwrap()
292 .dm_group_updates_migrated
293 },
294 true,
295 )
296 .await?;
297 alix.db().raw_query(|conn| {
298 xmtp_db::diesel::update(xmtp_db::schema::user_preferences::table)
299 .set(xmtp_db::schema::user_preferences::dm_group_updates_migrated.eq(false))
300 .execute(conn)
301 })?;
302
303 let old_updates = dm.find_messages_v2(&MsgQueryArgs {
304 content_types: Some(vec![ContentType::GroupUpdated]),
305 ..Default::default()
306 })?;
307
308 let payload = GroupUpdated {
309 added_inboxes: vec![Inbox {
310 inbox_id: "tied".to_string(),
311 }],
312 ..Default::default()
313 };
314
315 let shared_sent_at_ns = now_ns();
318 let total = (BATCH_SIZE as usize) + 25;
319 let mut encoded_payload = Vec::new();
320 GroupUpdatedCodec::encode(payload.clone())?.encode(&mut encoded_payload)?;
321
322 let mut duplicates = vec![];
323 for i in 0..total {
324 let msg = StoredGroupMessage {
325 id: sha256(&rand_vec::<12>()),
326 group_id: dm.group_id,
327 decrypted_message_bytes: encoded_payload.clone(),
328 sent_at_ns: shared_sent_at_ns,
329 kind: GroupMessageKind::MembershipChange,
330 sender_installation_id: vec![1, 2, 3],
331 sender_inbox_id: "123".to_string(),
332 delivery_status: DeliveryStatus::Published,
333 content_type: ContentType::GroupUpdated,
334 version_major: 0,
335 version_minor: 0,
336 authority_id: "unknown".to_string(),
337 reference_id: None,
338 sequence_id: i as i64 + 1,
339 envelope_hash: None,
340 expiry_ns: None,
341 expire_at_ns: None,
342 inserted_at_ns: 0,
343 should_push: true,
344 idempotency_key: String::new(),
345 };
346 msg.store(&alix.db())?;
347 if i > 0 {
348 duplicates.push(msg.id);
349 }
350 }
351
352 perform_inner(alix.db()).await?;
353
354 let msgs = dm.find_messages_v2(&MsgQueryArgs {
355 content_types: Some(vec![ContentType::GroupUpdated]),
356 ..Default::default()
357 })?;
358
359 for msg in &msgs {
360 assert!(
361 !duplicates.contains(&msg.metadata.id),
362 "a duplicate past the page boundary survived: {:?}",
363 msg.metadata.id
364 );
365 }
366 assert_eq!(msgs.len(), old_updates.len() + 1);
368 }
369}