1use crate::builder::ForkRecoveryPolicy;
2use crate::groups::MlsGroup;
3use crate::groups::mls_sync::retry_due;
4use crate::groups::oneshot::Oneshot;
5use crate::groups::summary::SyncSummary;
6use crate::subscriptions::internal::dm_event_context;
7use futures::StreamExt;
8use std::collections::HashSet;
9use std::sync::Arc;
10use std::{collections::HashMap, time::Duration};
11use thiserror::Error;
12use xmtp_api::ApiError;
13use xmtp_common::RetryableError;
14use xmtp_common::hex::NormalizeHex;
15use xmtp_common::snippet::Snippet;
16use xmtp_common::time::now_ns;
17use xmtp_db::TransactionOutcome::Continue;
18use xmtp_db::consent_record::ConsentState;
19use xmtp_db::group::StoredGroupForRespondingReadds;
20use xmtp_db::remote_commit_log::RemoteCommitLog;
21use xmtp_db::remote_commit_log::RemoteCommitLogOrder;
22use xmtp_db::{
23 DbQuery, StorageError, Store, TransactionOutcome,
24 group::StoredGroupForReaddRequest,
25 local_commit_log::{CommitType, LocalCommitLogOrder},
26 prelude::*,
27 readd_status::QueryReaddStatus,
28 remote_commit_log::{CommitResult, NewRemoteCommitLog},
29};
30use xmtp_mls_group_keys::commit_log::{open_entry, seal_entry, sign_entry_author};
31use xmtp_mls_group_keys::group_keys::{GroupKeys, SealError};
32use xmtp_mls_group_state::app_data::group_root::group_keys_from_extensions;
33use xmtp_mls_validation::ForkActors;
34use xmtp_proto::ShortHex;
35use xmtp_proto::backend_v1::SealedCommitLogEntry;
36use xmtp_proto::types::Cursor;
37use xmtp_proto::types::{CommitLogEntry as DecodedCommitLogEntry, Topic, TopicCursor};
38use xmtp_proto::xmtp::mls::message_contents::CommitResult as ProtoCommitResult;
39use xmtp_proto::xmtp::mls::message_contents::PlaintextCommitLogEntry;
40
41use crate::{
42 context::XmtpSharedContext,
43 groups::GroupError,
44 worker::{
45 BoxedWorker, NeedsDbReconnect, NewWorker, Worker, WorkerFactory, WorkerKind, WorkerResult,
46 },
47};
48use xmtp_proto::xmtp::mls::message_contents::{
49 OneshotMessage, ReaddRequest, oneshot_message::MessageType,
50};
51
52use xmtp_events::{ClientEvent, EventWriter, GroupRef};
53use xmtp_proto::types::GroupId;
54pub const DEFAULT_INTERVAL_DURATION: Duration = Duration::from_secs(60 * 5); #[derive(Clone)]
58pub struct Factory<Context> {
59 context: Context,
60 holds: Arc<parking_lot::Mutex<Holds>>,
63}
64
65impl<Context> Factory<Context>
66where
67 Context: XmtpSharedContext + 'static,
68{
69 pub(crate) fn new(context: Context) -> Self {
70 Self {
71 context,
72 holds: Arc::default(),
73 }
74 }
75
76 pub(crate) fn worker(&self) -> CommitLogWorker<Context> {
78 CommitLogWorker::with_holds(self.context.clone(), self.holds.clone())
79 }
80}
81
82impl<Context> WorkerFactory for Factory<Context>
83where
84 Context: XmtpSharedContext + 'static,
85{
86 fn kind(&self) -> WorkerKind {
87 WorkerKind::CommitLog
88 }
89
90 fn create(&self) -> BoxedWorker {
91 Box::new(self.worker())
92 }
93}
94
95#[derive(Debug, Error)]
96pub enum CommitLogError {
97 #[error("generic storage error: {0}")]
98 Storage(#[from] StorageError),
99 #[error("diesel error: {0}")]
100 Diesel(#[from] xmtp_db::diesel::result::Error),
101 #[error("generic api error: {0}")]
102 Api(#[from] ApiError),
103 #[error("connection error: {0}")]
104 Connection(#[from] xmtp_db::ConnectionError),
105 #[error("prost decode error: {0}")]
106 Prost(#[from] prost::DecodeError),
107 #[error("keystore error: {0}")]
108 KeystoreError(#[from] xmtp_db::sql_key_store::SqlKeyStoreError),
109 #[error("group error: {0}")]
110 GroupError(#[from] GroupError),
111 #[error("crypto error: {0}")]
112 CryptoError(#[from] openmls_traits::types::CryptoError),
113 #[error("try from slice error: {0}")]
114 TryFromSliceError(#[from] std::array::TryFromSliceError),
115 #[error("conversion error: {0}")]
116 Conversion(#[from] xmtp_proto::ConversionError),
117 #[error("Group did not pass readd validation: {0}")]
118 GroupReaddValidationError(String),
119 #[error("sync error: {0}")]
120 SyncError(#[from] SyncSummary),
121 #[error("failed to send readd request for group {group_id}: {source}")]
122 FailedToSendReadd {
123 group_id: GroupId,
124 source: Box<CommitLogError>,
125 },
126 #[error("{count} readd request(s) failed: {errors:?}", count = errors.len())]
127 FailedReadds { errors: Vec<CommitLogError> },
128 #[error("no latest commit sequence id found for forked group {group_id}")]
129 MissingLatestCommitSequenceId { group_id: GroupId },
130}
131
132impl RetryableError for CommitLogError {
133 fn is_retryable(&self) -> bool {
134 match self {
135 Self::Storage(storage_error) => storage_error.is_retryable(),
136 Self::Diesel(diesel_error) => diesel_error.is_retryable(),
137 Self::Api(api_error) => api_error.is_retryable(),
138 Self::Connection(connection_error) => connection_error.is_retryable(),
139 Self::Prost(_prost_error) => false,
140 Self::KeystoreError(keystore_error) => keystore_error.is_retryable(),
141 Self::GroupError(group_error) => group_error.is_retryable(),
142 Self::CryptoError(_crypto_error) => false,
143 Self::TryFromSliceError(_try_from_slice_error) => false,
144 Self::Conversion(_) => false,
145 Self::GroupReaddValidationError(_group_readd_validation_error) => false,
146 Self::SyncError(sync_error) => sync_error.is_retryable(),
147 Self::FailedToSendReadd { source, .. } => source.is_retryable(),
148 Self::FailedReadds { errors } => errors.iter().any(|e| e.is_retryable()),
149 Self::MissingLatestCommitSequenceId { .. } => false,
150 }
151 }
152
153 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
154 match self {
155 Self::Api(api_error) => api_error.rate_limit_delay(),
156 Self::GroupError(group_error) => group_error.rate_limit_delay(),
157 Self::SyncError(sync_error) => sync_error.rate_limit_delay(),
158 Self::FailedToSendReadd { source, .. } => source.rate_limit_delay(),
159 Self::FailedReadds { errors } => errors
160 .iter()
161 .filter_map(RetryableError::rate_limit_delay)
162 .max(),
163 _ => None,
164 }
165 }
166}
167
168impl NeedsDbReconnect for CommitLogError {
169 fn restart_delay(&self) -> Option<Duration> {
170 RetryableError::rate_limit_delay(self)
171 }
172
173 #[deny(clippy::wildcard_enum_match_arm)]
174 fn needs_db_reconnect(&self) -> bool {
175 match self {
176 Self::Storage(s) => s.db_needs_connection(),
177 Self::Connection(c) => c.db_needs_connection(),
179 Self::GroupError(e) => e.needs_db_reconnect(),
181 Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
183 Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
184 Self::SyncError(summary) => summary.needs_db_reconnect(),
185 Self::Diesel(_)
187 | Self::Api(_)
188 | Self::Prost(_)
189 | Self::KeystoreError(_)
190 | Self::CryptoError(_)
191 | Self::TryFromSliceError(_)
192 | Self::Conversion(_)
193 | Self::GroupReaddValidationError(_)
194 | Self::MissingLatestCommitSequenceId { .. } => false,
195 }
196 }
197}
198
199#[xmtp_common::async_trait]
200impl<Context> Worker for CommitLogWorker<Context>
201where
202 Context: XmtpSharedContext + 'static,
203{
204 fn kind(&self) -> WorkerKind {
205 WorkerKind::CommitLog
206 }
207
208 async fn run_tasks(&mut self) -> WorkerResult<()> {
209 self.run().await.map_err(|e| Box::new(e) as Box<_>)
210 }
211}
212
213impl<Context> NewWorker<Context> for CommitLogWorker<Context>
214where
215 Context: XmtpSharedContext + 'static,
216{
217 fn factory(context: Context) -> impl WorkerFactory + 'static {
218 Factory::new(context)
219 }
220}
221
222#[derive(Default)]
224pub(crate) struct Holds {
225 publish: HashMap<GroupId, xmtp_common::time::Instant>,
228 readd: HashMap<GroupId, xmtp_common::time::Instant>,
231}
232
233pub struct CommitLogWorker<Context> {
234 context: Context,
235 failed_publishes: Vec<GroupId>,
237 holds: Arc<parking_lot::Mutex<Holds>>,
239 #[cfg(test)]
240 seal_fault: Option<SealFault>,
241}
242
243fn hold(
247 held: &mut HashMap<GroupId, xmtp_common::time::Instant>,
248 id: GroupId,
249 delay: Option<Duration>,
250) {
251 let Some(delay) = delay else {
252 return;
253 };
254 let until = xmtp_common::time::saturating_after(xmtp_common::time::Instant::now(), delay);
255 let held = held.entry(id).or_insert(until);
256 *held = (*held).max(until);
257}
258
259#[cfg(test)]
261pub(crate) type SealFault = std::sync::Arc<
262 dyn Fn(
263 &GroupId,
264 Result<SealedCommitLogEntry, SealError>,
265 ) -> Result<SealedCommitLogEntry, SealError>
266 + Send
267 + Sync,
268>;
269
270impl<Context> CommitLogWorker<Context>
271where
272 Context: XmtpSharedContext + 'static,
273{
274 pub fn new(context: Context) -> Self {
275 Self::with_holds(context, Arc::default())
276 }
277
278 fn with_holds(context: Context, holds: Arc<parking_lot::Mutex<Holds>>) -> Self {
279 Self {
280 context,
281 failed_publishes: Vec::new(),
282 holds,
283 #[cfg(test)]
284 seal_fault: None,
285 }
286 }
287
288 pub fn failed_publishes(&self) -> &[GroupId] {
292 &self.failed_publishes
293 }
294}
295
296struct PendingPublish {
299 cursor_info: ConversationCursorInfo,
300 entries: Vec<SealedCommitLogEntry>,
301}
302
303#[derive(Debug, PartialEq, Clone)]
304pub struct ConversationCursorInfo {
305 pub conversation_id: Vec<u8>,
306 pub num_entries_published: usize,
307 pub last_entry_published_sequence_id: i64,
308 pub last_entry_published_rowid: i64,
309 pub entry_rowids: Vec<i64>,
311}
312
313impl ConversationCursorInfo {
314 fn stored_cursor(&self, stored: usize) -> Option<i64> {
321 if stored >= self.entry_rowids.len() {
322 return Some(self.last_entry_published_rowid);
323 }
324 stored.checked_sub(1).map(|last| self.entry_rowids[last])
325 }
326}
327
328#[derive(Debug, PartialEq, Clone)]
329pub struct SaveRemoteCommitLogResult {
330 pub conversation_id: Vec<u8>,
331 pub num_entries_saved: usize,
332}
333
334#[cfg(test)]
336pub enum CommitLogTestFunction {
337 PublishCommitLogsToRemote,
338 SaveRemoteCommitLog,
339 CheckForkedState,
340 All,
341}
342
343#[cfg(test)]
344pub struct TestResult {
345 pub save_remote_commit_log_results: Option<HashMap<Vec<u8>, usize>>,
346 pub publish_commit_log_results: Option<Vec<ConversationCursorInfo>>,
347 pub is_forked: Option<HashMap<Vec<u8>, Option<bool>>>,
348}
349
350impl<Context> CommitLogWorker<Context>
352where
353 Context: XmtpSharedContext + 'static,
354{
355 async fn run(&mut self) -> Result<(), CommitLogError> {
356 let (mut worker_interval, jitter) = self
360 .context
361 .worker_interval(WorkerKind::CommitLog, DEFAULT_INTERVAL_DURATION);
362 if let Some(interval) = self.context.fork_recovery_opts().worker_interval_ns {
363 worker_interval = Duration::from_nanos(interval).max(Duration::from_secs(2));
364 }
365 let mut intervals = xmtp_common::time::jittered_interval_stream(worker_interval, jitter);
366 while (intervals.next().await).is_some() {
367 self.tick().await?;
368 }
369 Ok(())
370 }
371
372 #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
373 pub async fn tick(&mut self) -> Result<(), CommitLogError> {
374 self.save_remote_commit_log().await?;
375 self.update_forked_state().await?;
376 self.publish_commit_logs_to_remote().await?;
377 self.send_outgoing_readd_requests().await?;
378 self.handle_incoming_pending_readds().await?;
379 Ok(())
380 }
381
382 async fn publish_commit_logs_to_remote(
393 &mut self,
394 ) -> Result<Vec<ConversationCursorInfo>, CommitLogError> {
395 self.failed_publishes.clear();
396 let now = xmtp_common::time::Instant::now();
397 self.holds.lock().publish.retain(|_, until| now < *until);
398 let conn = &self.context.db();
399 let conversations = conn.get_conversation_ids_for_remote_log_publish()?;
401 let api = self.context.api();
402 let mut published = Vec::new();
403 for conversation in &conversations {
404 if self.holds.lock().publish.contains_key(&conversation.id) {
408 continue;
409 }
410 let Some(PendingPublish {
411 cursor_info,
412 entries,
413 }) = self.prepare_conversation_publish(conn, &conversation.id)?
414 else {
415 continue;
416 };
417 let mut stored = Vec::new();
418 let result = if entries.is_empty() {
419 Ok(())
420 } else {
421 tracing::info!(
422 group_id = %conversation.id.as_slice().snippet(),
423 entries = entries.len(),
424 "Publishing commit log entries to remote commit log"
425 );
426 api.publish_commit_log(entries, &mut stored).await
429 };
430 if let Some(rowid) = cursor_info.stored_cursor(stored.len()) {
433 conn.update_cursor(
434 conversation.id,
435 xmtp_db::refresh_state::EntityKind::CommitLogUpload,
436 Cursor(rowid as u64),
437 )?;
438 }
439 match result {
440 Ok(()) => published.push(cursor_info),
441 Err(error) => {
442 tracing::warn!(
443 group_id = %conversation.id.as_slice().snippet(),
444 stored = stored.len(),
445 %error,
446 "Commit log publish failed; the conversation retries at the next turn"
447 );
448 self.failed_publishes.push(conversation.id);
449 hold(
450 &mut self.holds.lock().publish,
451 conversation.id,
452 error.rate_limit_delay(),
453 );
454 if error.is_retryable() && error.rate_limit_delay().is_none() {
457 break;
458 }
459 }
460 }
461 }
462 Ok(published)
463 }
464
465 fn prepare_conversation_publish(
471 &self,
472 conn: &impl DbQuery,
473 conversation_id: &GroupId,
474 ) -> Result<Option<PendingPublish>, CommitLogError> {
475 let recovery = crate::state_tx::state_write(self.context.mls_storage(), |tx| {
478 tx.with_group(*conversation_id, |group, storage| {
479 use crate::groups::mls_ext::CommitLogStorer;
480 let db = storage.db();
481 let permits = group
482 .recover_commit_log_publisher(&db, self.context.inbox_id().as_ref())
483 .map_err(GroupError::from)?;
484 Ok::<_, GroupError>((permits, db.get_local_commit_log_cursor(conversation_id)))
487 })
488 .map(Continue)
489 });
490 let (permits, local_commit_log_cursor) = match recovery {
491 Ok(outcome) => outcome.into_continued(),
492 Err(error) if error.needs_db_reconnect() => return Err(error.into()),
493 Err(error) => {
494 tracing::warn!(
495 group_id = %conversation_id.as_slice().snippet(),
496 %error,
497 "A conversation's publisher state did not load; it skips publication this turn"
498 );
499 return Ok(None);
500 }
501 };
502
503 if !permits {
504 return Ok(None);
505 }
506 let local_commit_log_cursor = local_commit_log_cursor?.unwrap_or(0);
509 let membership_floor = conn
510 .get_latest_chain_start_rowid(conversation_id)?
511 .unwrap_or(0) as u64;
512 let published_commit_log_cursor = conn
513 .get_last_cursor(
514 conversation_id,
515 xmtp_db::refresh_state::EntityKind::CommitLogUpload,
516 )?
517 .0
518 .max(membership_floor);
519 if local_commit_log_cursor <= published_commit_log_cursor as i32 {
520 return Ok(None);
522 }
523
524 let mut logs = conn.get_local_commit_log_after_cursor(
527 conversation_id,
528 published_commit_log_cursor as i64,
529 LocalCommitLogOrder::AscendingByRowid,
530 )?;
531 logs.retain(|log| log.rowid <= local_commit_log_cursor);
532 let Some(max_rowid) = logs.last().map(|log| log.rowid) else {
533 return Ok(None);
534 };
535 let removed_from_group_marker = CommitType::RemovedFromGroup.to_string();
545 let (entry_rowids, plaintext_commit_log_entries): (Vec<i64>, Vec<PlaintextCommitLogEntry>) =
546 logs.iter()
547 .filter(|log| {
548 log.commit_type.as_deref() != Some(removed_from_group_marker.as_str())
549 })
550 .map(|log| (log.rowid as i64, PlaintextCommitLogEntry::from(log)))
551 .unzip();
552 let entries = if plaintext_commit_log_entries.is_empty() {
553 Vec::new()
554 } else {
555 let Some(entries) =
556 self.seal_group_logs(conversation_id, &plaintext_commit_log_entries)?
557 else {
558 return Ok(None);
559 };
560 entries
561 };
562 Ok(Some(PendingPublish {
563 cursor_info: ConversationCursorInfo {
564 conversation_id: conversation_id.to_vec(),
565 num_entries_published: plaintext_commit_log_entries.len(),
566 last_entry_published_sequence_id: plaintext_commit_log_entries
567 .last()
568 .map(|e| e.commit_sequence_id as i64)
569 .unwrap_or(0),
570 last_entry_published_rowid: max_rowid as i64,
571 entry_rowids,
572 },
573 entries,
574 }))
575 }
576
577 fn seal_group_logs(
583 &self,
584 conversation_id: &GroupId,
585 plaintext_commit_log_entries: &[PlaintextCommitLogEntry],
586 ) -> Result<Option<Vec<SealedCommitLogEntry>>, CommitLogError> {
587 let Some(keys) = self.group_keys(conversation_id)? else {
588 return Ok(None);
589 };
590 let mut sealed = Vec::with_capacity(plaintext_commit_log_entries.len());
591 for entry in plaintext_commit_log_entries {
592 let mut entry = entry.clone();
593 let sealed_entry = sign_entry_author(
594 &keys,
595 &self.context.identity().installation_keys,
596 &mut entry,
597 )
598 .and_then(|()| seal_entry(&keys, &entry));
599 match self.apply_seal_fault(conversation_id, sealed_entry) {
600 Ok(entry) => sealed.push(entry),
601 Err(error) => {
602 tracing::error!(
603 group_id = %conversation_id.as_slice().snippet(),
604 %error,
605 "Sealing a commit log entry failed; the conversation publishes nothing this turn"
606 );
607 return Ok(None);
608 }
609 }
610 }
611 Ok(Some(sealed))
612 }
613
614 #[cfg(not(test))]
615 fn apply_seal_fault(
616 &self,
617 _conversation_id: &GroupId,
618 sealed: Result<SealedCommitLogEntry, SealError>,
619 ) -> Result<SealedCommitLogEntry, SealError> {
620 sealed
621 }
622
623 #[cfg(test)]
624 fn apply_seal_fault(
625 &self,
626 conversation_id: &GroupId,
627 sealed: Result<SealedCommitLogEntry, SealError>,
628 ) -> Result<SealedCommitLogEntry, SealError> {
629 match &self.seal_fault {
630 Some(fault) => fault(conversation_id, sealed),
631 None => sealed,
632 }
633 }
634
635 fn group_keys(&self, conversation_id: &GroupId) -> Result<Option<GroupKeys>, CommitLogError> {
641 let group_context = MlsGroup::new_cached(self.context.clone(), conversation_id)
642 .map_err(GroupError::from)
643 .and_then(|(group, _)| group.load_group_context());
644 let group_context = match group_context {
645 Ok(group_context) => group_context,
646 Err(error) if error.needs_db_reconnect() => return Err(error.into()),
647 Err(error) => {
648 tracing::warn!(
649 group_id = %conversation_id.as_slice().snippet(),
650 %error,
651 "A conversation's group state did not load; it skips the commit log this turn"
652 );
653 return Ok(None);
654 }
655 };
656 match group_keys_from_extensions(group_context.extensions()) {
657 Ok(keys) => Ok(Some(keys)),
658 Err(error) => {
659 tracing::warn!(
660 group_id = %conversation_id.as_slice().snippet(),
661 %error,
662 "A conversation without a group root has no commit log"
663 );
664 Ok(None)
665 }
666 }
667 }
668
669 async fn save_remote_commit_log(&mut self) -> Result<HashMap<Vec<u8>, usize>, CommitLogError> {
672 let conn = &self.context.db();
673 let conversations: HashMap<Vec<u8>, GroupId> = conn
677 .get_conversation_ids_for_remote_log_download()?
678 .into_iter()
679 .filter_map(|conversation| Some((conversation.commit_log_public_key?, conversation.id)))
680 .collect();
681
682 let remote_log_cursors = conn.get_remote_log_cursors(
684 conversations
685 .values()
686 .map(|group_id| group_id.as_slice())
687 .collect::<Vec<_>>()
688 .as_slice(),
689 )?;
690 let query_log_requests: TopicCursor = conversations
691 .iter()
692 .map(|(commit_log_id, group_id)| {
693 let cursor = remote_log_cursors
694 .get(group_id.as_slice())
695 .copied()
696 .unwrap_or_default();
697 (Topic::new_commit_log(commit_log_id), cursor)
698 })
699 .collect();
700
701 if query_log_requests.is_empty() {
703 tracing::debug!("No commit log requests to query");
704 return Ok(HashMap::new());
705 }
706
707 let mut keys_by_group = HashMap::new();
708 let mut cursors = query_log_requests;
709 for group_id in conversations.values() {
710 if let Some(keys) = self.group_keys(group_id)? {
711 keys_by_group.insert(*group_id, keys);
712 }
713 }
714 cursors.retain(|topic, _| {
715 conversations
716 .get(topic.identifier())
717 .is_some_and(|group| keys_by_group.contains_key(group))
718 });
719 let limits = self.context.incoming_runtime().policy().fetched_limits();
720 let mut save_remote_commit_log_results = HashMap::new();
721 self.context
722 .api()
723 .fold_ordered_pages(cursors, limits, |batches, cursors| {
724 for batch in batches {
725 let Some(group_id) = conversations.get(batch.topic.identifier()) else {
726 continue;
727 };
728 let Some(keys) = keys_by_group.get(group_id) else {
729 continue;
730 };
731 let entries = batch
732 .envelopes
733 .into_iter()
734 .map(|envelope| {
735 xmtp_api_backend::envelope::decode_commit_log_entry(envelope.decode()?)
736 })
737 .collect::<Result<Vec<_>, _>>()
738 .map_err(ApiError::from)?;
739 let (num_entries, held) = self
740 .save_remote_commit_log_entries_and_update_cursors(
741 conn, group_id, keys, entries,
742 )?;
743 *save_remote_commit_log_results
744 .entry(group_id.to_vec())
745 .or_insert(0) += num_entries;
746 if held {
747 cursors.remove(&batch.topic);
750 }
751 }
752 Ok::<_, CommitLogError>(())
753 })
754 .await?;
755
756 Ok(save_remote_commit_log_results)
757 }
758
759 fn save_remote_commit_log_entries_and_update_cursors(
763 &self,
764 conn: &impl DbQuery,
765 group_id: &GroupId,
766 keys: &GroupKeys,
767 entries: Vec<DecodedCommitLogEntry>,
768 ) -> Result<(usize, bool), CommitLogError> {
769 let processed = conn
774 .latest_cursor_for_id(
775 group_id,
776 &[xmtp_db::refresh_state::EntityKind::ApplicationMessage],
777 )?
778 .0;
779 let (active, publishers, boundaries, reader_epoch_floor) =
780 crate::state_tx::state_write(self.context.mls_storage(), |tx| {
781 tx.with_group(*group_id, |g, storage| {
782 use crate::groups::mls_ext::CommitLogStorer;
783 Ok::<_, GroupError>((
784 g.is_active(),
785 ForkActors::from_group(g)?.publisher_keys(g)?,
786 storage.db().get_commit_log_publisher_boundaries(group_id)?,
787 g.initialize_commit_log_reader_epoch(&storage.db())?,
788 ))
789 })
790 .map(Continue)
791 })?
792 .into_continued();
793 let mut evaluated_to = None;
794 let mut held = false;
795 let mut num_entries_saved = 0;
796 let mut latest_saved_remote_log = conn.get_latest_remote_log_for_group(group_id)?;
801 for decoded in &entries {
802 let Some(log_entry) = open_remote_entry(keys, group_id, decoded) else {
803 evaluated_to = Some(decoded.cursor);
804 continue;
805 };
806 if log_entry.applied_epoch_number < reader_epoch_floor {
808 evaluated_to = Some(decoded.cursor);
809 continue;
810 }
811 if active && log_entry.commit_sequence_id > processed {
812 held = true;
813 break;
814 }
815 evaluated_to = Some(decoded.cursor);
816 if !publishers.contains(&log_entry.author_installation_key) {
817 continue;
818 }
819 let chain_holds = latest_saved_remote_log.as_ref().is_some_and(|latest| {
820 let boundary = boundaries
821 .get(&latest.author_installation_key)
822 .copied()
823 .unwrap_or(0);
824 publishers.contains(&latest.author_installation_key)
825 && !(latest.commit_sequence_id < boundary
826 && log_entry.commit_sequence_id >= boundary as u64)
827 });
828 if should_skip_remote_commit_log_entry(
829 latest_saved_remote_log.as_ref(),
830 &log_entry,
831 chain_holds,
832 ) {
833 continue;
834 }
835
836 let commit_result = CommitResult::from(
837 ProtoCommitResult::try_from(log_entry.commit_result)
838 .unwrap_or(ProtoCommitResult::Unspecified),
839 );
840 num_entries_saved += 1;
841 NewRemoteCommitLog {
842 log_sequence_id: decoded.cursor.0 as i64,
843 group_id: *group_id,
844 commit_sequence_id: log_entry.commit_sequence_id as i64,
845 commit_result,
846 applied_epoch_number: log_entry.applied_epoch_number as i64,
847 applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
848 author_installation_key: log_entry.author_installation_key.clone(),
849 }
850 .store(conn)?;
851
852 latest_saved_remote_log = Some(RemoteCommitLog {
853 rowid: 0,
854 log_sequence_id: decoded.cursor.0 as i64,
855 group_id: *group_id,
856 commit_sequence_id: log_entry.commit_sequence_id as i64,
857 commit_result,
858 applied_epoch_number: log_entry.applied_epoch_number as i64,
859 applied_epoch_authenticator: log_entry.applied_epoch_authenticator,
860 author_installation_key: log_entry.author_installation_key,
861 });
862 }
863 if let Some(cursor) = evaluated_to {
864 conn.update_cursor(
865 group_id,
866 xmtp_db::refresh_state::EntityKind::CommitLogDownload,
867 cursor,
868 )?;
869 }
870
871 Ok((num_entries_saved, held))
872 }
873
874 pub async fn update_forked_state(&mut self) -> Result<(), CommitLogError> {
876 let conversation_ids_for_forked_state_check =
877 self.context.db().get_conversation_ids_for_fork_check()?;
878
879 for conversation_id in conversation_ids_for_forked_state_check {
880 self.refresh_fork_state(&GroupId::try_from(conversation_id)?)?;
881 tokio::task::yield_now().await;
882 }
883
884 Ok(())
885 }
886
887 fn refresh_fork_state(
891 &self,
892 conversation_id: &GroupId,
893 ) -> Result<Option<bool>, CommitLogError> {
894 crate::state_tx::state_write_with_events(
895 self.context.mls_storage(),
896 self.context.events(),
897 |tx, events| {
898 let storage = tx.storage();
899 let db = storage.db();
900 if db.get_group_commit_log_forked_status(conversation_id)? == Some(true) {
901 return Ok::<_, CommitLogError>(Continue(Some(true)));
902 }
903 let is_forked = self.check_conversation_fork_state(&db, conversation_id)?;
904 db.set_group_commit_log_forked_status(conversation_id, is_forked)?;
905 if is_forked == Some(true) {
906 let dm_id = db
907 .find_group(conversation_id)?
908 .and_then(|group| group.dm_id);
909 events.emit_with_context(
910 Some(ClientEvent::ConversationForkDetected(GroupRef {
911 group_id: conversation_id.to_vec(),
912 })),
913 None,
914 dm_event_context(dm_id.as_deref()),
915 );
916 }
917 Ok(Continue(is_forked))
918 },
919 )
920 .map(TransactionOutcome::into_continued)
921 }
922
923 async fn permitted_readders(&self, group_id: &GroupId) -> Result<Vec<String>, CommitLogError> {
926 let (group, _) = MlsGroup::new_cached(self.context.clone(), group_id)?;
927 Ok(group.with_group_snapshot(|g| {
928 Ok::<_, GroupError>(ForkActors::from_group(g)?.request_targets(self.context.inbox_id()))
929 })?)
930 }
931
932 async fn request_readd(
933 &mut self,
934 group_info: StoredGroupForReaddRequest,
935 ) -> Result<(), CommitLogError> {
936 let conn = self.context.db();
937 let group_id = group_info.group_id;
938
939 if conn.is_awaiting_readd(&group_id, self.context.installation_id().as_slice())? {
941 tracing::debug!(
942 group_id = %group_id,
943 "Skipping readd request for group because it has already been requested"
944 );
945 return Ok(());
946 }
947
948 tracing::debug!(group_id = %group_id, "Sending readd request");
949
950 let latest_commit_sequence_id = group_info
952 .latest_commit_sequence_id
953 .ok_or(CommitLogError::MissingLatestCommitSequenceId { group_id })?;
954 let oneshot_message = OneshotMessage {
955 message_type: Some(MessageType::ReaddRequest(ReaddRequest {
956 group_id: group_id.to_vec(),
957 latest_commit_sequence_id: latest_commit_sequence_id as u64,
958 })),
959 };
960 let readders = self.permitted_readders(&group_id).await?;
961 tracing::debug!(
962 group_id = %group_id,
963 "Sending readd request to {:?}",
964 readders
965 );
966 conn.update_requested_at_sequence_id(
968 &group_id,
969 self.context.installation_id().as_slice(),
970 latest_commit_sequence_id,
971 )?;
972
973 if let Err((error, definitely_unsent)) =
974 Oneshot::send_message_with_outcome(self.context.clone(), readders, oneshot_message)
975 .await
976 {
977 if definitely_unsent {
978 conn.delete_readd_statuses(
979 &group_id,
980 HashSet::from([self.context.installation_id().to_vec()]),
981 )?;
982 }
983 return Err(error.into());
984 }
985 tracing::debug!(group_id = %group_id, "Sent readd request",);
986 tracing::debug!(
987 group_id = %group_id,
988 sequence_id = latest_commit_sequence_id,
989 "Updated requested readd sequence id",
990 );
991
992 Ok(())
993 }
994
995 async fn send_outgoing_readd_requests(&mut self) -> Result<(), CommitLogError> {
997 if self.context.fork_recovery_opts().enable_recovery_requests == ForkRecoveryPolicy::None {
998 return Ok(());
999 }
1000 let conn = self.context.db();
1001
1002 let mut forked_groups = conn.get_conversation_ids_for_requesting_readds()?;
1004 if self.context.fork_recovery_opts().enable_recovery_requests
1005 == ForkRecoveryPolicy::AllowlistedGroups
1006 {
1007 let groups_to_request_recovery = self
1008 .context
1009 .fork_recovery_opts()
1010 .groups_to_request_recovery
1011 .iter()
1012 .map(|group_id| group_id.normalize_hex())
1013 .collect::<HashSet<String>>();
1014 tracing::info!(
1015 "Forked groups: {:?}, allowlisted groups for sending recovery requests: {:?}",
1016 forked_groups
1017 .iter()
1018 .map(|group_info| group_info.group_id.to_string())
1019 .collect::<Vec<String>>(),
1020 groups_to_request_recovery
1021 );
1022 forked_groups.retain(|group_info| {
1023 groups_to_request_recovery
1024 .contains(&group_info.group_id.to_string().normalize_hex())
1025 });
1026 }
1027
1028 let mut failures = Vec::new();
1031 for group_info in forked_groups {
1032 let group_id = group_info.group_id;
1033 if let Err(source) = self.request_readd(group_info).await {
1034 if source.needs_db_reconnect() {
1035 return Err(source);
1036 }
1037 failures.push(CommitLogError::FailedToSendReadd {
1038 group_id,
1039 source: Box::new(source),
1040 });
1041 }
1042 }
1043
1044 if !failures.is_empty() {
1045 return Err(CommitLogError::FailedReadds { errors: failures });
1046 }
1047
1048 Ok(())
1049 }
1050
1051 async fn handle_incoming_pending_readds(&mut self) -> Result<(), CommitLogError> {
1052 if self.context.fork_recovery_opts().disable_recovery_responses {
1053 return Ok(());
1054 }
1055 let now = xmtp_common::time::Instant::now();
1056 self.holds.lock().readd.retain(|_, until| now < *until);
1057 let conn = self.context.db();
1058 let groups_for_readd = conn.get_conversation_ids_for_responding_readds()?;
1059
1060 if groups_for_readd.is_empty() {
1062 return Ok(());
1063 }
1064 tracing::debug!(
1065 "Processing readd requests for {} groups",
1066 groups_for_readd.len()
1067 );
1068
1069 for group in groups_for_readd {
1070 if self.holds.lock().readd.contains_key(&group.group_id) {
1074 continue;
1075 }
1076 match self.validate_pending_readds(&conn, &group).await {
1077 Ok(validated_installations) => {
1078 if validated_installations.is_empty() {
1079 continue;
1080 }
1081 let mls_group = MlsGroup::new(
1082 self.context.clone(),
1083 group.group_id,
1084 group.dm_id.clone(),
1085 group.conversation_type,
1086 group.created_at_ns,
1087 );
1088 if let Err(e) = mls_group
1089 .readd_installations(
1090 validated_installations.into_iter().collect::<Vec<_>>(),
1091 )
1092 .await
1093 {
1094 let e = CommitLogError::from(e);
1095 if e.needs_db_reconnect() {
1096 return Err(e);
1097 }
1098 hold(
1099 &mut self.holds.lock().readd,
1100 group.group_id,
1101 e.rate_limit_delay(),
1102 );
1103 tracing::warn!(
1104 group_id = group.group_id.short_hex(),
1105 "Keeping readd requests for group after a failed readd: {}",
1106 e
1107 );
1108 }
1109 }
1110 Err(e) if e.needs_db_reconnect() => return Err(e),
1111 Err(e @ CommitLogError::GroupReaddValidationError(_)) => {
1114 tracing::warn!(
1115 group_id = %group.group_id,
1116 "Deleting readd statuses for group because it failed validation: {}",
1117 e
1118 );
1119 conn.delete_other_readd_statuses(
1120 &group.group_id,
1121 self.context.installation_id().as_slice(),
1122 )?;
1123 }
1124 Err(e) => {
1126 hold(
1127 &mut self.holds.lock().readd,
1128 group.group_id,
1129 e.rate_limit_delay(),
1130 );
1131 tracing::warn!(
1132 group_id = group.group_id.short_hex(),
1133 "Keeping readd requests for group after a failed validation: {}",
1134 e
1135 );
1136 }
1137 }
1138 }
1139
1140 Ok(())
1141 }
1142
1143 async fn validate_pending_readds(
1145 &self,
1146 conn: &impl DbQuery,
1147 group: &StoredGroupForRespondingReadds,
1148 ) -> Result<HashSet<Vec<u8>>, CommitLogError> {
1149 let (mls_group, _) = MlsGroup::new_cached(self.context.clone(), &group.group_id)?;
1150 tracing::debug!(
1151 group_id = %mls_group.group_id,
1152 "Processing readd requests for group"
1153 );
1154
1155 let synced = mls_group.sync_with_conn().await;
1156 if mls_group.consent_state()? != ConsentState::Allowed {
1159 return Err(CommitLogError::GroupReaddValidationError(
1160 "Group is not consented".to_string(),
1161 ));
1162 }
1163 if !mls_group.is_active()? {
1164 return Err(CommitLogError::GroupReaddValidationError(
1165 "Group is not active".to_string(),
1166 ));
1167 }
1168 synced?;
1170 let actors =
1171 mls_group.with_group_snapshot(|g| Ok::<_, GroupError>(ForkActors::from_group(g)?))?;
1172 if !actors.permits(self.context.inbox_id()) {
1173 return Err(CommitLogError::GroupReaddValidationError(
1174 "No longer a permitted readder of group".to_string(),
1175 ));
1176 }
1177
1178 let fork_state = self.refresh_fork_state(&mls_group.group_id)?;
1180 if fork_state == Some(true) {
1181 tracing::info!(
1182 group_id = mls_group.group_id.short_hex(),
1183 "Responder is forked, keeping readd requests"
1184 );
1185 return Ok(HashSet::new());
1186 } else if fork_state.is_none() {
1187 tracing::info!(
1188 group_id = %mls_group.group_id,
1189 "Local commit log ahead of remote, skipping group"
1190 );
1191 return Ok(HashSet::new());
1192 }
1193
1194 let readd_statuses = conn.get_readds_awaiting_response(
1195 &mls_group.group_id,
1196 self.context.installation_id().as_slice(),
1197 )?;
1198 let mut unverified = readd_statuses
1199 .iter()
1200 .map(|readd_status| readd_status.installation_id.clone())
1201 .collect::<HashSet<_>>();
1202
1203 let (unverified, verified) = mls_group.with_group_snapshot(|openmls_group| {
1204 let mut verified = HashSet::new();
1205 for member in openmls_group.members() {
1206 if unverified.contains(&member.signature_key) {
1207 unverified.remove(&member.signature_key);
1208 verified.insert(member.signature_key);
1209 }
1210 }
1211 Ok::<_, GroupError>((unverified, verified))
1212 })?;
1213 tracing::debug!(
1214 group_id = %mls_group.group_id,
1215 "{} readd requests were for non-members, while {} were for members",
1216 unverified.len(),
1217 verified.len()
1218 );
1219 conn.delete_readd_statuses(&mls_group.group_id, unverified)?;
1220
1221 let processed = conn
1222 .latest_cursor_for_id(
1223 mls_group.group_id,
1224 &[xmtp_db::refresh_state::EntityKind::ApplicationMessage],
1225 )?
1226 .0;
1227 let now = now_ns();
1228 Ok(readd_statuses
1231 .into_iter()
1232 .filter(|status| {
1233 verified.contains(&status.installation_id)
1234 && retry_due(&status.installation_id, now)
1235 && status.requested_at_sequence_id.is_some_and(|requested| {
1236 u64::try_from(requested).is_ok_and(|requested| requested <= processed)
1237 })
1238 })
1239 .map(|status| status.installation_id)
1240 .collect())
1241 }
1242
1243 fn check_conversation_fork_state(
1244 &self,
1245 conn: &impl DbQuery,
1246 conversation_id: &GroupId,
1247 ) -> Result<Option<bool>, CommitLogError> {
1248 let fork_check_local_cursor = conn.get_last_cursor(
1250 conversation_id,
1251 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1252 )?;
1253 let fork_check_remote_cursor = conn.get_last_cursor(
1254 conversation_id,
1255 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1256 )?;
1257
1258 let mut local_cursor = fork_check_local_cursor.0 as i64;
1267 let mut crossed_chain_start = false;
1268 if let Some(anchor_rowid) = conn.get_latest_chain_start_rowid(conversation_id)?
1269 && anchor_rowid as i64 > local_cursor
1270 {
1271 local_cursor = anchor_rowid as i64;
1272 crossed_chain_start = true;
1273 conn.update_cursor(
1274 conversation_id,
1275 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1276 Cursor(anchor_rowid as u64),
1277 )?;
1278 }
1279
1280 let local_logs = conn.get_local_commit_log_after_cursor(
1282 conversation_id,
1283 local_cursor,
1284 LocalCommitLogOrder::DescendingByRowid,
1285 )?;
1286 let remote_logs = conn.get_remote_commit_log_after_cursor(
1287 conversation_id,
1288 fork_check_remote_cursor.0 as i64,
1289 RemoteCommitLogOrder::DescendingByRowid,
1290 )?;
1291
1292 if local_logs.is_empty() {
1294 if crossed_chain_start {
1295 return Ok(None);
1299 }
1300 return Ok(conn.get_group_commit_log_forked_status(conversation_id)?);
1301 }
1302
1303 let mut is_remote_log_up_to_date = true;
1304 for local_log in &local_logs {
1306 if local_log.commit_result == CommitResult::Success
1315 && local_log.applied_epoch_authenticator == local_log.last_epoch_authenticator
1316 {
1317 conn.update_cursor(
1322 conversation_id,
1323 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1324 Cursor(local_log.rowid as u64),
1325 )?;
1326 continue;
1327 }
1328
1329 let Some(matching_remote_log) =
1330 self.find_matching_remote_log(&remote_logs, local_log.commit_sequence_id)
1331 else {
1332 is_remote_log_up_to_date = false;
1333 continue;
1334 };
1335 let is_mismatched = local_log.applied_epoch_authenticator
1337 != matching_remote_log.applied_epoch_authenticator;
1338
1339 if is_mismatched {
1340 tracing::warn!(
1343 group_id = %conversation_id.as_slice().snippet(),
1344 commit_sequence_id = local_log.commit_sequence_id,
1345 "Detected forked state: the local and remote epoch authenticators differ"
1346 );
1347 }
1348
1349 conn.update_cursor(
1351 conversation_id,
1352 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1353 Cursor(local_log.rowid as u64),
1354 )?;
1355 conn.update_cursor(
1356 conversation_id,
1357 xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1358 Cursor(matching_remote_log.rowid as u64),
1359 )?;
1360
1361 if is_mismatched {
1362 return Ok(Some(true));
1363 } else if is_remote_log_up_to_date {
1364 return Ok(Some(false));
1365 } else {
1366 return Ok(None);
1369 }
1370 }
1371
1372 Ok(None)
1373 }
1374
1375 fn find_matching_remote_log<'a>(
1376 &self,
1377 remote_logs: &'a [xmtp_db::remote_commit_log::RemoteCommitLog],
1378 commit_sequence_id: i64,
1379 ) -> Option<&'a xmtp_db::remote_commit_log::RemoteCommitLog> {
1380 remote_logs
1381 .iter()
1382 .find(|remote_log| remote_log.commit_sequence_id == commit_sequence_id)
1383 }
1384}
1385
1386fn open_remote_entry(
1392 keys: &GroupKeys,
1393 group_id: &GroupId,
1394 decoded: &DecodedCommitLogEntry,
1395) -> Option<PlaintextCommitLogEntry> {
1396 let commit_log_id = keys.commit_log_id();
1397 let Some(sealed) = &decoded.sealed else {
1398 tracing::warn!(
1399 commit_log_id = %commit_log_id.as_slice().snippet(),
1400 sequence_id = decoded.cursor.0,
1401 "Discarding a commit log envelope that holds no sealed entry"
1402 );
1403 return None;
1404 };
1405 open_entry(keys, group_id.as_slice(), decoded.cursor.0, sealed)
1406 .inspect_err(|reason| {
1407 tracing::warn!(
1408 commit_log_id = %commit_log_id.as_slice().snippet(),
1409 sequence_id = decoded.cursor.0,
1410 %reason,
1411 "Discarding a commit log entry"
1412 );
1413 })
1414 .ok()
1415}
1416
1417pub(crate) fn should_skip_remote_commit_log_entry(
1426 latest_saved_remote_log: Option<&RemoteCommitLog>,
1427 entry: &PlaintextCommitLogEntry,
1428 chain_holds: bool,
1429) -> bool {
1430 let Some(latest_saved_remote_log) = latest_saved_remote_log else {
1431 return false;
1432 };
1433
1434 if !chain_holds {
1435 return entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64;
1436 }
1437 let is_applied = entry.commit_result == ProtoCommitResult::Applied as i32;
1438
1439 entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64
1440 || (is_applied
1441 && !latest_saved_remote_log
1442 .applied_epoch_authenticator
1443 .is_empty()
1444 && entry.last_epoch_authenticator
1445 != latest_saved_remote_log.applied_epoch_authenticator)
1446 || (is_applied
1447 && entry.applied_epoch_number as i64
1448 != latest_saved_remote_log.applied_epoch_number + 1)
1449 || (!is_applied
1450 && (entry.applied_epoch_authenticator
1451 != latest_saved_remote_log.applied_epoch_authenticator
1452 || entry.applied_epoch_number as i64
1453 != latest_saved_remote_log.applied_epoch_number))
1454}
1455
1456#[cfg(test)]
1458impl<Context> CommitLogWorker<Context>
1459where
1460 Context: XmtpSharedContext + 'static,
1461{
1462 pub async fn _tick(&mut self) -> Result<(), CommitLogError> {
1463 self.tick().await
1464 }
1465
1466 pub async fn _run(&mut self) -> Result<(), CommitLogError> {
1468 self.run().await
1469 }
1470
1471 pub(crate) fn with_seal_fault(mut self, fault: SealFault) -> Self {
1473 self.seal_fault = Some(fault);
1474 self
1475 }
1476
1477 pub fn get_all_fork_statuses(&self) -> Result<HashMap<Vec<u8>, Option<bool>>, CommitLogError> {
1479 use xmtp_db::group::GroupQueryArgs;
1480 let conn = &self.context.db();
1481 let all_groups = conn.find_groups(GroupQueryArgs::default())?;
1483
1484 let mut results = HashMap::new();
1485 for group in all_groups {
1486 let fork_status = conn.get_group_commit_log_forked_status(&group.id)?;
1487 results.insert(group.id.to_vec(), fork_status);
1488 }
1489
1490 Ok(results)
1491 }
1492
1493 pub async fn run_test(
1495 &mut self,
1496 commit_log_test_function: CommitLogTestFunction,
1497 iterations: Option<usize>,
1498 ) -> Result<Vec<TestResult>, CommitLogError> {
1499 let mut test_results = Vec::new();
1500 match iterations {
1501 Some(n) => {
1502 for _ in 0..n {
1504 let test_result = self.test_helper(&commit_log_test_function).await?;
1505 test_results.push(test_result);
1506 }
1507 }
1508 None => {
1509 let test_result = self.test_helper(&commit_log_test_function).await?;
1510 test_results.push(test_result);
1511 }
1512 }
1513 Ok(test_results)
1514 }
1515
1516 async fn test_helper(
1517 &mut self,
1518 commit_log_test_function: &CommitLogTestFunction,
1519 ) -> Result<TestResult, CommitLogError> {
1520 let mut test_result = TestResult {
1521 save_remote_commit_log_results: None,
1522 publish_commit_log_results: None,
1523 is_forked: None,
1524 };
1525 match commit_log_test_function {
1526 CommitLogTestFunction::PublishCommitLogsToRemote => {
1527 let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1528 test_result.publish_commit_log_results = Some(publish_commit_log_results);
1529 }
1530 CommitLogTestFunction::SaveRemoteCommitLog => {
1531 let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1532 test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1533 }
1534 CommitLogTestFunction::CheckForkedState => {
1535 self.update_forked_state().await?;
1536 let is_forked = self.get_all_fork_statuses()?;
1537 test_result.is_forked = Some(is_forked);
1538 }
1539 CommitLogTestFunction::All => {
1540 let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1542 test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1543 self.update_forked_state().await?;
1544 let is_forked = self.get_all_fork_statuses()?;
1545 test_result.is_forked = Some(is_forked);
1546 let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1547 test_result.publish_commit_log_results = Some(publish_commit_log_results);
1548 }
1549 }
1550 Ok(test_result)
1551 }
1552}
1553
1554#[cfg(test)]
1555mod stored_cursor_tests {
1556 use super::*;
1557
1558 fn conversation(id: u8, count: usize) -> ConversationCursorInfo {
1561 let entry_rowids: Vec<i64> = (1..=count as i64).map(|n| n * 10).collect();
1562 ConversationCursorInfo {
1563 conversation_id: vec![id],
1564 num_entries_published: count,
1565 last_entry_published_sequence_id: count as i64,
1566 last_entry_published_rowid: entry_rowids.last().copied().unwrap_or(0) + 5,
1567 entry_rowids,
1568 }
1569 }
1570
1571 #[xmtp_common::test(unwrap_try = true)]
1577 fn a_partly_stored_backlog_resumes_from_its_first_unstored_entry() {
1578 let info = conversation(1, 1500);
1579 assert_eq!(info.stored_cursor(1000), Some(10_000));
1580 assert_eq!(info.stored_cursor(1), Some(10));
1581 assert_eq!(info.stored_cursor(1500), Some(15_005));
1582 assert_eq!(info.stored_cursor(0), None);
1583 }
1584
1585 #[xmtp_common::test(unwrap_try = true)]
1588 fn a_conversation_with_only_skipped_rows_moves_past_them() {
1589 assert_eq!(conversation(2, 0).stored_cursor(0), Some(5));
1590 }
1591}
1592
1593#[cfg(all(test, not(target_arch = "wasm32")))]
1594mod publication_snapshot_tests {
1595 use super::*;
1596 use crate::groups::UpdateAdminListType::{AddSuper, RemoveSuper};
1597 use crate::prelude::*;
1598 use crate::tester;
1599 use std::sync::{Arc, Mutex, mpsc};
1600 use xmtp_db::mock::MockDbQuery;
1601
1602 #[xmtp_common::test(unwrap_try = true)]
1604 async fn publication_excludes_a_demotion_applied_after_its_authority_snapshot() {
1605 tester!(alix, disable_workers);
1606 tester!(bo, disable_workers);
1607 let a = alix
1608 .create_group_with_members(&[bo.inbox_id()], None, None)
1609 .await?;
1610 bo.sync_welcomes().await?;
1611 let b = bo.group(&a.group_id)?;
1612 a.update_admin_list(AddSuper, bo.inbox_id().to_string())
1613 .await?;
1614 b.sync().await?;
1615 let mut worker = CommitLogWorker::new(alix.context.clone());
1616 worker.publish_commit_logs_to_remote().await?;
1617
1618 a.update_group_name("pending before demotion".into())
1619 .await?;
1620 b.sync().await?;
1621 let pending = alix.context.db().get_latest_log_for_group(&a.group_id)??;
1622 b.update_admin_list(RemoveSuper, alix.inbox_id().to_string())
1623 .await?;
1624 let demotion = bo.context.db().get_latest_log_for_group(&a.group_id)??;
1625
1626 let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1627 let (resume_tx, resume_rx) = mpsc::channel();
1628 let gate = Arc::new(Mutex::new(Some((ready_tx, resume_rx))));
1629 let pause = Arc::new(move || {
1630 let barrier = gate.lock().unwrap().take();
1631 if let Some((ready, resume)) = barrier {
1632 ready.send(()).unwrap();
1633 resume.recv_timeout(Duration::from_secs(30)).unwrap();
1634 }
1635 });
1636 let context = alix.context.clone();
1637 let group_id = a.group_id;
1638 let preparation = std::thread::spawn(move || {
1639 let mut conn = MockDbQuery::new();
1640 let db = context.db();
1644 let pause_cursor = pause.clone();
1645 conn.expect_get_local_commit_log_cursor()
1646 .returning(move |id| {
1647 pause_cursor();
1648 db.get_local_commit_log_cursor(id)
1649 });
1650 let db = context.db();
1651 conn.expect_get_latest_chain_start_rowid()
1652 .returning(move |id| {
1653 pause();
1654 db.get_latest_chain_start_rowid(id)
1655 });
1656 let db = context.db();
1657 conn.expect_get_last_cursor()
1658 .returning(move |id, kind| db.get_last_cursor(id, kind));
1659 let db = context.db();
1660 conn.expect_get_local_commit_log_after_cursor()
1661 .returning(move |id, cursor, order| {
1662 db.get_local_commit_log_after_cursor(id, cursor, order)
1663 });
1664 CommitLogWorker::new(context)
1665 .prepare_conversation_publish(&conn, &group_id)
1666 .unwrap()
1667 .unwrap()
1668 });
1669
1670 xmtp_common::time::timeout(Duration::from_secs(30), ready_rx).await??;
1671 a.sync().await?;
1672 assert!(
1673 !alix
1674 .context
1675 .db()
1676 .find_group(&a.group_id)??
1677 .should_publish_commit_log
1678 );
1679 assert_eq!(
1680 alix.context
1681 .db()
1682 .get_latest_log_for_group(&a.group_id)??
1683 .commit_sequence_id,
1684 demotion.commit_sequence_id
1685 );
1686 resume_tx.send(())?;
1687 let prepared = preparation.join().unwrap();
1688
1689 let mut stored = Vec::new();
1690 alix.context
1691 .api()
1692 .publish_commit_log(prepared.entries, &mut stored)
1693 .await?;
1694 let keys = worker.group_keys(&a.group_id)??;
1695 let topic = Topic::new_commit_log(keys.commit_log_id());
1696 let remote = alix
1697 .context
1698 .api()
1699 .query_commit_log([(topic, Cursor(0))].into())
1700 .await?;
1701 let entries = remote
1702 .iter()
1703 .map(|entry| {
1704 open_entry(
1705 &keys,
1706 a.group_id.as_slice(),
1707 entry.cursor.0,
1708 entry.sealed.as_ref().unwrap(),
1709 )
1710 .unwrap()
1711 })
1712 .collect::<Vec<_>>();
1713 assert!(
1714 entries
1715 .iter()
1716 .any(|entry| entry.commit_sequence_id == pending.commit_sequence_id as u64)
1717 );
1718 assert!(
1719 entries
1720 .iter()
1721 .all(|entry| entry.commit_sequence_id != demotion.commit_sequence_id as u64)
1722 );
1723 assert_eq!(
1724 prepared.cursor_info.stored_cursor(stored.len()),
1725 Some(i64::from(pending.rowid))
1726 );
1727 assert!(
1728 worker
1729 .prepare_conversation_publish(&alix.context.db(), &a.group_id)?
1730 .is_none()
1731 );
1732 }
1733}
1734
1735#[cfg(all(test, not(target_arch = "wasm32")))]
1738mod disconnect_propagation_tests {
1739 use super::CommitLogError;
1742 use crate::core::reconnect::NeedsDbReconnect;
1743 use crate::groups::GroupError;
1744 use xmtp_db::{ConnectionError, PlatformStorageError, StorageError};
1745
1746 fn disconnect_storage() -> StorageError {
1748 StorageError::Platform(PlatformStorageError::PoolNeedsConnection)
1749 }
1750
1751 fn disconnect_connection() -> ConnectionError {
1753 ConnectionError::Platform(PlatformStorageError::PoolNeedsConnection)
1754 }
1755
1756 #[xmtp_common::test]
1757 fn commit_log_error_forwards_disconnect() {
1758 assert!(CommitLogError::Connection(disconnect_connection()).needs_db_reconnect());
1759 assert!(
1760 CommitLogError::GroupError(GroupError::Storage(disconnect_storage()))
1761 .needs_db_reconnect()
1762 );
1763 assert!(
1764 CommitLogError::SyncError(crate::groups::summary::SyncSummary::other(
1765 GroupError::Storage(disconnect_storage()),
1766 ))
1767 .needs_db_reconnect()
1768 );
1769 assert!(
1771 !CommitLogError::Connection(ConnectionError::DisconnectInTransaction)
1772 .needs_db_reconnect()
1773 );
1774 }
1775}