1use crate::builder::ForkRecoveryPolicy;
2use crate::groups::MlsGroup;
3use crate::groups::commit_log_key::CommitLogKeyCrypto;
4use crate::groups::oneshot::Oneshot;
5use crate::groups::summary::SyncSummary;
6use futures::StreamExt;
7use openmls_traits::OpenMlsProvider;
8use std::collections::HashSet;
9use std::{collections::HashMap, time::Duration};
10use thiserror::Error;
11use xmtp_api::ApiError;
12use xmtp_common::RetryableError;
13use xmtp_common::hex::NormalizeHex;
14use xmtp_db::TransactionOutcome::Continue;
15use xmtp_db::consent_record::ConsentState;
16use xmtp_db::group::ConversationType;
17use xmtp_db::group::DmIdExt;
18use xmtp_db::group::StoredGroupForRespondingReadds;
19use xmtp_db::remote_commit_log::RemoteCommitLog;
20use xmtp_db::remote_commit_log::RemoteCommitLogOrder;
21use xmtp_db::{
22 DbQuery, StorageError, Store, TransactionOutcome,
23 group::{StoredGroupCommitLogPublicKey, StoredGroupForReaddRequest},
24 local_commit_log::{CommitType, LocalCommitLogOrder},
25 prelude::*,
26 readd_status::QueryReaddStatus,
27 remote_commit_log::{CommitResult, NewRemoteCommitLog},
28};
29use xmtp_proto::backend_v1::CommitLogEntry;
30use xmtp_proto::types::Cursor;
31use xmtp_proto::types::{CommitLogEntry as DecodedCommitLogEntry, TopicCursor, TopicKind};
32use xmtp_proto::xmtp::mls::message_contents::CommitResult as ProtoCommitResult;
33use xmtp_proto::xmtp::mls::message_contents::PlaintextCommitLogEntry;
34
35use crate::groups::commit_log_key::derive_consensus_public_key;
36use crate::groups::commit_log_key::get_or_create_signing_key;
37use crate::{
38 context::XmtpSharedContext,
39 groups::GroupError,
40 worker::{BoxedWorker, NeedsDbReconnect, Worker, WorkerFactory, WorkerKind, WorkerResult},
41};
42use xmtp_proto::xmtp::mls::message_contents::{
43 OneshotMessage, ReaddRequest, oneshot_message::MessageType,
44};
45
46use xmtp_proto::types::GroupId;
47pub const DEFAULT_INTERVAL_DURATION: Duration = Duration::from_secs(60 * 5); #[derive(Clone)]
51pub struct Factory<Context> {
52 context: Context,
53}
54
55impl<Context> WorkerFactory for Factory<Context>
56where
57 Context: XmtpSharedContext + 'static,
58{
59 fn kind(&self) -> WorkerKind {
60 WorkerKind::CommitLog
61 }
62
63 fn create(
64 &self,
65 metrics: Option<crate::worker::DynMetrics>,
66 ) -> (BoxedWorker, Option<crate::worker::DynMetrics>) {
67 (
68 Box::new(CommitLogWorker::new(self.context.clone())) as Box<_>,
69 metrics,
70 )
71 }
72}
73
74#[derive(Debug, Error)]
75pub enum CommitLogError {
76 #[error("generic storage error: {0}")]
77 Storage(#[from] StorageError),
78 #[error("diesel error: {0}")]
79 Diesel(#[from] xmtp_db::diesel::result::Error),
80 #[error("generic api error: {0}")]
81 Api(#[from] ApiError),
82 #[error("connection error: {0}")]
83 Connection(#[from] xmtp_db::ConnectionError),
84 #[error("prost decode error: {0}")]
85 Prost(#[from] prost::DecodeError),
86 #[error("keystore error: {0}")]
87 KeystoreError(#[from] xmtp_db::sql_key_store::SqlKeyStoreError),
88 #[error("group error: {0}")]
89 GroupError(#[from] GroupError),
90 #[error("crypto error: {0}")]
91 CryptoError(#[from] openmls_traits::types::CryptoError),
92 #[error("try from slice error: {0}")]
93 TryFromSliceError(#[from] std::array::TryFromSliceError),
94 #[error(transparent)]
96 Signing(#[from] xmtp_mls_common::commit_log::CommitLogSigningError),
97 #[error("conversion error: {0}")]
98 Conversion(#[from] xmtp_proto::ConversionError),
99 #[error("Group did not pass readd validation: {0}")]
100 GroupReaddValidationError(String),
101 #[error("sync error: {0}")]
102 SyncError(#[from] SyncSummary),
103 #[error("failed to send readd request for group {group_id}: {source}")]
104 FailedToSendReadd {
105 group_id: GroupId,
106 source: Box<CommitLogError>,
107 },
108 #[error("{count} readd request(s) failed: {errors:?}", count = errors.len())]
109 FailedReadds { errors: Vec<CommitLogError> },
110 #[error("no latest commit sequence id found for forked group {group_id}")]
111 MissingLatestCommitSequenceId { group_id: GroupId },
112}
113
114impl RetryableError for CommitLogError {
115 fn is_retryable(&self) -> bool {
116 match self {
117 Self::Storage(storage_error) => storage_error.is_retryable(),
118 Self::Diesel(diesel_error) => diesel_error.is_retryable(),
119 Self::Api(api_error) => api_error.is_retryable(),
120 Self::Connection(connection_error) => connection_error.is_retryable(),
121 Self::Prost(_prost_error) => false,
122 Self::KeystoreError(keystore_error) => keystore_error.is_retryable(),
123 Self::GroupError(group_error) => group_error.is_retryable(),
124 Self::CryptoError(_crypto_error) => false,
125 Self::TryFromSliceError(_try_from_slice_error) => false,
126 Self::Signing(_) => false,
127 Self::Conversion(_) => false,
128 Self::GroupReaddValidationError(_group_readd_validation_error) => false,
129 Self::SyncError(sync_error) => sync_error.is_retryable(),
130 Self::FailedToSendReadd { source, .. } => source.is_retryable(),
131 Self::FailedReadds { errors } => errors.iter().any(|e| e.is_retryable()),
132 Self::MissingLatestCommitSequenceId { .. } => false,
133 }
134 }
135}
136
137impl NeedsDbReconnect for CommitLogError {
138 fn needs_db_reconnect(&self) -> bool {
139 match self {
140 Self::Storage(s) => s.db_needs_connection(),
141 Self::Connection(c) => c.db_needs_connection(),
143 Self::GroupError(e) => e.needs_db_reconnect(),
145 Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
147 Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
148 Self::Diesel(_)
150 | Self::Api(_)
151 | Self::Prost(_)
152 | Self::KeystoreError(_)
153 | Self::CryptoError(_)
154 | Self::TryFromSliceError(_)
155 | Self::Signing(_)
156 | Self::Conversion(_)
157 | Self::GroupReaddValidationError(_)
158 | Self::SyncError(_)
159 | Self::MissingLatestCommitSequenceId { .. } => false,
160 }
161 }
162}
163
164#[xmtp_common::async_trait]
165impl<Context> Worker for CommitLogWorker<Context>
166where
167 Context: XmtpSharedContext + 'static,
168{
169 fn kind(&self) -> WorkerKind {
170 WorkerKind::CommitLog
171 }
172
173 async fn run_tasks(&mut self) -> WorkerResult<()> {
174 self.run().await.map_err(|e| Box::new(e) as Box<_>)
175 }
176
177 fn factory<C>(context: C) -> impl WorkerFactory + 'static
178 where
179 C: XmtpSharedContext + 'static,
180 {
181 Factory { context }
182 }
183}
184
185pub struct CommitLogWorker<Context> {
186 context: Context,
187}
188
189impl<Context> CommitLogWorker<Context>
190where
191 Context: XmtpSharedContext + 'static,
192{
193 pub fn new(context: Context) -> Self {
194 Self { context }
195 }
196}
197
198#[derive(Debug, PartialEq, Clone)]
199pub struct ConversationCursorInfo {
200 pub conversation_id: Vec<u8>,
201 pub num_entries_published: usize,
202 pub last_entry_published_sequence_id: i64,
203 pub last_entry_published_rowid: i64,
204}
205
206#[derive(Debug, PartialEq, Clone)]
207pub struct SaveRemoteCommitLogResult {
208 pub conversation_id: Vec<u8>,
209 pub num_entries_saved: usize,
210}
211
212#[cfg(test)]
214pub enum CommitLogTestFunction {
215 PublishCommitLogsToRemote,
216 SaveRemoteCommitLog,
217 CheckForkedState,
218 All,
219}
220
221#[cfg(test)]
222pub struct TestResult {
223 pub save_remote_commit_log_results: Option<HashMap<Vec<u8>, usize>>,
224 pub publish_commit_log_results: Option<Vec<ConversationCursorInfo>>,
225 pub is_forked: Option<HashMap<Vec<u8>, Option<bool>>>,
226}
227
228impl<Context> CommitLogWorker<Context>
230where
231 Context: XmtpSharedContext + 'static,
232{
233 async fn run(&mut self) -> Result<(), CommitLogError> {
234 let (mut worker_interval, jitter) = self
238 .context
239 .worker_interval(WorkerKind::CommitLog, DEFAULT_INTERVAL_DURATION);
240 if let Some(interval) = self.context.fork_recovery_opts().worker_interval_ns {
241 worker_interval = Duration::from_nanos(interval).max(Duration::from_secs(2));
242 }
243 let mut intervals = xmtp_common::time::jittered_interval_stream(worker_interval, jitter);
244 while (intervals.next().await).is_some() {
245 self.tick().await?;
246 }
247 Ok(())
248 }
249
250 #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
251 pub async fn tick(&mut self) -> Result<(), CommitLogError> {
252 self.save_remote_commit_log().await?;
253 self.update_forked_state().await?;
254 self.publish_commit_logs_to_remote().await?;
255 self.send_outgoing_readd_requests().await?;
256 self.handle_incoming_pending_readds().await?;
257 Ok(())
258 }
259
260 async fn publish_commit_logs_to_remote(
261 &mut self,
262 ) -> Result<Vec<ConversationCursorInfo>, CommitLogError> {
263 let conn = &self.context.db();
264 let conversation_ids_for_remote_log_publish =
266 conn.get_conversation_ids_for_remote_log_publish()?;
267
268 let (conversation_cursor_info, all_entries) =
270 self.prepare_publish_commit_log_info(conn, &conversation_ids_for_remote_log_publish)?;
271
272 if all_entries.is_empty() {
274 tracing::debug!("No commit log entries to publish");
275 return Ok(conversation_cursor_info);
276 }
277
278 tracing::info!(
279 "Publishing {} commit log entries to remote commit log",
280 all_entries.len()
281 );
282
283 let api = self.context.api();
286 api.publish_commit_log(all_entries).await?;
287
288 for conversation_cursor_info in &conversation_cursor_info {
290 tracing::debug!(
291 group_id = hex::encode(&conversation_cursor_info.conversation_id),
292 "Updating publish cursor",
293 );
294 conn.update_cursor(
295 &conversation_cursor_info.conversation_id,
296 xmtp_db::refresh_state::EntityKind::CommitLogUpload,
297 Cursor(conversation_cursor_info.last_entry_published_rowid as u64),
298 )?;
299 }
300 Ok(conversation_cursor_info)
301 }
302
303 fn prepare_publish_commit_log_info(
306 &self,
307 conn: &impl DbQuery,
308 conversation_keys: &[StoredGroupCommitLogPublicKey],
309 ) -> Result<(Vec<ConversationCursorInfo>, Vec<CommitLogEntry>), CommitLogError> {
310 let mut conversation_cursor_info: Vec<ConversationCursorInfo> = Vec::new();
311 let mut all_entries = Vec::new();
312 for conversation in conversation_keys {
313 let local_commit_log_cursor = conn
317 .get_local_commit_log_cursor(&conversation.id)?
318 .unwrap_or(0);
319 let published_commit_log_cursor = conn
320 .get_last_cursor(
321 conversation.id,
322 xmtp_db::refresh_state::EntityKind::CommitLogUpload,
323 )?
324 .0;
325
326 if local_commit_log_cursor <= published_commit_log_cursor as i32 {
327 continue;
329 }
330
331 let logs = conn.get_local_commit_log_after_cursor(
335 &conversation.id,
336 published_commit_log_cursor as i64,
337 LocalCommitLogOrder::AscendingByRowid,
338 )?;
339 let removed_from_group_marker = CommitType::RemovedFromGroup.to_string();
349 let max_rowid = logs.last().map(|log| log.rowid);
350 let plaintext_commit_log_entries: Vec<PlaintextCommitLogEntry> = logs
351 .iter()
352 .filter(|log| {
353 log.commit_type.as_deref() != Some(removed_from_group_marker.as_str())
354 })
355 .map(PlaintextCommitLogEntry::from)
356 .collect();
357
358 if let Some(max_rowid) = max_rowid {
360 let signed_entries =
361 self.sign_group_logs(conversation, &plaintext_commit_log_entries)?;
362 all_entries.extend(signed_entries);
363 conversation_cursor_info.push(ConversationCursorInfo {
364 conversation_id: conversation.id.to_vec(),
365 num_entries_published: plaintext_commit_log_entries.len(),
366 last_entry_published_sequence_id: plaintext_commit_log_entries
367 .last()
368 .map(|e| e.commit_sequence_id as i64)
369 .unwrap_or(0),
370 last_entry_published_rowid: max_rowid as i64,
371 });
372 }
373 }
374 Ok((conversation_cursor_info, all_entries))
375 }
376
377 fn sign_group_logs(
378 &self,
379 conversation: &StoredGroupCommitLogPublicKey,
380 plaintext_commit_log_entries: &[PlaintextCommitLogEntry],
381 ) -> Result<Vec<CommitLogEntry>, CommitLogError> {
382 let Some(private_key) = get_or_create_signing_key(&self.context, conversation)? else {
383 tracing::warn!(group_id = %conversation.id, "No signing key available for group");
384 return Ok(vec![]);
385 };
386
387 let provider = self.context.mls_provider();
388 let mut signed_entries = Vec::new();
389 for entry in plaintext_commit_log_entries {
390 let signed = xmtp_mls_common::commit_log::sign_commit_log(
391 entry,
392 &private_key,
393 provider.crypto(),
394 )?;
395
396 signed_entries.push(CommitLogEntry {
397 serialized_commit_log_entry: signed.serialized_commit_log_entry,
398 signature: Some(signed.signature),
399 });
400 }
401 Ok(signed_entries)
402 }
403
404 async fn save_remote_commit_log(&mut self) -> Result<HashMap<Vec<u8>, usize>, CommitLogError> {
406 let conn = &self.context.db();
407 let conversation_id_to_public_key: HashMap<Vec<u8>, Option<Vec<u8>>> = conn
409 .get_conversation_ids_for_remote_log_download()?
410 .into_iter()
411 .map(|c| (c.id.to_vec(), c.commit_log_public_key))
412 .collect();
413
414 let remote_log_cursors = conn.get_remote_log_cursors(
416 conversation_id_to_public_key
417 .keys()
418 .map(Vec::as_slice)
419 .collect::<Vec<_>>()
420 .as_slice(),
421 )?;
422 let query_log_requests: TopicCursor = remote_log_cursors
423 .into_iter()
424 .map(|(id, cursor)| (TopicKind::CommitLogEntriesV1.create(id), cursor))
425 .collect();
426
427 if query_log_requests.is_empty() {
429 tracing::debug!("No commit log requests to query");
430 return Ok(HashMap::new());
431 }
432
433 let api = self.context.api();
435 let query_commit_log_responses = api.query_commit_log(query_log_requests).await?;
436
437 let mut grouped: HashMap<Vec<u8>, Vec<DecodedCommitLogEntry>> = HashMap::new();
438 for entry in query_commit_log_responses {
439 grouped
440 .entry(entry.entry.group_id.clone())
441 .or_default()
442 .push(entry);
443 }
444 let mut save_remote_commit_log_results = HashMap::new();
445 for (group_id, entries) in grouped {
446 let mut consensus_public_key = conversation_id_to_public_key
447 .get(&group_id)
448 .and_then(Option::clone);
449 if consensus_public_key.is_none() {
450 consensus_public_key = derive_consensus_public_key(
451 &self.context,
452 &group_id,
453 &entries
454 .iter()
455 .map(|entry| entry.payload.clone())
456 .collect::<Vec<_>>(),
457 )
458 .await?;
459 }
460 let num_entries = self.save_remote_commit_log_entries_and_update_cursors(
461 conn,
462 &group_id,
463 entries,
464 consensus_public_key,
465 )?;
466 save_remote_commit_log_results.insert(group_id, num_entries);
467 }
468
469 Ok(save_remote_commit_log_results)
470 }
471
472 fn save_remote_commit_log_entries_and_update_cursors(
473 &self,
474 conn: &impl DbQuery,
475 group_id: &[u8],
476 entries: Vec<DecodedCommitLogEntry>,
477 consensus_public_key: Option<Vec<u8>>,
478 ) -> Result<usize, CommitLogError> {
479 let group_id = GroupId::try_from(group_id)?;
480 let mut num_entries_saved = 0;
481 if let Some(consensus_public_key) = consensus_public_key {
486 let mut latest_saved_remote_log = conn.get_latest_remote_log_for_group(&group_id)?;
487 for decoded in &entries {
488 let commit_log_entry = &decoded.payload;
489 let log_entry = &decoded.entry;
490 let sequence_id = decoded
491 .meta
492 .cursor
493 .as_ref()
494 .ok_or(xmtp_proto::ConversionError::Missing {
495 item: "commit-log cursor",
496 r#type: "EnvelopeMeta",
497 })?
498 .sequence_id;
499 if self.should_skip_remote_commit_log_entry(
500 group_id.as_slice(),
501 latest_saved_remote_log.clone(),
502 commit_log_entry,
503 log_entry,
504 &consensus_public_key,
505 ) {
506 continue;
507 }
508
509 let log_entry_group_id = GroupId::try_from(log_entry.group_id.as_slice())?;
510 num_entries_saved += 1;
511 NewRemoteCommitLog {
512 log_sequence_id: sequence_id as i64,
513 group_id: log_entry_group_id,
514 commit_sequence_id: log_entry.commit_sequence_id as i64,
515 commit_result: CommitResult::from(
516 ProtoCommitResult::try_from(log_entry.commit_result)
517 .unwrap_or(ProtoCommitResult::Unspecified),
518 ),
519 applied_epoch_number: log_entry.applied_epoch_number as i64,
520 applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
521 }
522 .store(conn)?;
523
524 latest_saved_remote_log = Some(RemoteCommitLog {
525 rowid: 0,
526 log_sequence_id: sequence_id as i64,
527 group_id: log_entry_group_id,
528 commit_sequence_id: log_entry.commit_sequence_id as i64,
529 commit_result: CommitResult::from(
530 ProtoCommitResult::try_from(log_entry.commit_result)
531 .unwrap_or(ProtoCommitResult::Unspecified),
532 ),
533 applied_epoch_number: log_entry.applied_epoch_number as i64,
534 applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
535 });
536 }
537 }
538 if let Some(last_entry) = entries.last() {
539 conn.update_cursor(
540 group_id,
541 xmtp_db::refresh_state::EntityKind::CommitLogDownload,
542 Cursor(
543 last_entry
544 .meta
545 .cursor
546 .as_ref()
547 .ok_or(xmtp_proto::ConversionError::Missing {
548 item: "commit-log cursor",
549 r#type: "EnvelopeMeta",
550 })?
551 .sequence_id,
552 ),
553 )?;
554 }
555
556 Ok(num_entries_saved)
557 }
558
559 fn should_skip_remote_commit_log_entry(
568 &self,
569 group_id: &[u8],
570 latest_saved_remote_log: Option<RemoteCommitLog>,
571 serialized_entry: &CommitLogEntry,
572 entry: &PlaintextCommitLogEntry,
573 consensus_public_key: &[u8],
574 ) -> bool {
575 if entry.group_id != group_id || entry.commit_sequence_id == 0 {
577 return true;
578 }
579 let provider = self.context.mls_provider();
580 if provider
581 .crypto()
582 .verify_commit_log_signature(serialized_entry, consensus_public_key)
583 .is_err()
584 {
585 tracing::warn!(
586 group_id = hex::encode(group_id),
587 "Invalid signature for commit log entry, skipping",
588 );
589 return true;
590 }
591 let Some(latest_saved_remote_log) = latest_saved_remote_log else {
592 return false;
593 };
594
595 let is_applied = entry.commit_result == ProtoCommitResult::Applied as i32;
596
597 entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64
598 || (is_applied
599 && !latest_saved_remote_log
600 .applied_epoch_authenticator
601 .is_empty()
602 && entry.last_epoch_authenticator
603 != latest_saved_remote_log.applied_epoch_authenticator)
604 || (is_applied
605 && entry.applied_epoch_number as i64
606 != latest_saved_remote_log.applied_epoch_number + 1)
607 || (!is_applied
608 && (entry.applied_epoch_authenticator
609 != latest_saved_remote_log.applied_epoch_authenticator
610 || entry.applied_epoch_number as i64
611 != latest_saved_remote_log.applied_epoch_number))
612 }
613
614 pub async fn update_forked_state(&mut self) -> Result<(), CommitLogError> {
616 let conversation_ids_for_forked_state_check =
617 self.context.db().get_conversation_ids_for_fork_check()?;
618
619 for conversation_id in conversation_ids_for_forked_state_check {
620 let conversation_id = GroupId::try_from(conversation_id)?;
621 self.context
622 .mls_provider()
623 .storage()
624 .transaction(|conn| {
625 let key_store = conn.key_store();
626 let db = key_store.db();
627 let is_forked = self.check_conversation_fork_state(&db, &conversation_id)?;
628 db.set_group_commit_log_forked_status(&conversation_id, is_forked)?;
630 Ok::<_, CommitLogError>(Continue(()))
631 })
632 .map(TransactionOutcome::into_continued)?;
633 tokio::task::yield_now().await;
634 }
635
636 Ok(())
637 }
638
639 async fn permitted_readders(&self, group_id: &GroupId) -> Result<Vec<String>, CommitLogError> {
642 let (group, stored_group) = MlsGroup::new_cached(self.context.clone(), group_id)?;
643 if stored_group.conversation_type == ConversationType::Dm {
644 let Some(dm_id) = stored_group.dm_id.clone() else {
645 tracing::error!(group_id = %group_id, "DM group has no dm_id");
646 return Ok(vec![]);
647 };
648 let other_id = dm_id.other_inbox_id(self.context.inbox_id());
649 return Ok(vec![other_id]);
650 }
651 let super_admins = group.super_admin_list()?;
652 Ok(super_admins)
653 }
654
655 async fn request_readd(
656 &mut self,
657 group_info: StoredGroupForReaddRequest,
658 ) -> Result<(), CommitLogError> {
659 let conn = self.context.db();
660 let group_id = group_info.group_id;
661
662 if conn.is_awaiting_readd(&group_id, self.context.installation_id().as_slice())? {
664 tracing::debug!(
665 group_id = %group_id,
666 "Skipping readd request for group because it has already been requested"
667 );
668 return Ok(());
669 }
670
671 tracing::debug!(group_id = %group_id, "Sending readd request");
672
673 let latest_commit_sequence_id = group_info
675 .latest_commit_sequence_id
676 .ok_or(CommitLogError::MissingLatestCommitSequenceId { group_id })?;
677 let oneshot_message = OneshotMessage {
678 message_type: Some(MessageType::ReaddRequest(ReaddRequest {
679 group_id: group_id.to_vec(),
680 latest_commit_sequence_id: latest_commit_sequence_id as u64,
681 })),
682 };
683 let readders = self.permitted_readders(&group_id).await?;
684 tracing::debug!(
685 group_id = %group_id,
686 "Sending readd request to {:?}",
687 readders
688 );
689 Oneshot::send_message(self.context.clone(), readders, oneshot_message).await?;
690
691 tracing::debug!(group_id = %group_id, "Sent readd request",);
692
693 conn.update_requested_at_sequence_id(
695 &group_id,
696 self.context.installation_id().as_slice(),
697 latest_commit_sequence_id,
698 )?;
699
700 tracing::debug!(
701 group_id = %group_id,
702 sequence_id = latest_commit_sequence_id,
703 "Updated requested readd sequence id",
704 );
705
706 Ok(())
707 }
708
709 async fn send_outgoing_readd_requests(&mut self) -> Result<(), CommitLogError> {
711 if self.context.fork_recovery_opts().enable_recovery_requests == ForkRecoveryPolicy::None {
712 return Ok(());
713 }
714 let conn = self.context.db();
715
716 let mut forked_groups = conn.get_conversation_ids_for_requesting_readds()?;
718 if self.context.fork_recovery_opts().enable_recovery_requests
719 == ForkRecoveryPolicy::AllowlistedGroups
720 {
721 let groups_to_request_recovery = self
722 .context
723 .fork_recovery_opts()
724 .groups_to_request_recovery
725 .iter()
726 .map(|group_id| group_id.normalize_hex())
727 .collect::<HashSet<String>>();
728 tracing::info!(
729 "Forked groups: {:?}, allowlisted groups for sending recovery requests: {:?}",
730 forked_groups
731 .iter()
732 .map(|group_info| group_info.group_id.to_string())
733 .collect::<Vec<String>>(),
734 groups_to_request_recovery
735 );
736 forked_groups.retain(|group_info| {
737 groups_to_request_recovery
738 .contains(&group_info.group_id.to_string().normalize_hex())
739 });
740 }
741
742 let mut failures = Vec::new();
745 for group_info in forked_groups {
746 let group_id = group_info.group_id;
747 if let Err(source) = self.request_readd(group_info).await {
748 if source.needs_db_reconnect() {
749 return Err(source);
750 }
751 failures.push(CommitLogError::FailedToSendReadd {
752 group_id,
753 source: Box::new(source),
754 });
755 }
756 }
757
758 if !failures.is_empty() {
759 return Err(CommitLogError::FailedReadds { errors: failures });
760 }
761
762 Ok(())
763 }
764
765 async fn handle_incoming_pending_readds(&self) -> Result<(), CommitLogError> {
766 if self.context.fork_recovery_opts().disable_recovery_responses {
767 return Ok(());
768 }
769 let conn = self.context.db();
770 let groups_for_readd = conn.get_conversation_ids_for_responding_readds()?;
771
772 if groups_for_readd.is_empty() {
774 return Ok(());
775 }
776 tracing::debug!(
777 "Processing readd requests for {} groups",
778 groups_for_readd.len()
779 );
780
781 for group in groups_for_readd {
782 match self.validate_pending_readds(&conn, &group).await {
783 Ok(validated_installations) => {
784 if validated_installations.is_empty() {
785 continue;
786 }
787 let mls_group = MlsGroup::new(
788 self.context.clone(),
789 group.group_id,
790 group.dm_id.clone(),
791 group.conversation_type,
792 group.created_at_ns,
793 );
794 mls_group
795 .readd_installations(
796 validated_installations.into_iter().collect::<Vec<_>>(),
797 )
798 .await?;
799 }
800 Err(e) => {
801 if e.is_retryable() {
804 return Err(e);
805 }
806 tracing::warn!(
807 group_id = %group.group_id,
808 "Deleting readd statuses for group because it failed validation: {}",
809 e
810 );
811 conn.delete_other_readd_statuses(
812 &group.group_id,
813 self.context.installation_id().as_slice(),
814 )?;
815 continue;
816 }
817 }
818 }
819
820 Ok(())
821 }
822
823 async fn validate_pending_readds(
824 &self,
825 conn: &impl DbQuery,
826 group: &StoredGroupForRespondingReadds,
827 ) -> Result<HashSet<Vec<u8>>, CommitLogError> {
828 let (mls_group, _) = MlsGroup::new_cached(self.context.clone(), &group.group_id)?;
829 tracing::debug!(
830 group_id = %mls_group.group_id,
831 "Processing readd requests for group"
832 );
833
834 mls_group.sync_with_conn().await?;
835
836 if mls_group.consent_state()? != ConsentState::Allowed {
837 return Err(CommitLogError::GroupReaddValidationError(
838 "Group is not consented".to_string(),
839 ));
840 }
841 if !mls_group.is_active()? {
842 return Err(CommitLogError::GroupReaddValidationError(
843 "Group is not active".to_string(),
844 ));
845 }
846 let is_super_admin = mls_group.is_super_admin(self.context.inbox_id().to_string())?;
847 if !is_super_admin {
848 return Err(CommitLogError::GroupReaddValidationError(
849 "No longer super admin of group".to_string(),
850 ));
851 }
852
853 let fork_state = self.check_conversation_fork_state(conn, &mls_group.group_id)?;
854 if let Some(true) = fork_state {
855 return Err(CommitLogError::GroupReaddValidationError(
856 "Group is forked".to_string(),
857 ));
858 } else if fork_state.is_none() {
859 tracing::info!(
860 group_id = %mls_group.group_id,
861 "Local commit log ahead of remote, skipping group"
862 );
863 return Ok(HashSet::new());
864 }
865
866 let readd_statuses = conn.get_readds_awaiting_response(
867 &mls_group.group_id,
868 self.context.installation_id().as_slice(),
869 )?;
870 let mut unverified = readd_statuses
871 .iter()
872 .map(|readd_status| readd_status.installation_id.clone())
873 .collect::<HashSet<_>>();
874
875 let (unverified, verified) = mls_group.with_group_snapshot(|openmls_group| {
876 let mut verified = HashSet::new();
877 for member in openmls_group.members() {
878 if unverified.contains(&member.signature_key) {
879 unverified.remove(&member.signature_key);
880 verified.insert(member.signature_key);
881 }
882 }
883 Ok::<_, GroupError>((unverified, verified))
884 })?;
885 tracing::debug!(
886 group_id = %mls_group.group_id,
887 "{} readd requests were for non-members, while {} were for members",
888 unverified.len(),
889 verified.len()
890 );
891 conn.delete_readd_statuses(&mls_group.group_id, unverified)?;
892
893 Ok(verified)
894 }
895
896 fn check_conversation_fork_state(
897 &self,
898 conn: &impl DbQuery,
899 conversation_id: &GroupId,
900 ) -> Result<Option<bool>, CommitLogError> {
901 let fork_check_local_cursor = conn.get_last_cursor(
903 conversation_id,
904 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
905 )?;
906 let fork_check_remote_cursor = conn.get_last_cursor(
907 conversation_id,
908 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
909 )?;
910
911 let mut local_cursor = fork_check_local_cursor.0 as i64;
920 let mut crossed_chain_start = false;
921 if let Some(anchor_rowid) = conn.get_latest_chain_start_rowid(conversation_id)?
922 && anchor_rowid as i64 > local_cursor
923 {
924 local_cursor = anchor_rowid as i64;
925 crossed_chain_start = true;
926 conn.update_cursor(
927 conversation_id,
928 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
929 Cursor(anchor_rowid as u64),
930 )?;
931 }
932
933 let local_logs = conn.get_local_commit_log_after_cursor(
935 conversation_id,
936 local_cursor,
937 LocalCommitLogOrder::DescendingByRowid,
938 )?;
939 let remote_logs = conn.get_remote_commit_log_after_cursor(
940 conversation_id,
941 fork_check_remote_cursor.0 as i64,
942 RemoteCommitLogOrder::DescendingByRowid,
943 )?;
944
945 if local_logs.is_empty() {
947 if crossed_chain_start {
948 return Ok(None);
952 }
953 return Ok(conn.get_group_commit_log_forked_status(conversation_id)?);
954 }
955
956 let mut is_remote_log_up_to_date = true;
957 for local_log in &local_logs {
959 if local_log.commit_result == CommitResult::Success
968 && local_log.applied_epoch_authenticator == local_log.last_epoch_authenticator
969 {
970 conn.update_cursor(
975 conversation_id,
976 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
977 Cursor(local_log.rowid as u64),
978 )?;
979 continue;
980 }
981
982 let Some(matching_remote_log) =
983 self.find_matching_remote_log(&remote_logs, local_log.commit_sequence_id)
984 else {
985 is_remote_log_up_to_date = false;
986 continue;
987 };
988 let is_mismatched = local_log.applied_epoch_authenticator
990 != matching_remote_log.applied_epoch_authenticator;
991
992 if is_mismatched {
993 tracing::warn!(
994 group_id = %conversation_id,
995 "Detected forked state\n\
996 Local log: {:?}\n\
997 Remote log: {:?}",
998 local_log,
999 matching_remote_log
1000 );
1001 }
1002
1003 conn.update_cursor(
1005 conversation_id,
1006 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1007 Cursor(local_log.rowid as u64),
1008 )?;
1009 conn.update_cursor(
1010 conversation_id,
1011 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1012 Cursor(matching_remote_log.rowid as u64),
1013 )?;
1014
1015 if is_mismatched {
1016 return Ok(Some(true));
1017 } else if is_remote_log_up_to_date {
1018 return Ok(Some(false));
1019 } else {
1020 return Ok(None);
1023 }
1024 }
1025
1026 Ok(None)
1027 }
1028
1029 fn find_matching_remote_log<'a>(
1030 &self,
1031 remote_logs: &'a [xmtp_db::remote_commit_log::RemoteCommitLog],
1032 commit_sequence_id: i64,
1033 ) -> Option<&'a xmtp_db::remote_commit_log::RemoteCommitLog> {
1034 remote_logs
1035 .iter()
1036 .find(|remote_log| remote_log.commit_sequence_id == commit_sequence_id)
1037 }
1038}
1039
1040#[cfg(test)]
1042impl<Context> CommitLogWorker<Context>
1043where
1044 Context: XmtpSharedContext + 'static,
1045{
1046 pub async fn _tick(&mut self) -> Result<(), CommitLogError> {
1047 self.tick().await
1048 }
1049
1050 pub(crate) fn _should_skip_remote_commit_log_entry(
1051 &self,
1052 group_id: &[u8],
1053 latest_saved_remote_log: Option<RemoteCommitLog>,
1054 serialized_entry: &xmtp_proto::backend_v1::CommitLogEntry,
1055 entry: &PlaintextCommitLogEntry,
1056 consensus_public_key: &[u8],
1057 ) -> bool {
1058 self.should_skip_remote_commit_log_entry(
1059 group_id,
1060 latest_saved_remote_log,
1061 serialized_entry,
1062 entry,
1063 consensus_public_key,
1064 )
1065 }
1066
1067 pub fn get_all_fork_statuses(&self) -> Result<HashMap<Vec<u8>, Option<bool>>, CommitLogError> {
1069 use xmtp_db::group::GroupQueryArgs;
1070 let conn = &self.context.db();
1071 let all_groups = conn.find_groups(GroupQueryArgs::default())?;
1073
1074 let mut results = HashMap::new();
1075 for group in all_groups {
1076 let fork_status = conn.get_group_commit_log_forked_status(&group.id)?;
1077 results.insert(group.id.to_vec(), fork_status);
1078 }
1079
1080 Ok(results)
1081 }
1082
1083 pub async fn run_test(
1085 &mut self,
1086 commit_log_test_function: CommitLogTestFunction,
1087 iterations: Option<usize>,
1088 ) -> Result<Vec<TestResult>, CommitLogError> {
1089 let mut test_results = Vec::new();
1090 match iterations {
1091 Some(n) => {
1092 for _ in 0..n {
1094 let test_result = self.test_helper(&commit_log_test_function).await?;
1095 test_results.push(test_result);
1096 }
1097 }
1098 None => {
1099 let test_result = self.test_helper(&commit_log_test_function).await?;
1100 test_results.push(test_result);
1101 }
1102 }
1103 Ok(test_results)
1104 }
1105
1106 async fn test_helper(
1107 &mut self,
1108 commit_log_test_function: &CommitLogTestFunction,
1109 ) -> Result<TestResult, CommitLogError> {
1110 let mut test_result = TestResult {
1111 save_remote_commit_log_results: None,
1112 publish_commit_log_results: None,
1113 is_forked: None,
1114 };
1115 match commit_log_test_function {
1116 CommitLogTestFunction::PublishCommitLogsToRemote => {
1117 let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1118 test_result.publish_commit_log_results = Some(publish_commit_log_results);
1119 }
1120 CommitLogTestFunction::SaveRemoteCommitLog => {
1121 let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1122 test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1123 }
1124 CommitLogTestFunction::CheckForkedState => {
1125 self.update_forked_state().await?;
1126 let is_forked = self.get_all_fork_statuses()?;
1127 test_result.is_forked = Some(is_forked);
1128 }
1129 CommitLogTestFunction::All => {
1130 let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1132 test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1133 self.update_forked_state().await?;
1134 let is_forked = self.get_all_fork_statuses()?;
1135 test_result.is_forked = Some(is_forked);
1136 let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1137 test_result.publish_commit_log_results = Some(publish_commit_log_results);
1138 }
1139 }
1140 Ok(test_result)
1141 }
1142}