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