Skip to main content

xmtp_mls/groups/
commit_log.rs

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;
47/// Interval at which the CommitLogWorker runs to publish commit log entries.
48pub const DEFAULT_INTERVAL_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes
49
50#[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    /// Commit-log signing failed. Not retryable.
95    #[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            // Only a `PoolNeedsConnection` connection error means the pool is gone.
142            Self::Connection(c) => c.db_needs_connection(),
143            // A dropped pool can be wrapped inside a GroupError; forward.
144            Self::GroupError(e) => e.needs_db_reconnect(),
145            // A readd send failure wraps an inner CommitLogError; forward.
146            Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
147            Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
148            // Remaining variants can't carry a dropped-pool signal.
149            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// Test related types
213#[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
228// CommitLogWorker implementation
229impl<Context> CommitLogWorker<Context>
230where
231    Context: XmtpSharedContext + 'static,
232{
233    async fn run(&mut self) -> Result<(), CommitLogError> {
234        // Precedence: fork-recovery override (clamped to >=2s) > WorkerConfig >
235        // const default. Fork recovery wins because its cadence is tied to
236        // recovery-protocol timing, not general worker tuning.
237        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        // Step 1 is to get the list of all group_id for dms and for groups where we are a super admin
265        let conversation_ids_for_remote_log_publish =
266            conn.get_conversation_ids_for_remote_log_publish()?;
267
268        // Step 2 is to prepare commit log entries for publishing along with the updated cursor for each conversation on publication success
269        let (conversation_cursor_info, all_entries) =
270            self.prepare_publish_commit_log_info(conn, &conversation_ids_for_remote_log_publish)?;
271
272        // Skip API call if there are no entries to publish
273        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        // Propagate a publish failure; cursors stay unadvanced so the restarted
284        // worker retries the same entries next turn.
285        let api = self.context.api();
286        api.publish_commit_log(all_entries).await?;
287
288        // Publishing was successful, let's update every group's cursor
289        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    // Check each `conversation_id` for new commit log entries. Return a combined list of all entries for batch publishing,
304    // along with the new cursor for each conversation on publication success
305    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            // Step 1: Check each conversation cursors to see if we have new commits that have not been published to remote commit log yet
314            // Propagate read errors (incl. a dropped pool) to the supervisor; a
315            // missing cursor legitimately defaults to 0.
316            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                // We have no new commits to publish for this conversation
328                continue;
329            }
330
331            // Step 2: collect all the commit log entries for this conversation
332            // Local commit log entries are returned sorted in ascending order of `rowid`
333            // All local commit log will have rowid > 0 since sqlite rowid starts at 1 https://www.sqlite.org/autoinc.html
334            let logs = conn.get_local_commit_log_after_cursor(
335                &conversation.id,
336                published_commit_log_cursor as i64,
337                LocalCommitLogOrder::AscendingByRowid,
338            )?;
339            // A commit that removed us is recorded locally (RemovedFromGroup,
340            // with the pre-commit epoch/authenticator) for debuggability, but
341            // must never be published: it does not attest the new epoch and
342            // could shadow the real consensus entry published by the
343            // remaining members. In practice a removed member is not a
344            // publisher (only super admins and DM participants publish, super
345            // admins cannot be removed, and DMs have no removals) — this
346            // filter makes that property structural. The publish cursor still
347            // advances past skipped rows.
348            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            // Step 3: Compile the conversation cursor info and all the commit log entries for this conversation
359            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    // Returns a map of conversation_id to the number of entries saved
405    async fn save_remote_commit_log(&mut self) -> Result<HashMap<Vec<u8>, usize>, CommitLogError> {
406        let conn = &self.context.db();
407        // This should be all groups we are in, and all dms are in except sync groups
408        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        // Step 1 is to collect a list of remote log cursors for all conversations and convert them into query log requests
415        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        // Skip API call if there are no requests to make
428        if query_log_requests.is_empty() {
429            tracing::debug!("No commit log requests to query");
430            return Ok(HashMap::new());
431        }
432
433        // Step 2 execute the api call to query remote commit log entries
434        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        // From the stored remote commit log, fetch the following info:
482        // 1. The latest applied epoch authenticator
483        // 2. The latest applied epoch number
484        // 3. The latest stored sequence id
485        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    // Should skip if:
560    // 1. The entry signature is invalid
561    // 2. The group_id of the entry does not match the requested group_id.
562    // 3. The commit_sequence_id of the entry is <= 0.
563    // 4. The commit_sequence_id of the entry is not greater than the most recently stored entry, if one exists.
564    // 5. The last_epoch_authenticator does not match the epoch_authenticator of the most recently stored entry with a CommitResult of COMMIT_RESULT_APPLIED, if one exists.
565    // 6. The entry has a CommitResult of COMMIT_RESULT_APPLIED, but the epoch number is not exactly 1 greater than the most recently stored entry with a result of COMMIT_RESULT_APPLIED, if one exists.
566    // 7. The entry CommitResult is not COMMIT_RESULT_APPLIED, and the epoch authenticator or epoch number does not match the most recently applied values
567    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        // These checks apply even if there is no latest saved remote log
576        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    // Updates fork status for conversations in the database
615    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                    // Persist the fork status to the database
629                    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    /// Returns the list of permitted readders for a group
640    /// Note: Does not return self - self is always a permitted readder
641    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        // Check if a readd request has already been sent for this group
663        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        // Send oneshot message with readd request to super admins
674        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        // Mark readd as requested
694        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    /// Send readd requests for all forked conversations
710    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        // Fetch all forked groups with their latest epoch
717        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        // Process groups in order, collecting per-group failures so one transient
743        // error doesn't block the rest; a dropped pool still bubbles immediately.
744        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        // Runs every worker tick (~2s); only speak when there's actually work.
773        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                    // Permanently-bad group: drop its readd statuses and move on.
802                    // Retryable failures (incl. a dropped pool) propagate instead.
803                    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        // Get cursors for this conversation
902        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        // Chain-start anchor: rows with `commit_sequence_id == 0` (Welcome /
912        // GroupCreation / BackupRestore) mark the beginning of this member's
913        // current membership session — e.g. rejoining via a new welcome after
914        // having been removed. History before the latest chain start is not
915        // attestable by this member and must not be compared against remote
916        // consensus. Those rows are filtered out of
917        // `get_local_commit_log_after_cursor`, so the anchor is looked up
918        // separately and applied as a floor on the local fork-check cursor.
919        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        // Get local and remote commit logs
934        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 there are no new commits to check, preserve the existing fork status
946        if local_logs.is_empty() {
947            if crossed_chain_start {
948                // A new chain start (e.g. a welcome from being re-added)
949                // invalidates any previously computed status: nothing after
950                // it has been verified against remote consensus yet.
951                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        // Check each local log against remote logs for matching commit_sequence_id
958        for local_log in &local_logs {
959            // Terminal removal marker: a commit that removed us merges only
960            // the public diff — we cannot derive the new epoch's secrets, so
961            // a Success row whose applied authenticator equals its last
962            // authenticator means "this commit removed us", not a fork. The
963            // remaining members publish the real post-commit authenticator,
964            // which can never match ours, so this row must be excluded from
965            // comparison. (Covers both new RemovedFromGroup rows and legacy
966            // rows that recorded the new epoch with the stale authenticator.)
967            if local_log.commit_result == CommitResult::Success
968                && local_log.applied_epoch_authenticator == local_log.last_epoch_authenticator
969            {
970                // Note: `update_cursor` is monotonic (it only ever moves the
971                // cursor forward), so the cursor updates below for older
972                // matched rows — the loop walks descending rowids — cannot
973                // regress the cursor behind this terminal row.
974                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            // Found a matching commit_sequence_id - check if forked
989            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            // Update cursors regardless of fork status (we found a match)
1004            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                // If we haven't verified the latest commit local commit logs, we
1021                // don't know if we are forked or not
1022                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// Helper that exposes private methods for testing
1041#[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    // Test helper to get fork status for all groups that would be checked (for backward compatibility with tests)
1068    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        // Get all groups (not just those with commit log keys)
1072        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    /// Test-only version that runs without infinite loop
1084    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                // Run exactly n times
1093                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                // Order is save; update fork status; publish
1131                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}