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_events::{ClientEvent, EventWriter, GroupRef};
47use xmtp_proto::types::GroupId;
48/// Interval at which the CommitLogWorker runs to publish commit log entries.
49pub const DEFAULT_INTERVAL_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes
50
51#[derive(Clone)]
52pub struct Factory<Context> {
53    context: Context,
54}
55
56impl<Context> WorkerFactory for Factory<Context>
57where
58    Context: XmtpSharedContext + 'static,
59{
60    fn kind(&self) -> WorkerKind {
61        WorkerKind::CommitLog
62    }
63
64    fn create(
65        &self,
66        metrics: Option<crate::worker::DynMetrics>,
67    ) -> (BoxedWorker, Option<crate::worker::DynMetrics>) {
68        (
69            Box::new(CommitLogWorker::new(self.context.clone())) as Box<_>,
70            metrics,
71        )
72    }
73}
74
75#[derive(Debug, Error)]
76pub enum CommitLogError {
77    #[error("generic storage error: {0}")]
78    Storage(#[from] StorageError),
79    #[error("diesel error: {0}")]
80    Diesel(#[from] xmtp_db::diesel::result::Error),
81    #[error("generic api error: {0}")]
82    Api(#[from] ApiError),
83    #[error("connection error: {0}")]
84    Connection(#[from] xmtp_db::ConnectionError),
85    #[error("prost decode error: {0}")]
86    Prost(#[from] prost::DecodeError),
87    #[error("keystore error: {0}")]
88    KeystoreError(#[from] xmtp_db::sql_key_store::SqlKeyStoreError),
89    #[error("group error: {0}")]
90    GroupError(#[from] GroupError),
91    #[error("crypto error: {0}")]
92    CryptoError(#[from] openmls_traits::types::CryptoError),
93    #[error("try from slice error: {0}")]
94    TryFromSliceError(#[from] std::array::TryFromSliceError),
95    /// Commit-log signing failed. Not retryable.
96    #[error(transparent)]
97    Signing(#[from] xmtp_mls_common::commit_log::CommitLogSigningError),
98    #[error("conversion error: {0}")]
99    Conversion(#[from] xmtp_proto::ConversionError),
100    #[error("Group did not pass readd validation: {0}")]
101    GroupReaddValidationError(String),
102    #[error("sync error: {0}")]
103    SyncError(#[from] SyncSummary),
104    #[error("failed to send readd request for group {group_id}: {source}")]
105    FailedToSendReadd {
106        group_id: GroupId,
107        source: Box<CommitLogError>,
108    },
109    #[error("{count} readd request(s) failed: {errors:?}", count = errors.len())]
110    FailedReadds { errors: Vec<CommitLogError> },
111    #[error("no latest commit sequence id found for forked group {group_id}")]
112    MissingLatestCommitSequenceId { group_id: GroupId },
113}
114
115impl RetryableError for CommitLogError {
116    fn is_retryable(&self) -> bool {
117        match self {
118            Self::Storage(storage_error) => storage_error.is_retryable(),
119            Self::Diesel(diesel_error) => diesel_error.is_retryable(),
120            Self::Api(api_error) => api_error.is_retryable(),
121            Self::Connection(connection_error) => connection_error.is_retryable(),
122            Self::Prost(_prost_error) => false,
123            Self::KeystoreError(keystore_error) => keystore_error.is_retryable(),
124            Self::GroupError(group_error) => group_error.is_retryable(),
125            Self::CryptoError(_crypto_error) => false,
126            Self::TryFromSliceError(_try_from_slice_error) => false,
127            Self::Signing(_) => false,
128            Self::Conversion(_) => false,
129            Self::GroupReaddValidationError(_group_readd_validation_error) => false,
130            Self::SyncError(sync_error) => sync_error.is_retryable(),
131            Self::FailedToSendReadd { source, .. } => source.is_retryable(),
132            Self::FailedReadds { errors } => errors.iter().any(|e| e.is_retryable()),
133            Self::MissingLatestCommitSequenceId { .. } => false,
134        }
135    }
136}
137
138impl NeedsDbReconnect for CommitLogError {
139    fn needs_db_reconnect(&self) -> bool {
140        match self {
141            Self::Storage(s) => s.db_needs_connection(),
142            // Only a `PoolNeedsConnection` connection error means the pool is gone.
143            Self::Connection(c) => c.db_needs_connection(),
144            // A dropped pool can be wrapped inside a GroupError; forward.
145            Self::GroupError(e) => e.needs_db_reconnect(),
146            // A readd send failure wraps an inner CommitLogError; forward.
147            Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
148            Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
149            // Remaining variants can't carry a dropped-pool signal.
150            Self::Diesel(_)
151            | Self::Api(_)
152            | Self::Prost(_)
153            | Self::KeystoreError(_)
154            | Self::CryptoError(_)
155            | Self::TryFromSliceError(_)
156            | Self::Signing(_)
157            | Self::Conversion(_)
158            | Self::GroupReaddValidationError(_)
159            | Self::SyncError(_)
160            | Self::MissingLatestCommitSequenceId { .. } => false,
161        }
162    }
163}
164
165#[xmtp_common::async_trait]
166impl<Context> Worker for CommitLogWorker<Context>
167where
168    Context: XmtpSharedContext + 'static,
169{
170    fn kind(&self) -> WorkerKind {
171        WorkerKind::CommitLog
172    }
173
174    async fn run_tasks(&mut self) -> WorkerResult<()> {
175        self.run().await.map_err(|e| Box::new(e) as Box<_>)
176    }
177
178    fn factory<C>(context: C) -> impl WorkerFactory + 'static
179    where
180        C: XmtpSharedContext + 'static,
181    {
182        Factory { context }
183    }
184}
185
186pub struct CommitLogWorker<Context> {
187    context: Context,
188}
189
190impl<Context> CommitLogWorker<Context>
191where
192    Context: XmtpSharedContext + 'static,
193{
194    pub fn new(context: Context) -> Self {
195        Self { context }
196    }
197}
198
199#[derive(Debug, PartialEq, Clone)]
200pub struct ConversationCursorInfo {
201    pub conversation_id: Vec<u8>,
202    pub num_entries_published: usize,
203    pub last_entry_published_sequence_id: i64,
204    pub last_entry_published_rowid: i64,
205}
206
207#[derive(Debug, PartialEq, Clone)]
208pub struct SaveRemoteCommitLogResult {
209    pub conversation_id: Vec<u8>,
210    pub num_entries_saved: usize,
211}
212
213// Test related types
214#[cfg(test)]
215pub enum CommitLogTestFunction {
216    PublishCommitLogsToRemote,
217    SaveRemoteCommitLog,
218    CheckForkedState,
219    All,
220}
221
222#[cfg(test)]
223pub struct TestResult {
224    pub save_remote_commit_log_results: Option<HashMap<Vec<u8>, usize>>,
225    pub publish_commit_log_results: Option<Vec<ConversationCursorInfo>>,
226    pub is_forked: Option<HashMap<Vec<u8>, Option<bool>>>,
227}
228
229// CommitLogWorker implementation
230impl<Context> CommitLogWorker<Context>
231where
232    Context: XmtpSharedContext + 'static,
233{
234    async fn run(&mut self) -> Result<(), CommitLogError> {
235        // Precedence: fork-recovery override (clamped to >=2s) > WorkerConfig >
236        // const default. Fork recovery wins because its cadence is tied to
237        // recovery-protocol timing, not general worker tuning.
238        let (mut worker_interval, jitter) = self
239            .context
240            .worker_interval(WorkerKind::CommitLog, DEFAULT_INTERVAL_DURATION);
241        if let Some(interval) = self.context.fork_recovery_opts().worker_interval_ns {
242            worker_interval = Duration::from_nanos(interval).max(Duration::from_secs(2));
243        }
244        let mut intervals = xmtp_common::time::jittered_interval_stream(worker_interval, jitter);
245        while (intervals.next().await).is_some() {
246            self.tick().await?;
247        }
248        Ok(())
249    }
250
251    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
252    pub async fn tick(&mut self) -> Result<(), CommitLogError> {
253        self.save_remote_commit_log().await?;
254        self.update_forked_state().await?;
255        self.publish_commit_logs_to_remote().await?;
256        self.send_outgoing_readd_requests().await?;
257        self.handle_incoming_pending_readds().await?;
258        Ok(())
259    }
260
261    async fn publish_commit_logs_to_remote(
262        &mut self,
263    ) -> Result<Vec<ConversationCursorInfo>, CommitLogError> {
264        let conn = &self.context.db();
265        // Step 1 is to get the list of all group_id for dms and for groups where we are a super admin
266        let conversation_ids_for_remote_log_publish =
267            conn.get_conversation_ids_for_remote_log_publish()?;
268
269        // Step 2 is to prepare commit log entries for publishing along with the updated cursor for each conversation on publication success
270        let (conversation_cursor_info, all_entries) =
271            self.prepare_publish_commit_log_info(conn, &conversation_ids_for_remote_log_publish)?;
272
273        // Skip API call if there are no entries to publish
274        if all_entries.is_empty() {
275            tracing::debug!("No commit log entries to publish");
276            return Ok(conversation_cursor_info);
277        }
278
279        tracing::info!(
280            "Publishing {} commit log entries to remote commit log",
281            all_entries.len()
282        );
283
284        // Propagate a publish failure; cursors stay unadvanced so the restarted
285        // worker retries the same entries next turn.
286        let api = self.context.api();
287        api.publish_commit_log(all_entries).await?;
288
289        // Publishing was successful, let's update every group's cursor
290        for conversation_cursor_info in &conversation_cursor_info {
291            tracing::debug!(
292                group_id = hex::encode(&conversation_cursor_info.conversation_id),
293                "Updating publish cursor",
294            );
295            conn.update_cursor(
296                &conversation_cursor_info.conversation_id,
297                xmtp_db::refresh_state::EntityKind::CommitLogUpload,
298                Cursor(conversation_cursor_info.last_entry_published_rowid as u64),
299            )?;
300        }
301        Ok(conversation_cursor_info)
302    }
303
304    // implements: FORK-021
305    // Check each `conversation_id` for new commit log entries. Return a combined list of all entries for batch publishing,
306    // along with the new cursor for each conversation on publication success
307    fn prepare_publish_commit_log_info(
308        &self,
309        conn: &impl DbQuery,
310        conversation_keys: &[StoredGroupCommitLogPublicKey],
311    ) -> Result<(Vec<ConversationCursorInfo>, Vec<CommitLogEntry>), CommitLogError> {
312        let mut conversation_cursor_info: Vec<ConversationCursorInfo> = Vec::new();
313        let mut all_entries = Vec::new();
314        for conversation in conversation_keys {
315            // Step 1: Check each conversation cursors to see if we have new commits that have not been published to remote commit log yet
316            // Propagate read errors (incl. a dropped pool) to the supervisor; a
317            // missing cursor legitimately defaults to 0.
318            let local_commit_log_cursor = conn
319                .get_local_commit_log_cursor(&conversation.id)?
320                .unwrap_or(0);
321            let published_commit_log_cursor = conn
322                .get_last_cursor(
323                    conversation.id,
324                    xmtp_db::refresh_state::EntityKind::CommitLogUpload,
325                )?
326                .0;
327
328            if local_commit_log_cursor <= published_commit_log_cursor as i32 {
329                // We have no new commits to publish for this conversation
330                continue;
331            }
332
333            // Step 2: collect all the commit log entries for this conversation
334            // Local commit log entries are returned sorted in ascending order of `rowid`
335            // All local commit log will have rowid > 0 since sqlite rowid starts at 1 https://www.sqlite.org/autoinc.html
336            let logs = conn.get_local_commit_log_after_cursor(
337                &conversation.id,
338                published_commit_log_cursor as i64,
339                LocalCommitLogOrder::AscendingByRowid,
340            )?;
341            // A commit that removed us is recorded locally (RemovedFromGroup,
342            // with the pre-commit epoch/authenticator) for debuggability, but
343            // must never be published: it does not attest the new epoch and
344            // could shadow the real consensus entry published by the
345            // remaining members. In practice a removed member is not a
346            // publisher (only super admins and DM participants publish, super
347            // admins cannot be removed, and DMs have no removals) — this
348            // filter makes that property structural. The publish cursor still
349            // advances past skipped rows.
350            let removed_from_group_marker = CommitType::RemovedFromGroup.to_string();
351            let max_rowid = logs.last().map(|log| log.rowid);
352            let plaintext_commit_log_entries: Vec<PlaintextCommitLogEntry> = logs
353                .iter()
354                .filter(|log| {
355                    log.commit_type.as_deref() != Some(removed_from_group_marker.as_str())
356                })
357                .map(PlaintextCommitLogEntry::from)
358                .collect();
359
360            // Step 3: Compile the conversation cursor info and all the commit log entries for this conversation
361            if let Some(max_rowid) = max_rowid {
362                let signed_entries =
363                    self.sign_group_logs(conversation, &plaintext_commit_log_entries)?;
364                // A missing signing key leaves this group's records pending.
365                // Another group's successful publish must not advance its cursor.
366                if signed_entries.is_empty() && !plaintext_commit_log_entries.is_empty() {
367                    continue;
368                }
369                all_entries.extend(signed_entries);
370                conversation_cursor_info.push(ConversationCursorInfo {
371                    conversation_id: conversation.id.to_vec(),
372                    num_entries_published: plaintext_commit_log_entries.len(),
373                    last_entry_published_sequence_id: plaintext_commit_log_entries
374                        .last()
375                        .map(|e| e.commit_sequence_id as i64)
376                        .unwrap_or(0),
377                    last_entry_published_rowid: max_rowid as i64,
378                });
379            }
380        }
381        Ok((conversation_cursor_info, all_entries))
382    }
383
384    fn sign_group_logs(
385        &self,
386        conversation: &StoredGroupCommitLogPublicKey,
387        plaintext_commit_log_entries: &[PlaintextCommitLogEntry],
388    ) -> Result<Vec<CommitLogEntry>, CommitLogError> {
389        let Some(private_key) = get_or_create_signing_key(&self.context, conversation)? else {
390            tracing::warn!(group_id = %conversation.id, "No signing key available for group");
391            return Ok(vec![]);
392        };
393
394        let provider = self.context.mls_provider();
395        let mut signed_entries = Vec::new();
396        for entry in plaintext_commit_log_entries {
397            let signed = xmtp_mls_common::commit_log::sign_commit_log(
398                entry,
399                &private_key,
400                provider.crypto(),
401            )?;
402
403            signed_entries.push(CommitLogEntry {
404                serialized_commit_log_entry: signed.serialized_commit_log_entry,
405                signature: Some(signed.signature),
406            });
407        }
408        Ok(signed_entries)
409    }
410
411    // Returns a map of conversation_id to the number of entries saved
412    async fn save_remote_commit_log(&mut self) -> Result<HashMap<Vec<u8>, usize>, CommitLogError> {
413        let conn = &self.context.db();
414        // This should be all groups we are in, and all dms are in except sync groups
415        let conversation_id_to_public_key: HashMap<Vec<u8>, Option<Vec<u8>>> = conn
416            .get_conversation_ids_for_remote_log_download()?
417            .into_iter()
418            .map(|c| (c.id.to_vec(), c.commit_log_public_key))
419            .collect();
420
421        // Step 1 is to collect a list of remote log cursors for all conversations and convert them into query log requests
422        let remote_log_cursors = conn.get_remote_log_cursors(
423            conversation_id_to_public_key
424                .keys()
425                .map(Vec::as_slice)
426                .collect::<Vec<_>>()
427                .as_slice(),
428        )?;
429        let query_log_requests: TopicCursor = remote_log_cursors
430            .into_iter()
431            .map(|(id, cursor)| (TopicKind::CommitLogEntriesV1.create(id), cursor))
432            .collect();
433
434        // Skip API call if there are no requests to make
435        if query_log_requests.is_empty() {
436            tracing::debug!("No commit log requests to query");
437            return Ok(HashMap::new());
438        }
439
440        // Step 2 execute the api call to query remote commit log entries
441        let api = self.context.api();
442        let query_commit_log_responses = api.query_commit_log(query_log_requests).await?;
443
444        let mut grouped: HashMap<Vec<u8>, Vec<DecodedCommitLogEntry>> = HashMap::new();
445        for entry in query_commit_log_responses {
446            grouped
447                .entry(entry.entry.group_id.clone())
448                .or_default()
449                .push(entry);
450        }
451        let mut save_remote_commit_log_results = HashMap::new();
452        for (group_id, entries) in grouped {
453            let mut consensus_public_key = conversation_id_to_public_key
454                .get(&group_id)
455                .and_then(Option::clone);
456            if consensus_public_key.is_none() {
457                consensus_public_key = derive_consensus_public_key(
458                    &self.context,
459                    &group_id,
460                    &entries
461                        .iter()
462                        .map(|entry| entry.payload.clone())
463                        .collect::<Vec<_>>(),
464                )
465                .await?;
466            }
467            let num_entries = self.save_remote_commit_log_entries_and_update_cursors(
468                conn,
469                &group_id,
470                entries,
471                consensus_public_key,
472            )?;
473            save_remote_commit_log_results.insert(group_id, num_entries);
474        }
475
476        Ok(save_remote_commit_log_results)
477    }
478
479    fn save_remote_commit_log_entries_and_update_cursors(
480        &self,
481        conn: &impl DbQuery,
482        group_id: &[u8],
483        entries: Vec<DecodedCommitLogEntry>,
484        consensus_public_key: Option<Vec<u8>>,
485    ) -> Result<usize, CommitLogError> {
486        let group_id = GroupId::try_from(group_id)?;
487        let mut num_entries_saved = 0;
488        // From the stored remote commit log, fetch the following info:
489        // 1. The latest applied epoch authenticator
490        // 2. The latest applied epoch number
491        // 3. The latest stored sequence id
492        if let Some(consensus_public_key) = consensus_public_key {
493            let mut latest_saved_remote_log = conn.get_latest_remote_log_for_group(&group_id)?;
494            for decoded in &entries {
495                let commit_log_entry = &decoded.payload;
496                let log_entry = &decoded.entry;
497                let sequence_id = decoded
498                    .meta
499                    .cursor
500                    .as_ref()
501                    .ok_or(xmtp_proto::ConversionError::Missing {
502                        item: "commit-log cursor",
503                        r#type: "EnvelopeMeta",
504                    })?
505                    .sequence_id;
506                if self.should_skip_remote_commit_log_entry(
507                    group_id.as_slice(),
508                    latest_saved_remote_log.clone(),
509                    commit_log_entry,
510                    log_entry,
511                    &consensus_public_key,
512                ) {
513                    continue;
514                }
515
516                let log_entry_group_id = GroupId::try_from(log_entry.group_id.as_slice())?;
517                num_entries_saved += 1;
518                NewRemoteCommitLog {
519                    log_sequence_id: sequence_id as i64,
520                    group_id: log_entry_group_id,
521                    commit_sequence_id: log_entry.commit_sequence_id as i64,
522                    commit_result: CommitResult::from(
523                        ProtoCommitResult::try_from(log_entry.commit_result)
524                            .unwrap_or(ProtoCommitResult::Unspecified),
525                    ),
526                    applied_epoch_number: log_entry.applied_epoch_number as i64,
527                    applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
528                }
529                .store(conn)?;
530
531                latest_saved_remote_log = Some(RemoteCommitLog {
532                    rowid: 0,
533                    log_sequence_id: sequence_id as i64,
534                    group_id: log_entry_group_id,
535                    commit_sequence_id: log_entry.commit_sequence_id as i64,
536                    commit_result: CommitResult::from(
537                        ProtoCommitResult::try_from(log_entry.commit_result)
538                            .unwrap_or(ProtoCommitResult::Unspecified),
539                    ),
540                    applied_epoch_number: log_entry.applied_epoch_number as i64,
541                    applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
542                });
543            }
544        }
545        if let Some(last_entry) = entries.last() {
546            conn.update_cursor(
547                group_id,
548                xmtp_db::refresh_state::EntityKind::CommitLogDownload,
549                Cursor(
550                    last_entry
551                        .meta
552                        .cursor
553                        .as_ref()
554                        .ok_or(xmtp_proto::ConversionError::Missing {
555                            item: "commit-log cursor",
556                            r#type: "EnvelopeMeta",
557                        })?
558                        .sequence_id,
559                ),
560            )?;
561        }
562
563        Ok(num_entries_saved)
564    }
565
566    // Should skip if:
567    // 1. The entry signature is invalid
568    // 2. The group_id of the entry does not match the requested group_id.
569    // 3. The commit_sequence_id of the entry is <= 0.
570    // 4. The commit_sequence_id of the entry is not greater than the most recently stored entry, if one exists.
571    // 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.
572    // 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.
573    // 7. The entry CommitResult is not COMMIT_RESULT_APPLIED, and the epoch authenticator or epoch number does not match the most recently applied values
574    // implements: FORK-031, FORK-032
575    fn should_skip_remote_commit_log_entry(
576        &self,
577        group_id: &[u8],
578        latest_saved_remote_log: Option<RemoteCommitLog>,
579        serialized_entry: &CommitLogEntry,
580        entry: &PlaintextCommitLogEntry,
581        consensus_public_key: &[u8],
582    ) -> bool {
583        // These checks apply even if there is no latest saved remote log
584        if entry.group_id != group_id || entry.commit_sequence_id == 0 {
585            return true;
586        }
587        let provider = self.context.mls_provider();
588        if provider
589            .crypto()
590            .verify_commit_log_signature(serialized_entry, consensus_public_key)
591            .is_err()
592        {
593            tracing::warn!(
594                group_id = hex::encode(group_id),
595                "Invalid signature for commit log entry, skipping",
596            );
597            return true;
598        }
599        let Some(latest_saved_remote_log) = latest_saved_remote_log else {
600            return false;
601        };
602
603        let is_applied = entry.commit_result == ProtoCommitResult::Applied as i32;
604
605        entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64
606            || (is_applied
607                && !latest_saved_remote_log
608                    .applied_epoch_authenticator
609                    .is_empty()
610                && entry.last_epoch_authenticator
611                    != latest_saved_remote_log.applied_epoch_authenticator)
612            || (is_applied
613                && entry.applied_epoch_number as i64
614                    != latest_saved_remote_log.applied_epoch_number + 1)
615            || (!is_applied
616                && (entry.applied_epoch_authenticator
617                    != latest_saved_remote_log.applied_epoch_authenticator
618                    || entry.applied_epoch_number as i64
619                        != latest_saved_remote_log.applied_epoch_number))
620    }
621
622    // Updates fork status for conversations in the database
623    pub async fn update_forked_state(&mut self) -> Result<(), CommitLogError> {
624        let conversation_ids_for_forked_state_check =
625            self.context.db().get_conversation_ids_for_fork_check()?;
626
627        for conversation_id in conversation_ids_for_forked_state_check {
628            let conversation_id = GroupId::try_from(conversation_id)?;
629            crate::state_tx::state_write_with_events(
630                self.context.mls_storage(),
631                self.context.events(),
632                |tx, events| {
633                    let storage = tx.storage();
634                    let db = storage.db();
635                    let was_forked = db.get_group_commit_log_forked_status(&conversation_id)?;
636                    let is_forked = self.check_conversation_fork_state(&db, &conversation_id)?;
637                    // Persist the fork status to the database
638                    db.set_group_commit_log_forked_status(&conversation_id, is_forked)?;
639                    if was_forked != Some(true) && is_forked == Some(true) {
640                        events.emit(
641                            Some(ClientEvent::ConversationForkDetected(GroupRef {
642                                group_id: conversation_id.to_vec(),
643                            })),
644                            None,
645                        );
646                    }
647                    Ok::<_, CommitLogError>(Continue(()))
648                },
649            )
650            .map(TransactionOutcome::into_continued)?;
651            tokio::task::yield_now().await;
652        }
653
654        Ok(())
655    }
656
657    /// Returns the list of permitted readders for a group
658    /// Note: Does not return self - self is always a permitted readder
659    async fn permitted_readders(&self, group_id: &GroupId) -> Result<Vec<String>, CommitLogError> {
660        let (group, stored_group) = MlsGroup::new_cached(self.context.clone(), group_id)?;
661        if stored_group.conversation_type == ConversationType::Dm {
662            let Some(dm_id) = stored_group.dm_id.clone() else {
663                tracing::error!(group_id = %group_id, "DM group has no dm_id");
664                return Ok(vec![]);
665            };
666            return Ok(dm_id
667                .other_inbox_id(self.context.inbox_id())
668                .into_iter()
669                .collect());
670        }
671        let super_admins = group.super_admin_list()?;
672        Ok(super_admins)
673    }
674
675    async fn request_readd(
676        &mut self,
677        group_info: StoredGroupForReaddRequest,
678    ) -> Result<(), CommitLogError> {
679        let conn = self.context.db();
680        let group_id = group_info.group_id;
681
682        // Check if a readd request has already been sent for this group
683        if conn.is_awaiting_readd(&group_id, self.context.installation_id().as_slice())? {
684            tracing::debug!(
685                group_id = %group_id,
686                "Skipping readd request for group because it has already been requested"
687            );
688            return Ok(());
689        }
690
691        tracing::debug!(group_id = %group_id, "Sending readd request");
692
693        // Send oneshot message with readd request to super admins
694        let latest_commit_sequence_id = group_info
695            .latest_commit_sequence_id
696            .ok_or(CommitLogError::MissingLatestCommitSequenceId { group_id })?;
697        let oneshot_message = OneshotMessage {
698            message_type: Some(MessageType::ReaddRequest(ReaddRequest {
699                group_id: group_id.to_vec(),
700                latest_commit_sequence_id: latest_commit_sequence_id as u64,
701            })),
702        };
703        let readders = self.permitted_readders(&group_id).await?;
704        tracing::debug!(
705            group_id = %group_id,
706            "Sending readd request to {:?}",
707            readders
708        );
709        Oneshot::send_message(self.context.clone(), readders, oneshot_message).await?;
710
711        tracing::debug!(group_id = %group_id, "Sent readd request",);
712
713        // Mark readd as requested
714        conn.update_requested_at_sequence_id(
715            &group_id,
716            self.context.installation_id().as_slice(),
717            latest_commit_sequence_id,
718        )?;
719
720        tracing::debug!(
721            group_id = %group_id,
722            sequence_id = latest_commit_sequence_id,
723            "Updated requested readd sequence id",
724        );
725
726        Ok(())
727    }
728
729    /// Send readd requests for all forked conversations
730    async fn send_outgoing_readd_requests(&mut self) -> Result<(), CommitLogError> {
731        if self.context.fork_recovery_opts().enable_recovery_requests == ForkRecoveryPolicy::None {
732            return Ok(());
733        }
734        let conn = self.context.db();
735
736        // Fetch all forked groups with their latest epoch
737        let mut forked_groups = conn.get_conversation_ids_for_requesting_readds()?;
738        if self.context.fork_recovery_opts().enable_recovery_requests
739            == ForkRecoveryPolicy::AllowlistedGroups
740        {
741            let groups_to_request_recovery = self
742                .context
743                .fork_recovery_opts()
744                .groups_to_request_recovery
745                .iter()
746                .map(|group_id| group_id.normalize_hex())
747                .collect::<HashSet<String>>();
748            tracing::info!(
749                "Forked groups: {:?}, allowlisted groups for sending recovery requests: {:?}",
750                forked_groups
751                    .iter()
752                    .map(|group_info| group_info.group_id.to_string())
753                    .collect::<Vec<String>>(),
754                groups_to_request_recovery
755            );
756            forked_groups.retain(|group_info| {
757                groups_to_request_recovery
758                    .contains(&group_info.group_id.to_string().normalize_hex())
759            });
760        }
761
762        // Process groups in order, collecting per-group failures so one transient
763        // error doesn't block the rest; a dropped pool still bubbles immediately.
764        let mut failures = Vec::new();
765        for group_info in forked_groups {
766            let group_id = group_info.group_id;
767            if let Err(source) = self.request_readd(group_info).await {
768                if source.needs_db_reconnect() {
769                    return Err(source);
770                }
771                failures.push(CommitLogError::FailedToSendReadd {
772                    group_id,
773                    source: Box::new(source),
774                });
775            }
776        }
777
778        if !failures.is_empty() {
779            return Err(CommitLogError::FailedReadds { errors: failures });
780        }
781
782        Ok(())
783    }
784
785    async fn handle_incoming_pending_readds(&self) -> Result<(), CommitLogError> {
786        if self.context.fork_recovery_opts().disable_recovery_responses {
787            return Ok(());
788        }
789        let conn = self.context.db();
790        let groups_for_readd = conn.get_conversation_ids_for_responding_readds()?;
791
792        // Runs every worker tick (~2s); only speak when there's actually work.
793        if groups_for_readd.is_empty() {
794            return Ok(());
795        }
796        tracing::debug!(
797            "Processing readd requests for {} groups",
798            groups_for_readd.len()
799        );
800
801        for group in groups_for_readd {
802            match self.validate_pending_readds(&conn, &group).await {
803                Ok(validated_installations) => {
804                    if validated_installations.is_empty() {
805                        continue;
806                    }
807                    let mls_group = MlsGroup::new(
808                        self.context.clone(),
809                        group.group_id,
810                        group.dm_id.clone(),
811                        group.conversation_type,
812                        group.created_at_ns,
813                    );
814                    mls_group
815                        .readd_installations(
816                            validated_installations.into_iter().collect::<Vec<_>>(),
817                        )
818                        .await?;
819                }
820                Err(e) => {
821                    // Permanently-bad group: drop its readd statuses and move on.
822                    // Retryable failures (incl. a dropped pool) propagate instead.
823                    if e.is_retryable() {
824                        return Err(e);
825                    }
826                    tracing::warn!(
827                        group_id = %group.group_id,
828                        "Deleting readd statuses for group because it failed validation: {}",
829                        e
830                    );
831                    conn.delete_other_readd_statuses(
832                        &group.group_id,
833                        self.context.installation_id().as_slice(),
834                    )?;
835                    continue;
836                }
837            }
838        }
839
840        Ok(())
841    }
842
843    // implements: FORK-061
844    async fn validate_pending_readds(
845        &self,
846        conn: &impl DbQuery,
847        group: &StoredGroupForRespondingReadds,
848    ) -> Result<HashSet<Vec<u8>>, CommitLogError> {
849        let (mls_group, _) = MlsGroup::new_cached(self.context.clone(), &group.group_id)?;
850        tracing::debug!(
851            group_id = %mls_group.group_id,
852            "Processing readd requests for group"
853        );
854
855        mls_group.sync_with_conn().await?;
856
857        if mls_group.consent_state()? != ConsentState::Allowed {
858            return Err(CommitLogError::GroupReaddValidationError(
859                "Group is not consented".to_string(),
860            ));
861        }
862        if !mls_group.is_active()? {
863            return Err(CommitLogError::GroupReaddValidationError(
864                "Group is not active".to_string(),
865            ));
866        }
867        let is_super_admin = mls_group.live_is_super_admin(self.context.inbox_id())?;
868        if !is_super_admin {
869            return Err(CommitLogError::GroupReaddValidationError(
870                "No longer super admin of group".to_string(),
871            ));
872        }
873
874        let fork_state = self.check_conversation_fork_state(conn, &mls_group.group_id)?;
875        if let Some(true) = fork_state {
876            return Err(CommitLogError::GroupReaddValidationError(
877                "Group is forked".to_string(),
878            ));
879        } else if fork_state.is_none() {
880            tracing::info!(
881                group_id = %mls_group.group_id,
882                "Local commit log ahead of remote, skipping group"
883            );
884            return Ok(HashSet::new());
885        }
886
887        let readd_statuses = conn.get_readds_awaiting_response(
888            &mls_group.group_id,
889            self.context.installation_id().as_slice(),
890        )?;
891        let mut unverified = readd_statuses
892            .iter()
893            .map(|readd_status| readd_status.installation_id.clone())
894            .collect::<HashSet<_>>();
895
896        let (unverified, verified) = mls_group.with_group_snapshot(|openmls_group| {
897            let mut verified = HashSet::new();
898            for member in openmls_group.members() {
899                if unverified.contains(&member.signature_key) {
900                    unverified.remove(&member.signature_key);
901                    verified.insert(member.signature_key);
902                }
903            }
904            Ok::<_, GroupError>((unverified, verified))
905        })?;
906        tracing::debug!(
907            group_id = %mls_group.group_id,
908            "{} readd requests were for non-members, while {} were for members",
909            unverified.len(),
910            verified.len()
911        );
912        conn.delete_readd_statuses(&mls_group.group_id, unverified)?;
913
914        Ok(verified)
915    }
916
917    fn check_conversation_fork_state(
918        &self,
919        conn: &impl DbQuery,
920        conversation_id: &GroupId,
921    ) -> Result<Option<bool>, CommitLogError> {
922        // Get cursors for this conversation
923        let fork_check_local_cursor = conn.get_last_cursor(
924            conversation_id,
925            xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
926        )?;
927        let fork_check_remote_cursor = conn.get_last_cursor(
928            conversation_id,
929            xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
930        )?;
931
932        // Chain-start anchor: rows with `commit_sequence_id == 0` (Welcome /
933        // GroupCreation / BackupRestore) mark the beginning of this member's
934        // current membership session — e.g. rejoining via a new welcome after
935        // having been removed. History before the latest chain start is not
936        // attestable by this member and must not be compared against remote
937        // consensus. Those rows are filtered out of
938        // `get_local_commit_log_after_cursor`, so the anchor is looked up
939        // separately and applied as a floor on the local fork-check cursor.
940        let mut local_cursor = fork_check_local_cursor.0 as i64;
941        let mut crossed_chain_start = false;
942        if let Some(anchor_rowid) = conn.get_latest_chain_start_rowid(conversation_id)?
943            && anchor_rowid as i64 > local_cursor
944        {
945            local_cursor = anchor_rowid as i64;
946            crossed_chain_start = true;
947            conn.update_cursor(
948                conversation_id,
949                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
950                Cursor(anchor_rowid as u64),
951            )?;
952        }
953
954        // Get local and remote commit logs
955        let local_logs = conn.get_local_commit_log_after_cursor(
956            conversation_id,
957            local_cursor,
958            LocalCommitLogOrder::DescendingByRowid,
959        )?;
960        let remote_logs = conn.get_remote_commit_log_after_cursor(
961            conversation_id,
962            fork_check_remote_cursor.0 as i64,
963            RemoteCommitLogOrder::DescendingByRowid,
964        )?;
965
966        // If there are no new commits to check, preserve the existing fork status
967        if local_logs.is_empty() {
968            if crossed_chain_start {
969                // A new chain start (e.g. a welcome from being re-added)
970                // invalidates any previously computed status: nothing after
971                // it has been verified against remote consensus yet.
972                return Ok(None);
973            }
974            return Ok(conn.get_group_commit_log_forked_status(conversation_id)?);
975        }
976
977        let mut is_remote_log_up_to_date = true;
978        // Check each local log against remote logs for matching commit_sequence_id
979        for local_log in &local_logs {
980            // Terminal removal marker: a commit that removed us merges only
981            // the public diff — we cannot derive the new epoch's secrets, so
982            // a Success row whose applied authenticator equals its last
983            // authenticator means "this commit removed us", not a fork. The
984            // remaining members publish the real post-commit authenticator,
985            // which can never match ours, so this row must be excluded from
986            // comparison. (Covers both new RemovedFromGroup rows and legacy
987            // rows that recorded the new epoch with the stale authenticator.)
988            if local_log.commit_result == CommitResult::Success
989                && local_log.applied_epoch_authenticator == local_log.last_epoch_authenticator
990            {
991                // Note: `update_cursor` is monotonic (it only ever moves the
992                // cursor forward), so the cursor updates below for older
993                // matched rows — the loop walks descending rowids — cannot
994                // regress the cursor behind this terminal row.
995                conn.update_cursor(
996                    conversation_id,
997                    xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
998                    Cursor(local_log.rowid as u64),
999                )?;
1000                continue;
1001            }
1002
1003            let Some(matching_remote_log) =
1004                self.find_matching_remote_log(&remote_logs, local_log.commit_sequence_id)
1005            else {
1006                is_remote_log_up_to_date = false;
1007                continue;
1008            };
1009            // Found a matching commit_sequence_id - check if forked
1010            let is_mismatched = local_log.applied_epoch_authenticator
1011                != matching_remote_log.applied_epoch_authenticator;
1012
1013            if is_mismatched {
1014                tracing::warn!(
1015                    group_id = %conversation_id,
1016                    "Detected forked state\n\
1017                            Local log: {:?}\n\
1018                            Remote log: {:?}",
1019                    local_log,
1020                    matching_remote_log
1021                );
1022            }
1023
1024            // Update cursors regardless of fork status (we found a match)
1025            conn.update_cursor(
1026                conversation_id,
1027                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1028                Cursor(local_log.rowid as u64),
1029            )?;
1030            conn.update_cursor(
1031                conversation_id,
1032                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1033                Cursor(matching_remote_log.rowid as u64),
1034            )?;
1035
1036            if is_mismatched {
1037                return Ok(Some(true));
1038            } else if is_remote_log_up_to_date {
1039                return Ok(Some(false));
1040            } else {
1041                // If we haven't verified the latest commit local commit logs, we
1042                // don't know if we are forked or not
1043                return Ok(None);
1044            }
1045        }
1046
1047        Ok(None)
1048    }
1049
1050    fn find_matching_remote_log<'a>(
1051        &self,
1052        remote_logs: &'a [xmtp_db::remote_commit_log::RemoteCommitLog],
1053        commit_sequence_id: i64,
1054    ) -> Option<&'a xmtp_db::remote_commit_log::RemoteCommitLog> {
1055        remote_logs
1056            .iter()
1057            .find(|remote_log| remote_log.commit_sequence_id == commit_sequence_id)
1058    }
1059}
1060
1061// Helper that exposes private methods for testing
1062#[cfg(test)]
1063impl<Context> CommitLogWorker<Context>
1064where
1065    Context: XmtpSharedContext + 'static,
1066{
1067    pub async fn _tick(&mut self) -> Result<(), CommitLogError> {
1068        self.tick().await
1069    }
1070
1071    pub(crate) fn _should_skip_remote_commit_log_entry(
1072        &self,
1073        group_id: &[u8],
1074        latest_saved_remote_log: Option<RemoteCommitLog>,
1075        serialized_entry: &xmtp_proto::backend_v1::CommitLogEntry,
1076        entry: &PlaintextCommitLogEntry,
1077        consensus_public_key: &[u8],
1078    ) -> bool {
1079        self.should_skip_remote_commit_log_entry(
1080            group_id,
1081            latest_saved_remote_log,
1082            serialized_entry,
1083            entry,
1084            consensus_public_key,
1085        )
1086    }
1087
1088    // Test helper to get fork status for all groups that would be checked (for backward compatibility with tests)
1089    pub fn get_all_fork_statuses(&self) -> Result<HashMap<Vec<u8>, Option<bool>>, CommitLogError> {
1090        use xmtp_db::group::GroupQueryArgs;
1091        let conn = &self.context.db();
1092        // Get all groups (not just those with commit log keys)
1093        let all_groups = conn.find_groups(GroupQueryArgs::default())?;
1094
1095        let mut results = HashMap::new();
1096        for group in all_groups {
1097            let fork_status = conn.get_group_commit_log_forked_status(&group.id)?;
1098            results.insert(group.id.to_vec(), fork_status);
1099        }
1100
1101        Ok(results)
1102    }
1103
1104    /// Test-only version that runs without infinite loop
1105    pub async fn run_test(
1106        &mut self,
1107        commit_log_test_function: CommitLogTestFunction,
1108        iterations: Option<usize>,
1109    ) -> Result<Vec<TestResult>, CommitLogError> {
1110        let mut test_results = Vec::new();
1111        match iterations {
1112            Some(n) => {
1113                // Run exactly n times
1114                for _ in 0..n {
1115                    let test_result = self.test_helper(&commit_log_test_function).await?;
1116                    test_results.push(test_result);
1117                }
1118            }
1119            None => {
1120                let test_result = self.test_helper(&commit_log_test_function).await?;
1121                test_results.push(test_result);
1122            }
1123        }
1124        Ok(test_results)
1125    }
1126
1127    async fn test_helper(
1128        &mut self,
1129        commit_log_test_function: &CommitLogTestFunction,
1130    ) -> Result<TestResult, CommitLogError> {
1131        let mut test_result = TestResult {
1132            save_remote_commit_log_results: None,
1133            publish_commit_log_results: None,
1134            is_forked: None,
1135        };
1136        match commit_log_test_function {
1137            CommitLogTestFunction::PublishCommitLogsToRemote => {
1138                let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1139                test_result.publish_commit_log_results = Some(publish_commit_log_results);
1140            }
1141            CommitLogTestFunction::SaveRemoteCommitLog => {
1142                let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1143                test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1144            }
1145            CommitLogTestFunction::CheckForkedState => {
1146                self.update_forked_state().await?;
1147                let is_forked = self.get_all_fork_statuses()?;
1148                test_result.is_forked = Some(is_forked);
1149            }
1150            CommitLogTestFunction::All => {
1151                // Order is save; update fork status; publish
1152                let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1153                test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1154                self.update_forked_state().await?;
1155                let is_forked = self.get_all_fork_statuses()?;
1156                test_result.is_forked = Some(is_forked);
1157                let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1158                test_result.publish_commit_log_results = Some(publish_commit_log_results);
1159            }
1160        }
1161        Ok(test_result)
1162    }
1163}