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
33impl crate::worker::NeedsDbReconnect for SyncSummary {
34 fn needs_db_reconnect(&self) -> bool {
35 self.publish_errors
36 .iter()
37 .chain(self.post_commit_errors.iter())
38 .chain(self.other.as_deref())
39 .any(crate::worker::NeedsDbReconnect::needs_db_reconnect)
40 || self
41 .process
42 .errored
43 .iter()
44 .any(|(_, error)| error.needs_db_reconnect())
45 }
46}
47
48impl SyncSummary {
49 pub fn single(msg: MessageIdentifier) -> Self {
51 let mut process = ProcessSummary::default();
52 process.add(msg);
53 SyncSummary {
54 process,
55 ..Default::default()
56 }
57 }
58
59 pub fn new_message_by_id(&self, id: Cursor) -> Option<&MessageIdentifier> {
61 self.process.new_messages.iter().find(|m| m.cursor == id)
62 }
63
64 pub fn is_errored(&self) -> bool {
74 self.other.is_some()
75 || !self.publish_errors.is_empty()
76 || !self.post_commit_errors.is_empty()
77 }
78
79 pub fn add_publish_err(&mut self, e: GroupError) {
80 self.publish_errors.push(e);
81 }
82
83 pub fn add_post_commit_err(&mut self, e: GroupError) {
84 self.post_commit_errors.push(e);
85 }
86
87 pub fn add_process(&mut self, process: ProcessSummary) {
88 self.process = process;
89 }
90
91 pub fn extend(&mut self, other: SyncSummary) {
92 self.publish_errors.extend(other.publish_errors);
93 self.process.extend(other.process);
94 self.post_commit_errors.extend(other.post_commit_errors);
95 if self.other.is_none() {
100 self.other = other.other;
101 }
102 }
103
104 pub fn other(err: GroupError) -> Self {
106 Self {
107 other: Some(Box::new(err)),
108 ..Default::default()
109 }
110 }
111
112 pub fn add_other(&mut self, err: GroupError) {
113 self.other = Some(Box::new(err));
114 }
115}
116
117impl std::error::Error for SyncSummary {
118 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
123 if let Some(other) = self.other.as_deref() {
124 return Some(other);
125 }
126 if let Some(err) = self.publish_errors.first() {
127 return Some(err);
128 }
129 if let Some(err) = self.post_commit_errors.first() {
130 return Some(err);
131 }
132 self.process
133 .errored
134 .first()
135 .map(|(_, e)| e as &dyn std::error::Error)
136 }
137}
138
139impl std::fmt::Debug for SyncSummary {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 writeln!(f, "{}", self)
142 }
143}
144
145impl std::fmt::Display for SyncSummary {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 if !self.is_errored() {
148 let first_new = self
149 .process
150 .new_messages
151 .iter()
152 .min_by_key(|k| k.cursor)
153 .map(|m| m.cursor);
154 write!(
155 f,
156 "synced {} messages, {} failed {} succeeded from cursor {:?}",
157 self.process.total_messages.len(),
158 self.process.errored.len(),
159 self.process.new_messages.len(),
160 first_new
161 )?;
162 if !self.process.errored.is_empty() {
163 write!(f, "{}", self.process.unique_errors())?;
164 }
165 } else {
166 writeln!(
167 f,
168 "================================= Errors Occurred During Sync ==========================="
169 )?;
170 if !self.publish_errors.is_empty() {
171 writeln!(f, "{} errors publishing intents", self.publish_errors.len())?;
172 }
173 if !self.post_commit_errors.is_empty() {
174 writeln!(f, "{} errors post commit", self.post_commit_errors.len())?;
175 }
176 if let Some(e) = &self.other {
177 writeln!(f, "{}", e)?;
178 }
179 writeln!(f, "{}", self.process)?;
180 writeln!(
181 f,
182 "========================================================================================"
183 )?;
184 }
185 Ok(())
186 }
187}
188
189#[derive(Clone, PartialEq, Eq, Builder)]
190#[builder(setter(into), build_fn(error = "GroupMessageProcessingError"))]
191pub struct MessageIdentifier {
192 pub cursor: Cursor,
194 pub group_id: GroupId,
195 pub created_ns: chrono::DateTime<Utc>,
196 #[builder(default = false)]
198 pub previously_processed: bool,
199 #[builder(default = None)]
201 pub internal_id: Option<Vec<u8>>,
202 #[builder(default = None)]
205 pub group_context: Option<GroupContext>,
206 #[builder(default = None)]
208 pub intent_kind: Option<IntentKind>,
209}
210
211impl MessageIdentifier {
212 pub fn builder() -> MessageIdentifierBuilder {
213 Default::default()
214 }
215}
216
217impl std::fmt::Debug for MessageIdentifier {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 f.debug_struct("MessageIdentifier")
220 .field("cursor", &self.cursor)
221 .field("group_id", &xmtp_common::fmt::debug_hex(self.group_id))
222 .field("created_ns", &self.created_ns)
223 .field("internal_id", &self.internal_id)
224 .field("context", &self.group_context.as_ref().map(|g| g.epoch()))
225 .field("intent", &self.intent_kind)
226 .finish()
227 }
228}
229
230impl From<&xmtp_proto::types::GroupMessage> for MessageIdentifierBuilder {
231 fn from(value: &xmtp_proto::types::GroupMessage) -> Self {
232 MessageIdentifierBuilder {
233 cursor: Some(value.cursor),
234 group_id: Some(value.group_id),
235 created_ns: Some(value.created_ns),
236 internal_id: None,
237 group_context: None,
238 intent_kind: None,
239 previously_processed: Some(false),
240 }
241 }
242}
243
244impl From<&xmtp_proto::types::GroupMessage> for MessageIdentifier {
245 fn from(value: &xmtp_proto::types::GroupMessage) -> Self {
246 MessageIdentifier {
247 cursor: value.cursor,
248 group_id: value.group_id,
249 created_ns: value.created_ns,
250 internal_id: None,
251 group_context: None,
252 intent_kind: None,
253 previously_processed: false,
254 }
255 }
256}
257
258impl PartialOrd for MessageIdentifier {
259 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
260 self.cursor.partial_cmp(&other.cursor)
261 }
262}
263
264#[derive(Default)]
267pub struct ProcessSummary {
268 pub total_messages: HashSet<Cursor>,
269 pub new_messages: Vec<MessageIdentifier>,
270 pub errored: Vec<(Cursor, GroupMessageProcessingError)>,
271 pub app_data_changes: Vec<AppDataChange>,
277}
278
279impl std::fmt::Debug for ProcessSummary {
280 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281 writeln!(f, "{}", self)
282 }
283}
284
285pub struct ErrorSet {
286 unique: HashMap<String, Vec<Cursor>>,
288 sorted_ids: Vec<(Cursor, String)>,
290}
291
292impl std::fmt::Display for ErrorSet {
293 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
294 for (error_msg, ids) in &self.unique {
295 let mut sorted_ids = ids.clone();
296 sorted_ids.sort();
297 write!(f, "\n\t┝━> {:?} {error_msg}", sorted_ids)?;
298 }
299 Ok(())
300 }
301}
302
303impl ErrorSet {
304 pub fn unique(&self) -> &HashMap<String, Vec<Cursor>> {
305 &self.unique
306 }
307
308 pub fn sorted(&self) -> &[(Cursor, String)] {
309 &self.sorted_ids
310 }
311}
312
313impl ProcessSummary {
314 pub fn add_id(&mut self, cursor: Cursor) {
315 self.total_messages.insert(cursor);
316 }
317
318 pub fn add(&mut self, message: MessageIdentifier) {
319 self.total_messages.insert(message.cursor);
320 self.new_messages.push(message);
321 }
322
323 pub fn last(&self) -> Option<Cursor> {
325 self.total_messages.iter().max().copied()
326 }
327
328 pub fn first(&self) -> Option<Cursor> {
330 self.total_messages.iter().min().copied()
331 }
332
333 pub fn total(&self) -> usize {
335 self.total_messages.len()
336 }
337
338 pub fn new_message(&self) -> usize {
340 self.new_messages.len()
341 }
342
343 pub fn first_new(&self) -> Option<Cursor> {
345 self.new_messages.iter().map(|m| m.cursor).min()
346 }
347
348 pub fn last_errored(&self) -> Option<Cursor> {
350 self.errored.iter().map(|(i, _)| *i).max()
351 }
352
353 pub fn errored(&mut self, cursor: Cursor, error: GroupMessageProcessingError) {
354 self.errored.push((cursor, error));
355 }
356
357 pub fn unique_errors(&self) -> ErrorSet {
358 let mut sorted = self
359 .errored
360 .iter()
361 .map(|(m, e)| (*m, e.to_string()))
362 .collect::<Vec<(_, String)>>();
363 sorted.sort_by_key(|(m, _)| *m);
364 let mut error_set: HashMap<String, Vec<Cursor>> = HashMap::new();
365 for (id, err) in sorted.iter().cloned() {
366 error_set.entry(err).or_default().push(id);
367 }
368 ErrorSet {
369 unique: error_set,
370 sorted_ids: sorted,
371 }
372 }
373
374 pub fn extend(&mut self, other: ProcessSummary) {
375 self.total_messages.extend(other.total_messages);
376 self.new_messages.extend(other.new_messages);
377 self.errored.extend(other.errored)
378 }
379
380 pub fn is_errored(&self) -> bool {
381 !self.errored.is_empty()
382 }
383
384 fn detailed(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 let error_set = self.unique_errors();
387 writeln!(
388 f,
389 "\n=========================== Processed Messages Summary ====================="
390 )?;
391 writeln!(
392 f,
393 "Processed {} total messages in cursor range [{:?} ... {:?}]",
394 self.total_messages.len(),
395 error_set.sorted_ids.first().map(|(m, _)| m),
396 error_set.sorted_ids.last().map(|(m, _)| m)
397 )?;
398 if !self.errored.is_empty() {
399 let error_ids = error_set.unique.values().flatten();
400 let min = error_ids.clone().min();
401 let max = error_ids.clone().max();
402
403 writeln!(
404 f,
405 "Failed to process [{}] messages in cursor range [{:?} ... {:?}]\n\
406 [{}] unique errors:",
407 self.errored.len(),
408 min,
409 max,
410 error_set.unique.len(),
411 )?;
412 for (err, ids) in error_set.unique.iter() {
413 writeln!(f, "{} ids errored with [{}]", ids.len(), err)?;
414 }
415 } else {
416 writeln!(f, "no errors encountered processing messages.")?;
417 }
418 let success_range = self.new_messages.iter().map(|m| m.cursor);
419 let min = success_range.clone().min();
420 let max = success_range.clone().max();
421 writeln!(
422 f,
423 "Successfully processed {} messages in range {:?} ... {:?}",
424 self.new_messages.len(),
425 min,
426 max
427 )?;
428 write!(
429 f,
430 "=============================================================================",
431 )?;
432
433 Ok(())
434 }
435}
436
437impl std::fmt::Display for ProcessSummary {
438 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
439 if self.total_messages.len() > 1 {
440 self.detailed(f)?;
441 } else {
442 write!(
443 f,
444 "processed {} total messages, ",
445 self.total_messages.len()
446 )?;
447 if !self.errored.is_empty() {
448 for (cursor, message) in self.errored.iter() {
449 write!(f, "message with cursor {cursor} failed with {}", message)?;
450 }
451 }
452 if !self.new_messages.is_empty() {
453 write!(
454 f,
455 "{} new decryptable message(s) received",
456 self.new_messages.len()
457 )?;
458 }
459 }
460 Ok(())
461 }
462}
463
464#[cfg(test)]
465mod extend_tests {
466 use super::*;
467
468 #[xmtp_common::test]
473 fn extend_preserves_first_other_cause() {
474 let mut acc = SyncSummary::default();
475 acc.extend(SyncSummary::other(GroupError::GroupInactive)); acc.extend(SyncSummary::default()); let other = acc.other.as_ref().expect("first cause must survive");
479 assert_eq!(other.to_string(), GroupError::GroupInactive.to_string());
480 }
481
482 #[xmtp_common::test]
483 fn extend_takes_other_when_none_yet() {
484 let mut acc = SyncSummary::default();
485 acc.extend(SyncSummary::default()); acc.extend(SyncSummary::other(GroupError::GroupInactive)); assert!(acc.other.is_some(), "a later cause is still captured");
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::groups::mls_sync::GroupMessageProcessingError;
496 use std::error::Error;
497
498 fn errored_banner(s: &SyncSummary) -> bool {
504 s.to_string().contains("Errors Occurred During Sync")
505 }
506
507 #[xmtp_common::test]
508 fn clean_summary_is_not_errored() {
509 let summary = SyncSummary::default();
510 assert!(!summary.is_errored());
511 assert!(!errored_banner(&summary));
512 assert!(summary.source().is_none());
513 }
514
515 #[xmtp_common::test]
516 fn publish_only_error_is_errored() {
517 let mut summary = SyncSummary::default();
518 summary.add_publish_err(GroupError::GroupInactive);
519 assert!(summary.is_errored());
521 assert!(errored_banner(&summary));
522 }
523
524 #[xmtp_common::test]
525 fn post_commit_only_error_is_errored() {
526 let mut summary = SyncSummary::default();
527 summary.add_post_commit_err(GroupError::GroupInactive);
528 assert!(summary.is_errored());
529 assert!(errored_banner(&summary));
530 }
531
532 #[xmtp_common::test]
533 fn other_error_is_errored_and_is_source() {
534 let mut summary = SyncSummary::default();
535 summary.add_other(GroupError::GroupInactive);
536 assert!(summary.is_errored());
537 let source = summary.source().expect("source should be present");
539 assert_eq!(source.to_string(), GroupError::GroupInactive.to_string());
540 }
541
542 #[xmtp_common::test]
543 fn per_message_failures_do_not_flip_errored() {
544 let mut process = ProcessSummary::default();
547 process.errored(Cursor(7u64), GroupMessageProcessingError::InvalidPayload);
548 let mut summary = SyncSummary::default();
549 summary.add_process(process);
550
551 assert!(!summary.is_errored());
552 assert!(!errored_banner(&summary));
553 let source = summary
555 .source()
556 .expect("per-message error is the fallback source");
557 assert_eq!(
558 source.to_string(),
559 GroupMessageProcessingError::InvalidPayload.to_string()
560 );
561 }
562
563 #[xmtp_common::test]
564 fn source_prefers_other_over_per_message_error() {
565 let mut process = ProcessSummary::default();
566 process.errored(Cursor(7u64), GroupMessageProcessingError::InvalidPayload);
567 let mut summary = SyncSummary::default();
568 summary.add_process(process);
569 summary.add_publish_err(GroupError::GroupInactive);
570
571 let source = summary.source().expect("source should be present");
572 assert_eq!(source.to_string(), GroupError::GroupInactive.to_string());
573 }
574}