Skip to main content

xmtp_mls/utils/
cleanup_duplicate_updates.rs

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            // Grows while a page holds a single `sent_at_ns`, so that a tie
58            // group larger than one page still fits and the cursor can advance.
59            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                // The cursor filter is a strict `>` on `sent_at_ns`, so rows
77                // sharing the final value of a full page may continue past the
78                // page edge. Hold those back and re-read them with the next
79                // page; otherwise they are never examined and their duplicates
80                // survive the migration. A short page ends the scan and keeps
81                // every row.
82                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                    // Resume just above the last value read in full.
113                    Some(value) => {
114                        sent_after_ns = Some(value);
115                        limit = BATCH_SIZE;
116                    }
117                    // A full page of one repeated value: nothing could be
118                    // retired, so re-read it larger rather than stall.
119                    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        // Let startup cleanup finish before preparing this test's unmigrated state.
194        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        // Insert some duplicate group_updated messages
214        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        // Make sure the old update didn't get deleted. The +2 is for the 2 dummy updates.
259        assert_eq!(msgs.len(), old_updates.len() + 2);
260
261        // Let's insert another duplicate and make sure it stays this time.
262        // We don't want the perform to run more than once.
263        let msg = gen_update_msg(dm.group_id, payload1.clone());
264        msg.store(&alix.db())?;
265        perform_inner(alix.db()).await?;
266
267        // The duplicate should remain because perform will only clean up once.
268        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    /// `sent_at_ns` has no uniqueness guarantee, and the database records
276    /// `inserted_at_ns` only to the millisecond, so a burst of updates can
277    /// share one timestamp. The paging cursor advances with a strict `>`, so a
278    /// tie group straddling the page edge used to be skipped and its
279    /// duplicates survived. Put a tie group larger than one page in front of
280    /// the migration and require that every duplicate still goes.
281    #[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        // One shared timestamp for every row, so the whole run is a single tie
316        // group that cannot fit in one page.
317        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        // Exactly one of the identical updates is kept.
367        assert_eq!(msgs.len(), old_updates.len() + 1);
368    }
369}