1use chrono::Utc;
2use derive_builder::Builder;
3use openmls::group::GroupContext;
4use std::collections::{HashMap, HashSet};
5use xmtp_common::RetryableError;
6use xmtp_db::group_intent::IntentKind;
7use xmtp_proto::types::Cursor;
8
9use super::{GroupError, change_callbacks::AppDataChange, mls_sync::GroupMessageProcessingError};
10use xmtp_proto::types::GroupId;
11
12#[derive(Default)]
13pub struct SyncSummary {
14 pub(crate) publish_errors: Vec<GroupError>,
15 pub(crate) process: ProcessSummary,
16 pub(crate) post_commit_errors: Vec<GroupError>,
17 pub(crate) other: Option<Box<GroupError>>,
19}
20
21impl RetryableError for SyncSummary {
22 fn is_retryable(&self) -> bool {
23 self.publish_errors.iter().any(|e| e.is_retryable())
24 || self.post_commit_errors.iter().any(|e| e.is_retryable())
25 || self
26 .other
27 .as_ref()
28 .map(|s| s.is_retryable())
29 .unwrap_or(false)
30 }
31
32 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
34 self.publish_errors
35 .iter()
36 .chain(self.post_commit_errors.iter())
37 .chain(self.other.as_deref())
38 .filter_map(RetryableError::rate_limit_delay)
39 .max()
40 }
41}
42
43impl crate::worker::NeedsDbReconnect for SyncSummary {
44 fn needs_db_reconnect(&self) -> bool {
45 self.publish_errors
46 .iter()
47 .chain(self.post_commit_errors.iter())
48 .chain(self.other.as_deref())
49 .any(crate::worker::NeedsDbReconnect::needs_db_reconnect)
50 || self
51 .process
52 .errored
53 .iter()
54 .any(|(_, error)| error.needs_db_reconnect())
55 }
56}
57
58impl SyncSummary {
59 pub fn single(msg: MessageIdentifier) -> Self {
61 let mut process = ProcessSummary::default();
62 process.add(msg);
63 SyncSummary {
64 process,
65 ..Default::default()
66 }
67 }
68
69 pub fn new_message_by_id(&self, id: Cursor) -> Option<&MessageIdentifier> {
71 self.process.new_messages.iter().find(|m| m.cursor == id)
72 }
73
74 pub fn errors(&self) -> impl DoubleEndedIterator<Item = &(dyn std::error::Error + 'static)> {
78 self.other
79 .as_deref()
80 .into_iter()
81 .map(|error| error as &(dyn std::error::Error + 'static))
82 .chain(self.publish_errors.iter().map(|error| error as _))
83 .chain(self.post_commit_errors.iter().map(|error| error as _))
84 .chain(self.process.errored.iter().map(|(_, error)| error as _))
85 }
86
87 pub fn is_errored(&self) -> bool {
97 self.other.is_some()
98 || !self.publish_errors.is_empty()
99 || !self.post_commit_errors.is_empty()
100 }
101
102 pub fn add_publish_err(&mut self, e: GroupError) {
103 self.publish_errors.push(e);
104 }
105
106 pub fn add_post_commit_err(&mut self, e: GroupError) {
107 self.post_commit_errors.push(e);
108 }
109
110 pub fn add_process(&mut self, process: ProcessSummary) {
111 self.process = process;
112 }
113
114 pub fn extend(&mut self, other: SyncSummary) {
115 self.publish_errors.extend(other.publish_errors);
116 self.process.extend(other.process);
117 self.post_commit_errors.extend(other.post_commit_errors);
118 if self.other.is_none() {
123 self.other = other.other;
124 }
125 }
126
127 pub fn other(err: GroupError) -> Self {
129 Self {
130 other: Some(Box::new(err)),
131 ..Default::default()
132 }
133 }
134
135 pub fn add_other(&mut self, err: GroupError) {
136 self.other = Some(Box::new(err));
137 }
138}
139
140impl std::error::Error for SyncSummary {
141 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146 if let Some(other) = self.other.as_deref() {
147 return Some(other);
148 }
149 if let Some(err) = self.publish_errors.first() {
150 return Some(err);
151 }
152 if let Some(err) = self.post_commit_errors.first() {
153 return Some(err);
154 }
155 self.process
156 .errored
157 .first()
158 .map(|(_, e)| e as &dyn std::error::Error)
159 }
160}
161
162impl std::fmt::Debug for SyncSummary {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 writeln!(f, "{}", self)
165 }
166}
167
168impl std::fmt::Display for SyncSummary {
169 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170 if !self.is_errored() {
171 let first_new = self
172 .process
173 .new_messages
174 .iter()
175 .min_by_key(|k| k.cursor)
176 .map(|m| m.cursor);
177 write!(
178 f,
179 "synced {} messages, {} failed {} succeeded from cursor {:?}",
180 self.process.total_messages.len(),
181 self.process.errored.len(),
182 self.process.new_messages.len(),
183 first_new
184 )?;
185 if !self.process.errored.is_empty() {
186 write!(f, "{}", self.process.unique_errors())?;
187 }
188 } else {
189 writeln!(
190 f,
191 "================================= Errors Occurred During Sync ==========================="
192 )?;
193 if !self.publish_errors.is_empty() {
194 writeln!(f, "{} errors publishing intents", self.publish_errors.len())?;
195 }
196 if !self.post_commit_errors.is_empty() {
197 writeln!(f, "{} errors post commit", self.post_commit_errors.len())?;
198 for error in &self.post_commit_errors {
201 writeln!(f, " {error}")?;
202 }
203 }
204 if let Some(e) = &self.other {
205 writeln!(f, "{}", e)?;
206 }
207 writeln!(f, "{}", self.process)?;
208 writeln!(
209 f,
210 "========================================================================================"
211 )?;
212 }
213 Ok(())
214 }
215}
216
217#[derive(Clone, PartialEq, Eq, Builder)]
218#[builder(setter(into), build_fn(error = "GroupMessageProcessingError"))]
219pub struct MessageIdentifier {
220 pub cursor: Cursor,
222 pub group_id: GroupId,
223 pub created_ns: chrono::DateTime<Utc>,
224 #[builder(default = false)]
226 pub previously_processed: bool,
227 #[builder(default = None)]
229 pub internal_id: Option<Vec<u8>>,
230 #[builder(default = None)]
233 pub group_context: Option<GroupContext>,
234 #[builder(default = None)]
236 pub intent_kind: Option<IntentKind>,
237}
238
239impl MessageIdentifier {
240 pub fn builder() -> MessageIdentifierBuilder {
241 Default::default()
242 }
243}
244
245impl std::fmt::Debug for MessageIdentifier {
246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247 f.debug_struct("MessageIdentifier")
248 .field("cursor", &self.cursor)
249 .field("group_id", &xmtp_common::fmt::debug_hex(self.group_id))
250 .field("created_ns", &self.created_ns)
251 .field("internal_id", &self.internal_id)
252 .field("context", &self.group_context.as_ref().map(|g| g.epoch()))
253 .field("intent", &self.intent_kind)
254 .finish()
255 }
256}
257
258impl From<&xmtp_proto::types::GroupMessage> for MessageIdentifierBuilder {
259 fn from(value: &xmtp_proto::types::GroupMessage) -> Self {
260 MessageIdentifierBuilder {
261 cursor: Some(value.cursor),
262 group_id: Some(value.group_id),
263 created_ns: Some(value.created_ns),
264 internal_id: None,
265 group_context: None,
266 intent_kind: None,
267 previously_processed: Some(false),
268 }
269 }
270}
271
272impl From<&xmtp_proto::types::GroupMessage> for MessageIdentifier {
273 fn from(value: &xmtp_proto::types::GroupMessage) -> Self {
274 MessageIdentifier {
275 cursor: value.cursor,
276 group_id: value.group_id,
277 created_ns: value.created_ns,
278 internal_id: None,
279 group_context: None,
280 intent_kind: None,
281 previously_processed: false,
282 }
283 }
284}
285
286impl PartialOrd for MessageIdentifier {
287 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
288 self.cursor.partial_cmp(&other.cursor)
289 }
290}
291
292#[derive(Default)]
295pub struct ProcessSummary {
296 pub total_messages: HashSet<Cursor>,
297 pub new_messages: Vec<MessageIdentifier>,
298 pub errored: Vec<(Cursor, GroupMessageProcessingError)>,
299 pub app_data_changes: Vec<AppDataChange>,
305}
306
307impl std::fmt::Debug for ProcessSummary {
308 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309 writeln!(f, "{}", self)
310 }
311}
312
313pub struct ErrorSet {
314 unique: HashMap<String, Vec<Cursor>>,
316 sorted_ids: Vec<(Cursor, String)>,
318}
319
320impl std::fmt::Display for ErrorSet {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 for (error_msg, ids) in &self.unique {
323 let mut sorted_ids = ids.clone();
324 sorted_ids.sort();
325 write!(f, "\n\t┝━> {:?} {error_msg}", sorted_ids)?;
326 }
327 Ok(())
328 }
329}
330
331impl ErrorSet {
332 pub fn unique(&self) -> &HashMap<String, Vec<Cursor>> {
333 &self.unique
334 }
335
336 pub fn sorted(&self) -> &[(Cursor, String)] {
337 &self.sorted_ids
338 }
339}
340
341impl ProcessSummary {
342 pub fn add_id(&mut self, cursor: Cursor) {
343 self.total_messages.insert(cursor);
344 }
345
346 pub fn add(&mut self, message: MessageIdentifier) {
347 self.total_messages.insert(message.cursor);
348 self.new_messages.push(message);
349 }
350
351 pub fn last(&self) -> Option<Cursor> {
353 self.total_messages.iter().max().copied()
354 }
355
356 pub fn first(&self) -> Option<Cursor> {
358 self.total_messages.iter().min().copied()
359 }
360
361 pub fn total(&self) -> usize {
363 self.total_messages.len()
364 }
365
366 pub fn new_message(&self) -> usize {
368 self.new_messages.len()
369 }
370
371 pub fn first_new(&self) -> Option<Cursor> {
373 self.new_messages.iter().map(|m| m.cursor).min()
374 }
375
376 pub fn last_errored(&self) -> Option<Cursor> {
378 self.errored.iter().map(|(i, _)| *i).max()
379 }
380
381 pub fn errored(&mut self, cursor: Cursor, error: GroupMessageProcessingError) {
382 self.errored.push((cursor, error));
383 }
384
385 pub fn unique_errors(&self) -> ErrorSet {
386 let mut sorted = self
387 .errored
388 .iter()
389 .map(|(m, e)| (*m, e.to_string()))
390 .collect::<Vec<(_, String)>>();
391 sorted.sort_by_key(|(m, _)| *m);
392 let mut error_set: HashMap<String, Vec<Cursor>> = HashMap::new();
393 for (id, err) in sorted.iter().cloned() {
394 error_set.entry(err).or_default().push(id);
395 }
396 ErrorSet {
397 unique: error_set,
398 sorted_ids: sorted,
399 }
400 }
401
402 pub fn extend(&mut self, other: ProcessSummary) {
403 self.total_messages.extend(other.total_messages);
404 self.new_messages.extend(other.new_messages);
405 self.errored.extend(other.errored)
406 }
407
408 pub fn is_errored(&self) -> bool {
409 !self.errored.is_empty()
410 }
411
412 fn detailed(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
414 let error_set = self.unique_errors();
415 writeln!(
416 f,
417 "\n=========================== Processed Messages Summary ====================="
418 )?;
419 writeln!(
420 f,
421 "Processed {} total messages in cursor range [{:?} ... {:?}]",
422 self.total_messages.len(),
423 error_set.sorted_ids.first().map(|(m, _)| m),
424 error_set.sorted_ids.last().map(|(m, _)| m)
425 )?;
426 if !self.errored.is_empty() {
427 let error_ids = error_set.unique.values().flatten();
428 let min = error_ids.clone().min();
429 let max = error_ids.clone().max();
430
431 writeln!(
432 f,
433 "Failed to process [{}] messages in cursor range [{:?} ... {:?}]\n\
434 [{}] unique errors:",
435 self.errored.len(),
436 min,
437 max,
438 error_set.unique.len(),
439 )?;
440 for (err, ids) in error_set.unique.iter() {
441 writeln!(f, "{} ids errored with [{}]", ids.len(), err)?;
442 }
443 } else {
444 writeln!(f, "no errors encountered processing messages.")?;
445 }
446 let success_range = self.new_messages.iter().map(|m| m.cursor);
447 let min = success_range.clone().min();
448 let max = success_range.clone().max();
449 writeln!(
450 f,
451 "Successfully processed {} messages in range {:?} ... {:?}",
452 self.new_messages.len(),
453 min,
454 max
455 )?;
456 write!(
457 f,
458 "=============================================================================",
459 )?;
460
461 Ok(())
462 }
463}
464
465impl std::fmt::Display for ProcessSummary {
466 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467 if self.total_messages.len() > 1 {
468 self.detailed(f)?;
469 } else {
470 write!(
471 f,
472 "processed {} total messages, ",
473 self.total_messages.len()
474 )?;
475 if !self.errored.is_empty() {
476 for (cursor, message) in self.errored.iter() {
477 write!(f, "message with cursor {cursor} failed with {}", message)?;
478 }
479 }
480 if !self.new_messages.is_empty() {
481 write!(
482 f,
483 "{} new decryptable message(s) received",
484 self.new_messages.len()
485 )?;
486 }
487 }
488 Ok(())
489 }
490}
491
492#[cfg(test)]
493mod extend_tests {
494 use super::*;
495
496 #[xmtp_common::test]
501 fn extend_preserves_first_other_cause() {
502 let mut acc = SyncSummary::default();
503 acc.extend(SyncSummary::other(GroupError::GroupInactive)); acc.extend(SyncSummary::default()); let other = acc.other.as_ref().expect("first cause must survive");
507 assert_eq!(other.to_string(), GroupError::GroupInactive.to_string());
508 }
509
510 #[xmtp_common::test]
511 fn extend_takes_other_when_none_yet() {
512 let mut acc = SyncSummary::default();
513 acc.extend(SyncSummary::default()); acc.extend(SyncSummary::other(GroupError::GroupInactive)); assert!(acc.other.is_some(), "a later cause is still captured");
517 }
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use crate::groups::mls_sync::GroupMessageProcessingError;
524 use std::error::Error;
525
526 fn errored_banner(s: &SyncSummary) -> bool {
532 s.to_string().contains("Errors Occurred During Sync")
533 }
534
535 #[xmtp_common::test]
536 fn clean_summary_is_not_errored() {
537 let summary = SyncSummary::default();
538 assert!(!summary.is_errored());
539 assert!(!errored_banner(&summary));
540 assert!(summary.source().is_none());
541 }
542
543 #[xmtp_common::test]
544 fn publish_only_error_is_errored() {
545 let mut summary = SyncSummary::default();
546 summary.add_publish_err(GroupError::GroupInactive);
547 assert!(summary.is_errored());
549 assert!(errored_banner(&summary));
550 }
551
552 #[xmtp_common::test]
553 fn post_commit_only_error_is_errored() {
554 let mut summary = SyncSummary::default();
555 let error = GroupError::from(
556 crate::groups::mls_sync::publish::OutgoingPreparationError::WelcomesUnsendable,
557 );
558 let outcome = error.to_string();
559 summary.add_post_commit_err(error);
560 assert!(summary.is_errored());
561 assert!(errored_banner(&summary));
562 assert!(GroupError::from(summary).to_string().contains(&outcome));
564 }
565
566 #[xmtp_common::test]
567 fn other_error_is_errored_and_is_source() {
568 let mut summary = SyncSummary::default();
569 summary.add_other(GroupError::GroupInactive);
570 assert!(summary.is_errored());
571 let source = summary.source().expect("source should be present");
573 assert_eq!(source.to_string(), GroupError::GroupInactive.to_string());
574 }
575
576 #[xmtp_common::test]
577 fn per_message_failures_do_not_flip_errored() {
578 let mut process = ProcessSummary::default();
581 process.errored(Cursor(7u64), GroupMessageProcessingError::InvalidPayload);
582 let mut summary = SyncSummary::default();
583 summary.add_process(process);
584
585 assert!(!summary.is_errored());
586 assert!(!errored_banner(&summary));
587 let source = summary
589 .source()
590 .expect("per-message error is the fallback source");
591 assert_eq!(
592 source.to_string(),
593 GroupMessageProcessingError::InvalidPayload.to_string()
594 );
595 }
596
597 #[xmtp_common::test]
598 fn source_prefers_other_over_per_message_error() {
599 let mut process = ProcessSummary::default();
600 process.errored(Cursor(7u64), GroupMessageProcessingError::InvalidPayload);
601 let mut summary = SyncSummary::default();
602 summary.add_process(process);
603 summary.add_publish_err(GroupError::GroupInactive);
604
605 let source = summary.source().expect("source should be present");
606 assert_eq!(source.to_string(), GroupError::GroupInactive.to_string());
607 }
608}