Skip to main content

xmtp_mls/groups/
commit_log.rs

1use crate::builder::ForkRecoveryPolicy;
2use crate::groups::MlsGroup;
3use crate::groups::mls_sync::retry_due;
4use crate::groups::oneshot::Oneshot;
5use crate::groups::summary::SyncSummary;
6use crate::subscriptions::internal::dm_event_context;
7use futures::StreamExt;
8use std::collections::HashSet;
9use std::sync::Arc;
10use std::{collections::HashMap, time::Duration};
11use thiserror::Error;
12use xmtp_api::ApiError;
13use xmtp_common::RetryableError;
14use xmtp_common::hex::NormalizeHex;
15use xmtp_common::snippet::Snippet;
16use xmtp_common::time::now_ns;
17use xmtp_db::TransactionOutcome::Continue;
18use xmtp_db::consent_record::ConsentState;
19use xmtp_db::group::StoredGroupForRespondingReadds;
20use xmtp_db::remote_commit_log::RemoteCommitLog;
21use xmtp_db::remote_commit_log::RemoteCommitLogOrder;
22use xmtp_db::{
23    DbQuery, StorageError, Store, TransactionOutcome,
24    group::StoredGroupForReaddRequest,
25    local_commit_log::{CommitType, LocalCommitLogOrder},
26    prelude::*,
27    readd_status::QueryReaddStatus,
28    remote_commit_log::{CommitResult, NewRemoteCommitLog},
29};
30use xmtp_mls_group_keys::commit_log::{open_entry, seal_entry, sign_entry_author};
31use xmtp_mls_group_keys::group_keys::{GroupKeys, SealError};
32use xmtp_mls_group_state::app_data::group_root::group_keys_from_extensions;
33use xmtp_mls_validation::ForkActors;
34use xmtp_proto::ShortHex;
35use xmtp_proto::backend_v1::SealedCommitLogEntry;
36use xmtp_proto::types::Cursor;
37use xmtp_proto::types::{CommitLogEntry as DecodedCommitLogEntry, Topic, TopicCursor};
38use xmtp_proto::xmtp::mls::message_contents::CommitResult as ProtoCommitResult;
39use xmtp_proto::xmtp::mls::message_contents::PlaintextCommitLogEntry;
40
41use crate::{
42    context::XmtpSharedContext,
43    groups::GroupError,
44    worker::{
45        BoxedWorker, NeedsDbReconnect, NewWorker, Worker, WorkerFactory, WorkerKind, WorkerResult,
46    },
47};
48use xmtp_proto::xmtp::mls::message_contents::{
49    OneshotMessage, ReaddRequest, oneshot_message::MessageType,
50};
51
52use xmtp_events::{ClientEvent, EventWriter, GroupRef};
53use xmtp_proto::types::GroupId;
54/// Interval at which the CommitLogWorker runs to publish commit log entries.
55pub const DEFAULT_INTERVAL_DURATION: Duration = Duration::from_secs(60 * 5); // 5 minutes
56
57#[derive(Clone)]
58pub struct Factory<Context> {
59    context: Context,
60    /// Kept by the factory, so a storage reconnect, which builds a new
61    /// worker, keeps every rate-limit hold.
62    holds: Arc<parking_lot::Mutex<Holds>>,
63}
64
65impl<Context> Factory<Context>
66where
67    Context: XmtpSharedContext + 'static,
68{
69    pub(crate) fn new(context: Context) -> Self {
70        Self {
71            context,
72            holds: Arc::default(),
73        }
74    }
75
76    /// A worker that shares this factory's rate-limit holds.
77    pub(crate) fn worker(&self) -> CommitLogWorker<Context> {
78        CommitLogWorker::with_holds(self.context.clone(), self.holds.clone())
79    }
80}
81
82impl<Context> WorkerFactory for Factory<Context>
83where
84    Context: XmtpSharedContext + 'static,
85{
86    fn kind(&self) -> WorkerKind {
87        WorkerKind::CommitLog
88    }
89
90    fn create(&self) -> BoxedWorker {
91        Box::new(self.worker())
92    }
93}
94
95#[derive(Debug, Error)]
96pub enum CommitLogError {
97    #[error("generic storage error: {0}")]
98    Storage(#[from] StorageError),
99    #[error("diesel error: {0}")]
100    Diesel(#[from] xmtp_db::diesel::result::Error),
101    #[error("generic api error: {0}")]
102    Api(#[from] ApiError),
103    #[error("connection error: {0}")]
104    Connection(#[from] xmtp_db::ConnectionError),
105    #[error("prost decode error: {0}")]
106    Prost(#[from] prost::DecodeError),
107    #[error("keystore error: {0}")]
108    KeystoreError(#[from] xmtp_db::sql_key_store::SqlKeyStoreError),
109    #[error("group error: {0}")]
110    GroupError(#[from] GroupError),
111    #[error("crypto error: {0}")]
112    CryptoError(#[from] openmls_traits::types::CryptoError),
113    #[error("try from slice error: {0}")]
114    TryFromSliceError(#[from] std::array::TryFromSliceError),
115    #[error("conversion error: {0}")]
116    Conversion(#[from] xmtp_proto::ConversionError),
117    #[error("Group did not pass readd validation: {0}")]
118    GroupReaddValidationError(String),
119    #[error("sync error: {0}")]
120    SyncError(#[from] SyncSummary),
121    #[error("failed to send readd request for group {group_id}: {source}")]
122    FailedToSendReadd {
123        group_id: GroupId,
124        source: Box<CommitLogError>,
125    },
126    #[error("{count} readd request(s) failed: {errors:?}", count = errors.len())]
127    FailedReadds { errors: Vec<CommitLogError> },
128    #[error("no latest commit sequence id found for forked group {group_id}")]
129    MissingLatestCommitSequenceId { group_id: GroupId },
130}
131
132impl RetryableError for CommitLogError {
133    fn is_retryable(&self) -> bool {
134        match self {
135            Self::Storage(storage_error) => storage_error.is_retryable(),
136            Self::Diesel(diesel_error) => diesel_error.is_retryable(),
137            Self::Api(api_error) => api_error.is_retryable(),
138            Self::Connection(connection_error) => connection_error.is_retryable(),
139            Self::Prost(_prost_error) => false,
140            Self::KeystoreError(keystore_error) => keystore_error.is_retryable(),
141            Self::GroupError(group_error) => group_error.is_retryable(),
142            Self::CryptoError(_crypto_error) => false,
143            Self::TryFromSliceError(_try_from_slice_error) => false,
144            Self::Conversion(_) => false,
145            Self::GroupReaddValidationError(_group_readd_validation_error) => false,
146            Self::SyncError(sync_error) => sync_error.is_retryable(),
147            Self::FailedToSendReadd { source, .. } => source.is_retryable(),
148            Self::FailedReadds { errors } => errors.iter().any(|e| e.is_retryable()),
149            Self::MissingLatestCommitSequenceId { .. } => false,
150        }
151    }
152
153    fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
154        match self {
155            Self::Api(api_error) => api_error.rate_limit_delay(),
156            Self::GroupError(group_error) => group_error.rate_limit_delay(),
157            Self::SyncError(sync_error) => sync_error.rate_limit_delay(),
158            Self::FailedToSendReadd { source, .. } => source.rate_limit_delay(),
159            Self::FailedReadds { errors } => errors
160                .iter()
161                .filter_map(RetryableError::rate_limit_delay)
162                .max(),
163            _ => None,
164        }
165    }
166}
167
168impl NeedsDbReconnect for CommitLogError {
169    fn restart_delay(&self) -> Option<Duration> {
170        RetryableError::rate_limit_delay(self)
171    }
172
173    #[deny(clippy::wildcard_enum_match_arm)]
174    fn needs_db_reconnect(&self) -> bool {
175        match self {
176            Self::Storage(s) => s.db_needs_connection(),
177            // Only a `PoolNeedsConnection` connection error means the pool is gone.
178            Self::Connection(c) => c.db_needs_connection(),
179            // A dropped pool can be wrapped inside a GroupError; forward.
180            Self::GroupError(e) => e.needs_db_reconnect(),
181            // A readd send failure wraps an inner CommitLogError; forward.
182            Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
183            Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
184            Self::SyncError(summary) => summary.needs_db_reconnect(),
185            // Remaining variants can't carry a dropped-pool signal.
186            Self::Diesel(_)
187            | Self::Api(_)
188            | Self::Prost(_)
189            | Self::KeystoreError(_)
190            | Self::CryptoError(_)
191            | Self::TryFromSliceError(_)
192            | Self::Conversion(_)
193            | Self::GroupReaddValidationError(_)
194            | Self::MissingLatestCommitSequenceId { .. } => false,
195        }
196    }
197}
198
199#[xmtp_common::async_trait]
200impl<Context> Worker for CommitLogWorker<Context>
201where
202    Context: XmtpSharedContext + 'static,
203{
204    fn kind(&self) -> WorkerKind {
205        WorkerKind::CommitLog
206    }
207
208    async fn run_tasks(&mut self) -> WorkerResult<()> {
209        self.run().await.map_err(|e| Box::new(e) as Box<_>)
210    }
211}
212
213impl<Context> NewWorker<Context> for CommitLogWorker<Context>
214where
215    Context: XmtpSharedContext + 'static,
216{
217    fn factory(context: Context) -> impl WorkerFactory + 'static {
218        Factory::new(context)
219    }
220}
221
222/// Conversations held by a rate limit, and when each delay ends (RATE-041).
223#[derive(Default)]
224pub(crate) struct Holds {
225    /// Conversations whose commit-log publish was refused. Other
226    /// conversations still publish.
227    publish: HashMap<GroupId, xmtp_common::time::Instant>,
228    /// Conversations whose readd answer was refused. Other conversations
229    /// still answer.
230    readd: HashMap<GroupId, xmtp_common::time::Instant>,
231}
232
233pub struct CommitLogWorker<Context> {
234    context: Context,
235    /// The conversations whose commit-log publish failed in the last turn.
236    failed_publishes: Vec<GroupId>,
237    /// Rate-limit holds, shared with the factory that built this worker.
238    holds: Arc<parking_lot::Mutex<Holds>>,
239    #[cfg(test)]
240    seal_fault: Option<SealFault>,
241}
242
243/// Hold `id` in `held` until `delay`, a rate-limit delay, has passed. A
244/// shorter later delay never ends a pending one.
245// implements: RATE-041
246fn hold(
247    held: &mut HashMap<GroupId, xmtp_common::time::Instant>,
248    id: GroupId,
249    delay: Option<Duration>,
250) {
251    let Some(delay) = delay else {
252        return;
253    };
254    let until = xmtp_common::time::saturating_after(xmtp_common::time::Instant::now(), delay);
255    let held = held.entry(id).or_insert(until);
256    *held = (*held).max(until);
257}
258
259/// A test-only fault at the result of each seal, by conversation.
260#[cfg(test)]
261pub(crate) type SealFault = std::sync::Arc<
262    dyn Fn(
263            &GroupId,
264            Result<SealedCommitLogEntry, SealError>,
265        ) -> Result<SealedCommitLogEntry, SealError>
266        + Send
267        + Sync,
268>;
269
270impl<Context> CommitLogWorker<Context>
271where
272    Context: XmtpSharedContext + 'static,
273{
274    pub fn new(context: Context) -> Self {
275        Self::with_holds(context, Arc::default())
276    }
277
278    fn with_holds(context: Context, holds: Arc<parking_lot::Mutex<Holds>>) -> Self {
279        Self {
280            context,
281            failed_publishes: Vec::new(),
282            holds,
283            #[cfg(test)]
284            seal_fault: None,
285        }
286    }
287
288    /// The conversations whose commit-log publish failed in the last turn.
289    /// A failed publish ends the turn, not the worker, so `tick` still
290    /// returns `Ok`; a health check reads the failures here.
291    pub fn failed_publishes(&self) -> &[GroupId] {
292        &self.failed_publishes
293    }
294}
295
296/// One conversation's sealed entries and the cursor that publishing them
297/// reaches.
298struct PendingPublish {
299    cursor_info: ConversationCursorInfo,
300    entries: Vec<SealedCommitLogEntry>,
301}
302
303#[derive(Debug, PartialEq, Clone)]
304pub struct ConversationCursorInfo {
305    pub conversation_id: Vec<u8>,
306    pub num_entries_published: usize,
307    pub last_entry_published_sequence_id: i64,
308    pub last_entry_published_rowid: i64,
309    /// The local rowid of each published entry, in publish order.
310    pub entry_rowids: Vec<i64>,
311}
312
313impl ConversationCursorInfo {
314    /// The publish cursor to save once the first `stored` entries of this
315    /// conversation's publish are stored, or `None` when none is. A fully
316    /// stored publish moves to the conversation's last row, past skipped
317    /// rows. A partly stored one moves to the row of its last stored entry,
318    /// so that the next turn starts from its first unstored entry.
319    // implements: FORK-021
320    fn stored_cursor(&self, stored: usize) -> Option<i64> {
321        if stored >= self.entry_rowids.len() {
322            return Some(self.last_entry_published_rowid);
323        }
324        stored.checked_sub(1).map(|last| self.entry_rowids[last])
325    }
326}
327
328#[derive(Debug, PartialEq, Clone)]
329pub struct SaveRemoteCommitLogResult {
330    pub conversation_id: Vec<u8>,
331    pub num_entries_saved: usize,
332}
333
334// Test related types
335#[cfg(test)]
336pub enum CommitLogTestFunction {
337    PublishCommitLogsToRemote,
338    SaveRemoteCommitLog,
339    CheckForkedState,
340    All,
341}
342
343#[cfg(test)]
344pub struct TestResult {
345    pub save_remote_commit_log_results: Option<HashMap<Vec<u8>, usize>>,
346    pub publish_commit_log_results: Option<Vec<ConversationCursorInfo>>,
347    pub is_forked: Option<HashMap<Vec<u8>, Option<bool>>>,
348}
349
350// CommitLogWorker implementation
351impl<Context> CommitLogWorker<Context>
352where
353    Context: XmtpSharedContext + 'static,
354{
355    async fn run(&mut self) -> Result<(), CommitLogError> {
356        // Precedence: fork-recovery override (clamped to >=2s) > WorkerConfig >
357        // const default. Fork recovery wins because its cadence is tied to
358        // recovery-protocol timing, not general worker tuning.
359        let (mut worker_interval, jitter) = self
360            .context
361            .worker_interval(WorkerKind::CommitLog, DEFAULT_INTERVAL_DURATION);
362        if let Some(interval) = self.context.fork_recovery_opts().worker_interval_ns {
363            worker_interval = Duration::from_nanos(interval).max(Duration::from_secs(2));
364        }
365        let mut intervals = xmtp_common::time::jittered_interval_stream(worker_interval, jitter);
366        while (intervals.next().await).is_some() {
367            self.tick().await?;
368        }
369        Ok(())
370    }
371
372    #[tracing::instrument(skip_all, fields(worker = ?self.kind(), operation = "worker_turn"))]
373    pub async fn tick(&mut self) -> Result<(), CommitLogError> {
374        self.save_remote_commit_log().await?;
375        self.update_forked_state().await?;
376        self.publish_commit_logs_to_remote().await?;
377        self.send_outgoing_readd_requests().await?;
378        self.handle_incoming_pending_readds().await?;
379        Ok(())
380    }
381
382    /// Publish every eligible conversation's new entries, each conversation
383    /// in its own publish call, so a refused or unsendable entry holds back
384    /// only its own conversation. A failed publish is logged, recorded in
385    /// [`Self::failed_publishes`], and moves the conversation's cursor past
386    /// the stored prefix only; the conversation is tried again at the next
387    /// tick. A refusal, a rate limit, or a local parse failure holds back
388    /// only its own conversation. A transport failure ends the publishing
389    /// part of the turn, since every later conversation would wait through
390    /// the same retries. No publish failure stops the worker.
391    // implements: FORK-021
392    async fn publish_commit_logs_to_remote(
393        &mut self,
394    ) -> Result<Vec<ConversationCursorInfo>, CommitLogError> {
395        self.failed_publishes.clear();
396        let now = xmtp_common::time::Instant::now();
397        self.holds.lock().publish.retain(|_, until| now < *until);
398        let conn = &self.context.db();
399        // DMs, and groups where we are a super admin
400        let conversations = conn.get_conversation_ids_for_remote_log_publish()?;
401        let api = self.context.api();
402        let mut published = Vec::new();
403        for conversation in &conversations {
404            // A rate-limited publish is not retried at a later tick inside its
405            // delay; other conversations still publish.
406            // implements: RATE-041
407            if self.holds.lock().publish.contains_key(&conversation.id) {
408                continue;
409            }
410            let Some(PendingPublish {
411                cursor_info,
412                entries,
413            }) = self.prepare_conversation_publish(conn, &conversation.id)?
414            else {
415                continue;
416            };
417            let mut stored = Vec::new();
418            let result = if entries.is_empty() {
419                Ok(())
420            } else {
421                tracing::info!(
422                    group_id = %conversation.id.as_slice().snippet(),
423                    entries = entries.len(),
424                    "Publishing commit log entries to remote commit log"
425                );
426                // Entries go out in order, one request at a time, and
427                // publishing stops at the first failed request.
428                api.publish_commit_log(entries, &mut stored).await
429            };
430            // Move the cursor past the stored entries. After a failure the
431            // next turn starts at the first entry not stored.
432            if let Some(rowid) = cursor_info.stored_cursor(stored.len()) {
433                conn.update_cursor(
434                    conversation.id,
435                    xmtp_db::refresh_state::EntityKind::CommitLogUpload,
436                    Cursor(rowid as u64),
437                )?;
438            }
439            match result {
440                Ok(()) => published.push(cursor_info),
441                Err(error) => {
442                    tracing::warn!(
443                        group_id = %conversation.id.as_slice().snippet(),
444                        stored = stored.len(),
445                        %error,
446                        "Commit log publish failed; the conversation retries at the next turn"
447                    );
448                    self.failed_publishes.push(conversation.id);
449                    hold(
450                        &mut self.holds.lock().publish,
451                        conversation.id,
452                        error.rate_limit_delay(),
453                    );
454                    // A rate limit is retryable but holds only its own
455                    // conversation (RATE-041); the others still publish.
456                    if error.is_retryable() && error.rate_limit_delay().is_none() {
457                        break;
458                    }
459                }
460            }
461        }
462        Ok(published)
463    }
464
465    /// Seal a conversation's entries recorded after its upload cursor, in
466    /// ascending row order, with the cursor that publishing them reaches.
467    /// `None` when it has no new entries, or when they cannot be sealed this
468    /// turn.
469    // implements: FORK-021, FORK-085
470    fn prepare_conversation_publish(
471        &self,
472        conn: &impl DbQuery,
473        conversation_id: &GroupId,
474    ) -> Result<Option<PendingPublish>, CommitLogError> {
475        // Recovery and its durable cutoff share the MLS state transaction.
476        // Enumeration includes unrecovered groups even when their old flag is false.
477        let recovery = crate::state_tx::state_write(self.context.mls_storage(), |tx| {
478            tx.with_group(*conversation_id, |group, storage| {
479                use crate::groups::mls_ext::CommitLogStorer;
480                let db = storage.db();
481                let permits = group
482                    .recover_commit_log_publisher(&db, self.context.inbox_id().as_ref())
483                    .map_err(GroupError::from)?;
484                // Bound this batch to the same state that authorized publication.
485                // Keep cursor read errors outside the per-conversation recovery fallback.
486                Ok::<_, GroupError>((permits, db.get_local_commit_log_cursor(conversation_id)))
487            })
488            .map(Continue)
489        });
490        let (permits, local_commit_log_cursor) = match recovery {
491            Ok(outcome) => outcome.into_continued(),
492            Err(error) if error.needs_db_reconnect() => return Err(error.into()),
493            Err(error) => {
494                tracing::warn!(
495                    group_id = %conversation_id.as_slice().snippet(),
496                    %error,
497                    "A conversation's publisher state did not load; it skips publication this turn"
498                );
499                return Ok(None);
500            }
501        };
502
503        if !permits {
504            return Ok(None);
505        }
506        // Propagate read errors (incl. a dropped pool) to the supervisor; a
507        // missing cursor legitimately defaults to 0.
508        let local_commit_log_cursor = local_commit_log_cursor?.unwrap_or(0);
509        let membership_floor = conn
510            .get_latest_chain_start_rowid(conversation_id)?
511            .unwrap_or(0) as u64;
512        let published_commit_log_cursor = conn
513            .get_last_cursor(
514                conversation_id,
515                xmtp_db::refresh_state::EntityKind::CommitLogUpload,
516            )?
517            .0
518            .max(membership_floor);
519        if local_commit_log_cursor <= published_commit_log_cursor as i32 {
520            // We have no new commits to publish for this conversation
521            return Ok(None);
522        }
523
524        // Local commit log entries are returned sorted in ascending order of `rowid`
525        // All local commit log will have rowid > 0 since sqlite rowid starts at 1 https://www.sqlite.org/autoinc.html
526        let mut logs = conn.get_local_commit_log_after_cursor(
527            conversation_id,
528            published_commit_log_cursor as i64,
529            LocalCommitLogOrder::AscendingByRowid,
530        )?;
531        logs.retain(|log| log.rowid <= local_commit_log_cursor);
532        let Some(max_rowid) = logs.last().map(|log| log.rowid) else {
533            return Ok(None);
534        };
535        // A commit that removed us is recorded locally (RemovedFromGroup,
536        // with the pre-commit epoch/authenticator) for debuggability, but
537        // must never be published: it does not attest the new epoch and
538        // could shadow the real consensus entry published by the
539        // remaining members. In practice a removed member is not a
540        // publisher (only super admins and DM participants publish, super
541        // admins cannot be removed, and DMs have no removals) — this
542        // filter makes that property structural. The publish cursor still
543        // advances past skipped rows.
544        let removed_from_group_marker = CommitType::RemovedFromGroup.to_string();
545        let (entry_rowids, plaintext_commit_log_entries): (Vec<i64>, Vec<PlaintextCommitLogEntry>) =
546            logs.iter()
547                .filter(|log| {
548                    log.commit_type.as_deref() != Some(removed_from_group_marker.as_str())
549                })
550                .map(|log| (log.rowid as i64, PlaintextCommitLogEntry::from(log)))
551                .unzip();
552        let entries = if plaintext_commit_log_entries.is_empty() {
553            Vec::new()
554        } else {
555            let Some(entries) =
556                self.seal_group_logs(conversation_id, &plaintext_commit_log_entries)?
557            else {
558                return Ok(None);
559            };
560            entries
561        };
562        Ok(Some(PendingPublish {
563            cursor_info: ConversationCursorInfo {
564                conversation_id: conversation_id.to_vec(),
565                num_entries_published: plaintext_commit_log_entries.len(),
566                last_entry_published_sequence_id: plaintext_commit_log_entries
567                    .last()
568                    .map(|e| e.commit_sequence_id as i64)
569                    .unwrap_or(0),
570                last_entry_published_rowid: max_rowid as i64,
571                entry_rowids,
572            },
573            entries,
574        }))
575    }
576
577    /// Seal entries under the keys of the conversation's group root.
578    /// `None` when the conversation has no root or its group state does not
579    /// load, or when a seal fails its self-check. Nothing of that
580    /// conversation is then published this turn, and the failure is logged
581    /// without any entry or key bytes.
582    fn seal_group_logs(
583        &self,
584        conversation_id: &GroupId,
585        plaintext_commit_log_entries: &[PlaintextCommitLogEntry],
586    ) -> Result<Option<Vec<SealedCommitLogEntry>>, CommitLogError> {
587        let Some(keys) = self.group_keys(conversation_id)? else {
588            return Ok(None);
589        };
590        let mut sealed = Vec::with_capacity(plaintext_commit_log_entries.len());
591        for entry in plaintext_commit_log_entries {
592            let mut entry = entry.clone();
593            let sealed_entry = sign_entry_author(
594                &keys,
595                &self.context.identity().installation_keys,
596                &mut entry,
597            )
598            .and_then(|()| seal_entry(&keys, &entry));
599            match self.apply_seal_fault(conversation_id, sealed_entry) {
600                Ok(entry) => sealed.push(entry),
601                Err(error) => {
602                    tracing::error!(
603                        group_id = %conversation_id.as_slice().snippet(),
604                        %error,
605                        "Sealing a commit log entry failed; the conversation publishes nothing this turn"
606                    );
607                    return Ok(None);
608                }
609            }
610        }
611        Ok(Some(sealed))
612    }
613
614    #[cfg(not(test))]
615    fn apply_seal_fault(
616        &self,
617        _conversation_id: &GroupId,
618        sealed: Result<SealedCommitLogEntry, SealError>,
619    ) -> Result<SealedCommitLogEntry, SealError> {
620        sealed
621    }
622
623    #[cfg(test)]
624    fn apply_seal_fault(
625        &self,
626        conversation_id: &GroupId,
627        sealed: Result<SealedCommitLogEntry, SealError>,
628    ) -> Result<SealedCommitLogEntry, SealError> {
629        match &self.seal_fault {
630            Some(fault) => fault(conversation_id, sealed),
631            None => sealed,
632        }
633    }
634
635    /// The keys derived from a conversation's group root, read from its
636    /// group context each time and never stored. `None` for a group without
637    /// a root, such as a restored placeholder, and for a group whose state
638    /// does not load. Either holds back only that conversation. A dropped
639    /// database pool is not one conversation's failure, so it is returned.
640    fn group_keys(&self, conversation_id: &GroupId) -> Result<Option<GroupKeys>, CommitLogError> {
641        let group_context = MlsGroup::new_cached(self.context.clone(), conversation_id)
642            .map_err(GroupError::from)
643            .and_then(|(group, _)| group.load_group_context());
644        let group_context = match group_context {
645            Ok(group_context) => group_context,
646            Err(error) if error.needs_db_reconnect() => return Err(error.into()),
647            Err(error) => {
648                tracing::warn!(
649                    group_id = %conversation_id.as_slice().snippet(),
650                    %error,
651                    "A conversation's group state did not load; it skips the commit log this turn"
652                );
653                return Ok(None);
654            }
655        };
656        match group_keys_from_extensions(group_context.extensions()) {
657            Ok(keys) => Ok(Some(keys)),
658            Err(error) => {
659                tracing::warn!(
660                    group_id = %conversation_id.as_slice().snippet(),
661                    %error,
662                    "A conversation without a group root has no commit log"
663                );
664                Ok(None)
665            }
666        }
667    }
668
669    // Returns a map of conversation_id to the number of entries saved
670    // implements: FORK-030
671    async fn save_remote_commit_log(&mut self) -> Result<HashMap<Vec<u8>, usize>, CommitLogError> {
672        let conn = &self.context.db();
673        // All allowed DMs and groups other than sync and restored groups.
674        // Each names its commit-log id, stored at creation and join. A group
675        // without one has no root, and so no log to read.
676        let conversations: HashMap<Vec<u8>, GroupId> = conn
677            .get_conversation_ids_for_remote_log_download()?
678            .into_iter()
679            .filter_map(|conversation| Some((conversation.commit_log_public_key?, conversation.id)))
680            .collect();
681
682        // The download cursor stays keyed by group id; that link is local only.
683        let remote_log_cursors = conn.get_remote_log_cursors(
684            conversations
685                .values()
686                .map(|group_id| group_id.as_slice())
687                .collect::<Vec<_>>()
688                .as_slice(),
689        )?;
690        let query_log_requests: TopicCursor = conversations
691            .iter()
692            .map(|(commit_log_id, group_id)| {
693                let cursor = remote_log_cursors
694                    .get(group_id.as_slice())
695                    .copied()
696                    .unwrap_or_default();
697                (Topic::new_commit_log(commit_log_id), cursor)
698            })
699            .collect();
700
701        // Skip API call if there are no requests to make
702        if query_log_requests.is_empty() {
703            tracing::debug!("No commit log requests to query");
704            return Ok(HashMap::new());
705        }
706
707        let mut keys_by_group = HashMap::new();
708        let mut cursors = query_log_requests;
709        for group_id in conversations.values() {
710            if let Some(keys) = self.group_keys(group_id)? {
711                keys_by_group.insert(*group_id, keys);
712            }
713        }
714        cursors.retain(|topic, _| {
715            conversations
716                .get(topic.identifier())
717                .is_some_and(|group| keys_by_group.contains_key(group))
718        });
719        let limits = self.context.incoming_runtime().policy().fetched_limits();
720        let mut save_remote_commit_log_results = HashMap::new();
721        self.context
722            .api()
723            .fold_ordered_pages(cursors, limits, |batches, cursors| {
724                for batch in batches {
725                    let Some(group_id) = conversations.get(batch.topic.identifier()) else {
726                        continue;
727                    };
728                    let Some(keys) = keys_by_group.get(group_id) else {
729                        continue;
730                    };
731                    let entries = batch
732                        .envelopes
733                        .into_iter()
734                        .map(|envelope| {
735                            xmtp_api_backend::envelope::decode_commit_log_entry(envelope.decode()?)
736                        })
737                        .collect::<Result<Vec<_>, _>>()
738                        .map_err(ApiError::from)?;
739                    let (num_entries, held) = self
740                        .save_remote_commit_log_entries_and_update_cursors(
741                            conn, group_id, keys, entries,
742                        )?;
743                    *save_remote_commit_log_results
744                        .entry(group_id.to_vec())
745                        .or_insert(0) += num_entries;
746                    if held {
747                        // The durable cursor still precedes the held entry. Do not
748                        // fetch or evaluate any later page from this topic this turn.
749                        cursors.remove(&batch.topic);
750                    }
751                }
752                Ok::<_, CommitLogError>(())
753            })
754            .await?;
755
756        Ok(save_remote_commit_log_results)
757    }
758
759    /// Open, check, and store each envelope read from a conversation's
760    /// commit-log topic, in order, then move the download cursor past the
761    /// last one. A discarded envelope is read past and never evaluated again.
762    fn save_remote_commit_log_entries_and_update_cursors(
763        &self,
764        conn: &impl DbQuery,
765        group_id: &GroupId,
766        keys: &GroupKeys,
767        entries: Vec<DecodedCommitLogEntry>,
768    ) -> Result<(usize, bool), CommitLogError> {
769        // implements: FORK-087
770        // Read the durable processed position before loading the tree. Commits
771        // and their positions share a transaction, so this tree cannot precede
772        // the position against which the hold is checked.
773        let processed = conn
774            .latest_cursor_for_id(
775                group_id,
776                &[xmtp_db::refresh_state::EntityKind::ApplicationMessage],
777            )?
778            .0;
779        let (active, publishers, boundaries, reader_epoch_floor) =
780            crate::state_tx::state_write(self.context.mls_storage(), |tx| {
781                tx.with_group(*group_id, |g, storage| {
782                    use crate::groups::mls_ext::CommitLogStorer;
783                    Ok::<_, GroupError>((
784                        g.is_active(),
785                        ForkActors::from_group(g)?.publisher_keys(g)?,
786                        storage.db().get_commit_log_publisher_boundaries(group_id)?,
787                        g.initialize_commit_log_reader_epoch(&storage.db())?,
788                    ))
789                })
790                .map(Continue)
791            })?
792            .into_continued();
793        let mut evaluated_to = None;
794        let mut held = false;
795        let mut num_entries_saved = 0;
796        // From the stored remote commit log, fetch the following info:
797        // 1. The latest applied epoch authenticator
798        // 2. The latest applied epoch number
799        // 3. The latest stored sequence id
800        let mut latest_saved_remote_log = conn.get_latest_remote_log_for_group(group_id)?;
801        for decoded in &entries {
802            let Some(log_entry) = open_remote_entry(keys, group_id, decoded) else {
803                evaluated_to = Some(decoded.cursor);
804                continue;
805            };
806            // implements: FORK-087
807            if log_entry.applied_epoch_number < reader_epoch_floor {
808                evaluated_to = Some(decoded.cursor);
809                continue;
810            }
811            if active && log_entry.commit_sequence_id > processed {
812                held = true;
813                break;
814            }
815            evaluated_to = Some(decoded.cursor);
816            if !publishers.contains(&log_entry.author_installation_key) {
817                continue;
818            }
819            let chain_holds = latest_saved_remote_log.as_ref().is_some_and(|latest| {
820                let boundary = boundaries
821                    .get(&latest.author_installation_key)
822                    .copied()
823                    .unwrap_or(0);
824                publishers.contains(&latest.author_installation_key)
825                    && !(latest.commit_sequence_id < boundary
826                        && log_entry.commit_sequence_id >= boundary as u64)
827            });
828            if should_skip_remote_commit_log_entry(
829                latest_saved_remote_log.as_ref(),
830                &log_entry,
831                chain_holds,
832            ) {
833                continue;
834            }
835
836            let commit_result = CommitResult::from(
837                ProtoCommitResult::try_from(log_entry.commit_result)
838                    .unwrap_or(ProtoCommitResult::Unspecified),
839            );
840            num_entries_saved += 1;
841            NewRemoteCommitLog {
842                log_sequence_id: decoded.cursor.0 as i64,
843                group_id: *group_id,
844                commit_sequence_id: log_entry.commit_sequence_id as i64,
845                commit_result,
846                applied_epoch_number: log_entry.applied_epoch_number as i64,
847                applied_epoch_authenticator: log_entry.applied_epoch_authenticator.clone(),
848                author_installation_key: log_entry.author_installation_key.clone(),
849            }
850            .store(conn)?;
851
852            latest_saved_remote_log = Some(RemoteCommitLog {
853                rowid: 0,
854                log_sequence_id: decoded.cursor.0 as i64,
855                group_id: *group_id,
856                commit_sequence_id: log_entry.commit_sequence_id as i64,
857                commit_result,
858                applied_epoch_number: log_entry.applied_epoch_number as i64,
859                applied_epoch_authenticator: log_entry.applied_epoch_authenticator,
860                author_installation_key: log_entry.author_installation_key,
861            });
862        }
863        if let Some(cursor) = evaluated_to {
864            conn.update_cursor(
865                group_id,
866                xmtp_db::refresh_state::EntityKind::CommitLogDownload,
867                cursor,
868            )?;
869        }
870
871        Ok((num_entries_saved, held))
872    }
873
874    // Updates fork status for conversations in the database
875    pub async fn update_forked_state(&mut self) -> Result<(), CommitLogError> {
876        let conversation_ids_for_forked_state_check =
877            self.context.db().get_conversation_ids_for_fork_check()?;
878
879        for conversation_id in conversation_ids_for_forked_state_check {
880            self.refresh_fork_state(&GroupId::try_from(conversation_id)?)?;
881            tokio::task::yield_now().await;
882        }
883
884        Ok(())
885    }
886
887    /// Recompute and persist a conversation's fork status. A stored `forked`
888    /// is sticky and returned without recomputing.
889    // implements: FORK-041, FORK-082
890    fn refresh_fork_state(
891        &self,
892        conversation_id: &GroupId,
893    ) -> Result<Option<bool>, CommitLogError> {
894        crate::state_tx::state_write_with_events(
895            self.context.mls_storage(),
896            self.context.events(),
897            |tx, events| {
898                let storage = tx.storage();
899                let db = storage.db();
900                if db.get_group_commit_log_forked_status(conversation_id)? == Some(true) {
901                    return Ok::<_, CommitLogError>(Continue(Some(true)));
902                }
903                let is_forked = self.check_conversation_fork_state(&db, conversation_id)?;
904                db.set_group_commit_log_forked_status(conversation_id, is_forked)?;
905                if is_forked == Some(true) {
906                    let dm_id = db
907                        .find_group(conversation_id)?
908                        .and_then(|group| group.dm_id);
909                    events.emit_with_context(
910                        Some(ClientEvent::ConversationForkDetected(GroupRef {
911                            group_id: conversation_id.to_vec(),
912                        })),
913                        None,
914                        dm_event_context(dm_id.as_deref()),
915                    );
916                }
917                Ok(Continue(is_forked))
918            },
919        )
920        .map(TransactionOutcome::into_continued)
921    }
922
923    /// Explicit request recipients; the one-shot sender also includes its own inbox.
924    // implements: FORK-050
925    async fn permitted_readders(&self, group_id: &GroupId) -> Result<Vec<String>, CommitLogError> {
926        let (group, _) = MlsGroup::new_cached(self.context.clone(), group_id)?;
927        Ok(group.with_group_snapshot(|g| {
928            Ok::<_, GroupError>(ForkActors::from_group(g)?.request_targets(self.context.inbox_id()))
929        })?)
930    }
931
932    async fn request_readd(
933        &mut self,
934        group_info: StoredGroupForReaddRequest,
935    ) -> Result<(), CommitLogError> {
936        let conn = self.context.db();
937        let group_id = group_info.group_id;
938
939        // Check if a readd request has already been sent for this group
940        if conn.is_awaiting_readd(&group_id, self.context.installation_id().as_slice())? {
941            tracing::debug!(
942                group_id = %group_id,
943                "Skipping readd request for group because it has already been requested"
944            );
945            return Ok(());
946        }
947
948        tracing::debug!(group_id = %group_id, "Sending readd request");
949
950        // Send the one-shot request to the permitted readders.
951        let latest_commit_sequence_id = group_info
952            .latest_commit_sequence_id
953            .ok_or(CommitLogError::MissingLatestCommitSequenceId { group_id })?;
954        let oneshot_message = OneshotMessage {
955            message_type: Some(MessageType::ReaddRequest(ReaddRequest {
956                group_id: group_id.to_vec(),
957                latest_commit_sequence_id: latest_commit_sequence_id as u64,
958            })),
959        };
960        let readders = self.permitted_readders(&group_id).await?;
961        tracing::debug!(
962            group_id = %group_id,
963            "Sending readd request to {:?}",
964            readders
965        );
966        // Mark readd as requested
967        conn.update_requested_at_sequence_id(
968            &group_id,
969            self.context.installation_id().as_slice(),
970            latest_commit_sequence_id,
971        )?;
972
973        if let Err((error, definitely_unsent)) =
974            Oneshot::send_message_with_outcome(self.context.clone(), readders, oneshot_message)
975                .await
976        {
977            if definitely_unsent {
978                conn.delete_readd_statuses(
979                    &group_id,
980                    HashSet::from([self.context.installation_id().to_vec()]),
981                )?;
982            }
983            return Err(error.into());
984        }
985        tracing::debug!(group_id = %group_id, "Sent readd request",);
986        tracing::debug!(
987            group_id = %group_id,
988            sequence_id = latest_commit_sequence_id,
989            "Updated requested readd sequence id",
990        );
991
992        Ok(())
993    }
994
995    /// Send readd requests for all forked conversations
996    async fn send_outgoing_readd_requests(&mut self) -> Result<(), CommitLogError> {
997        if self.context.fork_recovery_opts().enable_recovery_requests == ForkRecoveryPolicy::None {
998            return Ok(());
999        }
1000        let conn = self.context.db();
1001
1002        // Fetch all forked groups with their latest epoch
1003        let mut forked_groups = conn.get_conversation_ids_for_requesting_readds()?;
1004        if self.context.fork_recovery_opts().enable_recovery_requests
1005            == ForkRecoveryPolicy::AllowlistedGroups
1006        {
1007            let groups_to_request_recovery = self
1008                .context
1009                .fork_recovery_opts()
1010                .groups_to_request_recovery
1011                .iter()
1012                .map(|group_id| group_id.normalize_hex())
1013                .collect::<HashSet<String>>();
1014            tracing::info!(
1015                "Forked groups: {:?}, allowlisted groups for sending recovery requests: {:?}",
1016                forked_groups
1017                    .iter()
1018                    .map(|group_info| group_info.group_id.to_string())
1019                    .collect::<Vec<String>>(),
1020                groups_to_request_recovery
1021            );
1022            forked_groups.retain(|group_info| {
1023                groups_to_request_recovery
1024                    .contains(&group_info.group_id.to_string().normalize_hex())
1025            });
1026        }
1027
1028        // Process groups in order, collecting per-group failures so one transient
1029        // error doesn't block the rest; a dropped pool still bubbles immediately.
1030        let mut failures = Vec::new();
1031        for group_info in forked_groups {
1032            let group_id = group_info.group_id;
1033            if let Err(source) = self.request_readd(group_info).await {
1034                if source.needs_db_reconnect() {
1035                    return Err(source);
1036                }
1037                failures.push(CommitLogError::FailedToSendReadd {
1038                    group_id,
1039                    source: Box::new(source),
1040                });
1041            }
1042        }
1043
1044        if !failures.is_empty() {
1045            return Err(CommitLogError::FailedReadds { errors: failures });
1046        }
1047
1048        Ok(())
1049    }
1050
1051    async fn handle_incoming_pending_readds(&mut self) -> Result<(), CommitLogError> {
1052        if self.context.fork_recovery_opts().disable_recovery_responses {
1053            return Ok(());
1054        }
1055        let now = xmtp_common::time::Instant::now();
1056        self.holds.lock().readd.retain(|_, until| now < *until);
1057        let conn = self.context.db();
1058        let groups_for_readd = conn.get_conversation_ids_for_responding_readds()?;
1059
1060        // Runs every worker tick (~2s); only speak when there's actually work.
1061        if groups_for_readd.is_empty() {
1062            return Ok(());
1063        }
1064        tracing::debug!(
1065            "Processing readd requests for {} groups",
1066            groups_for_readd.len()
1067        );
1068
1069        for group in groups_for_readd {
1070            // A rate-limited readd is not answered again at a later tick
1071            // inside its delay; other conversations still answer.
1072            // implements: RATE-041
1073            if self.holds.lock().readd.contains_key(&group.group_id) {
1074                continue;
1075            }
1076            match self.validate_pending_readds(&conn, &group).await {
1077                Ok(validated_installations) => {
1078                    if validated_installations.is_empty() {
1079                        continue;
1080                    }
1081                    let mls_group = MlsGroup::new(
1082                        self.context.clone(),
1083                        group.group_id,
1084                        group.dm_id.clone(),
1085                        group.conversation_type,
1086                        group.created_at_ns,
1087                    );
1088                    if let Err(e) = mls_group
1089                        .readd_installations(
1090                            validated_installations.into_iter().collect::<Vec<_>>(),
1091                        )
1092                        .await
1093                    {
1094                        let e = CommitLogError::from(e);
1095                        if e.needs_db_reconnect() {
1096                            return Err(e);
1097                        }
1098                        hold(
1099                            &mut self.holds.lock().readd,
1100                            group.group_id,
1101                            e.rate_limit_delay(),
1102                        );
1103                        tracing::warn!(
1104                            group_id = group.group_id.short_hex(),
1105                            "Keeping readd requests for group after a failed readd: {}",
1106                            e
1107                        );
1108                    }
1109                }
1110                Err(e) if e.needs_db_reconnect() => return Err(e),
1111                // The responder no longer consents to, is active in, or may
1112                // readd in the group: the requests are moot.
1113                Err(e @ CommitLogError::GroupReaddValidationError(_)) => {
1114                    tracing::warn!(
1115                        group_id = %group.group_id,
1116                        "Deleting readd statuses for group because it failed validation: {}",
1117                        e
1118                    );
1119                    conn.delete_other_readd_statuses(
1120                        &group.group_id,
1121                        self.context.installation_id().as_slice(),
1122                    )?;
1123                }
1124                // Reversible (e.g. a version pause): keep and retry next tick.
1125                Err(e) => {
1126                    hold(
1127                        &mut self.holds.lock().readd,
1128                        group.group_id,
1129                        e.rate_limit_delay(),
1130                    );
1131                    tracing::warn!(
1132                        group_id = group.group_id.short_hex(),
1133                        "Keeping readd requests for group after a failed validation: {}",
1134                        e
1135                    );
1136                }
1137            }
1138        }
1139
1140        Ok(())
1141    }
1142
1143    // implements: FORK-080, FORK-061
1144    async fn validate_pending_readds(
1145        &self,
1146        conn: &impl DbQuery,
1147        group: &StoredGroupForRespondingReadds,
1148    ) -> Result<HashSet<Vec<u8>>, CommitLogError> {
1149        let (mls_group, _) = MlsGroup::new_cached(self.context.clone(), &group.group_id)?;
1150        tracing::debug!(
1151            group_id = %mls_group.group_id,
1152            "Processing readd requests for group"
1153        );
1154
1155        let synced = mls_group.sync_with_conn().await;
1156        // A sync never changes consent or activity, so these drop moot
1157        // requests even when it fails (e.g. a paused group).
1158        if mls_group.consent_state()? != ConsentState::Allowed {
1159            return Err(CommitLogError::GroupReaddValidationError(
1160                "Group is not consented".to_string(),
1161            ));
1162        }
1163        if !mls_group.is_active()? {
1164            return Err(CommitLogError::GroupReaddValidationError(
1165                "Group is not active".to_string(),
1166            ));
1167        }
1168        // An unsynced commit may have made this member a readder.
1169        synced?;
1170        let actors =
1171            mls_group.with_group_snapshot(|g| Ok::<_, GroupError>(ForkActors::from_group(g)?))?;
1172        if !actors.permits(self.context.inbox_id()) {
1173            return Err(CommitLogError::GroupReaddValidationError(
1174                "No longer a permitted readder of group".to_string(),
1175            ));
1176        }
1177
1178        // A forked responder keeps the requests; a Welcome clears the fork.
1179        let fork_state = self.refresh_fork_state(&mls_group.group_id)?;
1180        if fork_state == Some(true) {
1181            tracing::info!(
1182                group_id = mls_group.group_id.short_hex(),
1183                "Responder is forked, keeping readd requests"
1184            );
1185            return Ok(HashSet::new());
1186        } else if fork_state.is_none() {
1187            tracing::info!(
1188                group_id = %mls_group.group_id,
1189                "Local commit log ahead of remote, skipping group"
1190            );
1191            return Ok(HashSet::new());
1192        }
1193
1194        let readd_statuses = conn.get_readds_awaiting_response(
1195            &mls_group.group_id,
1196            self.context.installation_id().as_slice(),
1197        )?;
1198        let mut unverified = readd_statuses
1199            .iter()
1200            .map(|readd_status| readd_status.installation_id.clone())
1201            .collect::<HashSet<_>>();
1202
1203        let (unverified, verified) = mls_group.with_group_snapshot(|openmls_group| {
1204            let mut verified = HashSet::new();
1205            for member in openmls_group.members() {
1206                if unverified.contains(&member.signature_key) {
1207                    unverified.remove(&member.signature_key);
1208                    verified.insert(member.signature_key);
1209                }
1210            }
1211            Ok::<_, GroupError>((unverified, verified))
1212        })?;
1213        tracing::debug!(
1214            group_id = %mls_group.group_id,
1215            "{} readd requests were for non-members, while {} were for members",
1216            unverified.len(),
1217            verified.len()
1218        );
1219        conn.delete_readd_statuses(&mls_group.group_id, unverified)?;
1220
1221        let processed = conn
1222            .latest_cursor_for_id(
1223                mls_group.group_id,
1224                &[xmtp_db::refresh_state::EntityKind::ApplicationMessage],
1225            )?
1226            .0;
1227        let now = now_ns();
1228        // Unsigned wire claims above i64::MAX are stored as negative values.
1229        // Keep them pending instead of treating them as an earlier position.
1230        Ok(readd_statuses
1231            .into_iter()
1232            .filter(|status| {
1233                verified.contains(&status.installation_id)
1234                    && retry_due(&status.installation_id, now)
1235                    && status.requested_at_sequence_id.is_some_and(|requested| {
1236                        u64::try_from(requested).is_ok_and(|requested| requested <= processed)
1237                    })
1238            })
1239            .map(|status| status.installation_id)
1240            .collect())
1241    }
1242
1243    fn check_conversation_fork_state(
1244        &self,
1245        conn: &impl DbQuery,
1246        conversation_id: &GroupId,
1247    ) -> Result<Option<bool>, CommitLogError> {
1248        // Get cursors for this conversation
1249        let fork_check_local_cursor = conn.get_last_cursor(
1250            conversation_id,
1251            xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1252        )?;
1253        let fork_check_remote_cursor = conn.get_last_cursor(
1254            conversation_id,
1255            xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1256        )?;
1257
1258        // Chain-start anchor: rows with `commit_sequence_id == 0` (Welcome /
1259        // GroupCreation / BackupRestore) mark the beginning of this member's
1260        // current membership session — e.g. rejoining via a new welcome after
1261        // having been removed. History before the latest chain start is not
1262        // attestable by this member and must not be compared against remote
1263        // consensus. Those rows are filtered out of
1264        // `get_local_commit_log_after_cursor`, so the anchor is looked up
1265        // separately and applied as a floor on the local fork-check cursor.
1266        let mut local_cursor = fork_check_local_cursor.0 as i64;
1267        let mut crossed_chain_start = false;
1268        if let Some(anchor_rowid) = conn.get_latest_chain_start_rowid(conversation_id)?
1269            && anchor_rowid as i64 > local_cursor
1270        {
1271            local_cursor = anchor_rowid as i64;
1272            crossed_chain_start = true;
1273            conn.update_cursor(
1274                conversation_id,
1275                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1276                Cursor(anchor_rowid as u64),
1277            )?;
1278        }
1279
1280        // Get local and remote commit logs
1281        let local_logs = conn.get_local_commit_log_after_cursor(
1282            conversation_id,
1283            local_cursor,
1284            LocalCommitLogOrder::DescendingByRowid,
1285        )?;
1286        let remote_logs = conn.get_remote_commit_log_after_cursor(
1287            conversation_id,
1288            fork_check_remote_cursor.0 as i64,
1289            RemoteCommitLogOrder::DescendingByRowid,
1290        )?;
1291
1292        // If there are no new commits to check, preserve the existing fork status
1293        if local_logs.is_empty() {
1294            if crossed_chain_start {
1295                // A new chain start (e.g. a welcome from being re-added)
1296                // invalidates any previously computed status: nothing after
1297                // it has been verified against remote consensus yet.
1298                return Ok(None);
1299            }
1300            return Ok(conn.get_group_commit_log_forked_status(conversation_id)?);
1301        }
1302
1303        let mut is_remote_log_up_to_date = true;
1304        // Check each local log against remote logs for matching commit_sequence_id
1305        for local_log in &local_logs {
1306            // Terminal removal marker: a commit that removed us merges only
1307            // the public diff — we cannot derive the new epoch's secrets, so
1308            // a Success row whose applied authenticator equals its last
1309            // authenticator means "this commit removed us", not a fork. The
1310            // remaining members publish the real post-commit authenticator,
1311            // which can never match ours, so this row must be excluded from
1312            // comparison. (Covers both new RemovedFromGroup rows and legacy
1313            // rows that recorded the new epoch with the stale authenticator.)
1314            if local_log.commit_result == CommitResult::Success
1315                && local_log.applied_epoch_authenticator == local_log.last_epoch_authenticator
1316            {
1317                // Note: `update_cursor` is monotonic (it only ever moves the
1318                // cursor forward), so the cursor updates below for older
1319                // matched rows — the loop walks descending rowids — cannot
1320                // regress the cursor behind this terminal row.
1321                conn.update_cursor(
1322                    conversation_id,
1323                    xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1324                    Cursor(local_log.rowid as u64),
1325                )?;
1326                continue;
1327            }
1328
1329            let Some(matching_remote_log) =
1330                self.find_matching_remote_log(&remote_logs, local_log.commit_sequence_id)
1331            else {
1332                is_remote_log_up_to_date = false;
1333                continue;
1334            };
1335            // Found a matching commit_sequence_id - check if forked
1336            let is_mismatched = local_log.applied_epoch_authenticator
1337                != matching_remote_log.applied_epoch_authenticator;
1338
1339            if is_mismatched {
1340                // No backend sequence id: with the group id it would link the
1341                // conversation to its commit-log topic.
1342                tracing::warn!(
1343                    group_id = %conversation_id.as_slice().snippet(),
1344                    commit_sequence_id = local_log.commit_sequence_id,
1345                    "Detected forked state: the local and remote epoch authenticators differ"
1346                );
1347            }
1348
1349            // Update cursors regardless of fork status (we found a match)
1350            conn.update_cursor(
1351                conversation_id,
1352                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckLocal,
1353                Cursor(local_log.rowid as u64),
1354            )?;
1355            conn.update_cursor(
1356                conversation_id,
1357                xmtp_db::refresh_state::EntityKind::CommitLogForkCheckRemote,
1358                Cursor(matching_remote_log.rowid as u64),
1359            )?;
1360
1361            if is_mismatched {
1362                return Ok(Some(true));
1363            } else if is_remote_log_up_to_date {
1364                return Ok(Some(false));
1365            } else {
1366                // If we haven't verified the latest commit local commit logs, we
1367                // don't know if we are forked or not
1368                return Ok(None);
1369            }
1370        }
1371
1372        Ok(None)
1373    }
1374
1375    fn find_matching_remote_log<'a>(
1376        &self,
1377        remote_logs: &'a [xmtp_db::remote_commit_log::RemoteCommitLog],
1378        commit_sequence_id: i64,
1379    ) -> Option<&'a xmtp_db::remote_commit_log::RemoteCommitLog> {
1380        remote_logs
1381            .iter()
1382            .find(|remote_log| remote_log.commit_sequence_id == commit_sequence_id)
1383    }
1384}
1385
1386/// Open one envelope read from a conversation's commit-log topic. `None`
1387/// discards it; the reason is logged without entry bytes. The log names the
1388/// envelope by its commit-log id and sequence id only, never by group id, so
1389/// it does not link the conversation to its commit-log topic.
1390// implements: FORK-077
1391fn open_remote_entry(
1392    keys: &GroupKeys,
1393    group_id: &GroupId,
1394    decoded: &DecodedCommitLogEntry,
1395) -> Option<PlaintextCommitLogEntry> {
1396    let commit_log_id = keys.commit_log_id();
1397    let Some(sealed) = &decoded.sealed else {
1398        tracing::warn!(
1399            commit_log_id = %commit_log_id.as_slice().snippet(),
1400            sequence_id = decoded.cursor.0,
1401            "Discarding a commit log envelope that holds no sealed entry"
1402        );
1403        return None;
1404    };
1405    open_entry(keys, group_id.as_slice(), decoded.cursor.0, sealed)
1406        .inspect_err(|reason| {
1407            tracing::warn!(
1408                commit_log_id = %commit_log_id.as_slice().snippet(),
1409                sequence_id = decoded.cursor.0,
1410                %reason,
1411                "Discarding a commit log entry"
1412            );
1413        })
1414        .ok()
1415}
1416
1417// If the last accepted author lost its publisher role or the entry crosses
1418// that author's publisher boundary, only rule 1 applies.
1419// Otherwise an opened entry is skipped if:
1420// 1. The commit_sequence_id of the entry is not greater than the most recently stored entry, if one exists.
1421// 2. 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.
1422// 3. 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.
1423// 4. The entry CommitResult is not COMMIT_RESULT_APPLIED, and the epoch authenticator or epoch number does not match the most recently applied values
1424// implements: FORK-083
1425pub(crate) fn should_skip_remote_commit_log_entry(
1426    latest_saved_remote_log: Option<&RemoteCommitLog>,
1427    entry: &PlaintextCommitLogEntry,
1428    chain_holds: bool,
1429) -> bool {
1430    let Some(latest_saved_remote_log) = latest_saved_remote_log else {
1431        return false;
1432    };
1433
1434    if !chain_holds {
1435        return entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64;
1436    }
1437    let is_applied = entry.commit_result == ProtoCommitResult::Applied as i32;
1438
1439    entry.commit_sequence_id <= latest_saved_remote_log.commit_sequence_id as u64
1440        || (is_applied
1441            && !latest_saved_remote_log
1442                .applied_epoch_authenticator
1443                .is_empty()
1444            && entry.last_epoch_authenticator
1445                != latest_saved_remote_log.applied_epoch_authenticator)
1446        || (is_applied
1447            && entry.applied_epoch_number as i64
1448                != latest_saved_remote_log.applied_epoch_number + 1)
1449        || (!is_applied
1450            && (entry.applied_epoch_authenticator
1451                != latest_saved_remote_log.applied_epoch_authenticator
1452                || entry.applied_epoch_number as i64
1453                    != latest_saved_remote_log.applied_epoch_number))
1454}
1455
1456// Helper that exposes private methods for testing
1457#[cfg(test)]
1458impl<Context> CommitLogWorker<Context>
1459where
1460    Context: XmtpSharedContext + 'static,
1461{
1462    pub async fn _tick(&mut self) -> Result<(), CommitLogError> {
1463        self.tick().await
1464    }
1465
1466    /// The worker's loop, as the supervisor runs it.
1467    pub async fn _run(&mut self) -> Result<(), CommitLogError> {
1468        self.run().await
1469    }
1470
1471    /// Pass every seal result through `fault`.
1472    pub(crate) fn with_seal_fault(mut self, fault: SealFault) -> Self {
1473        self.seal_fault = Some(fault);
1474        self
1475    }
1476
1477    // Test helper to get fork status for all groups that would be checked (for backward compatibility with tests)
1478    pub fn get_all_fork_statuses(&self) -> Result<HashMap<Vec<u8>, Option<bool>>, CommitLogError> {
1479        use xmtp_db::group::GroupQueryArgs;
1480        let conn = &self.context.db();
1481        // Get all groups (not just those with commit log keys)
1482        let all_groups = conn.find_groups(GroupQueryArgs::default())?;
1483
1484        let mut results = HashMap::new();
1485        for group in all_groups {
1486            let fork_status = conn.get_group_commit_log_forked_status(&group.id)?;
1487            results.insert(group.id.to_vec(), fork_status);
1488        }
1489
1490        Ok(results)
1491    }
1492
1493    /// Test-only version that runs without infinite loop
1494    pub async fn run_test(
1495        &mut self,
1496        commit_log_test_function: CommitLogTestFunction,
1497        iterations: Option<usize>,
1498    ) -> Result<Vec<TestResult>, CommitLogError> {
1499        let mut test_results = Vec::new();
1500        match iterations {
1501            Some(n) => {
1502                // Run exactly n times
1503                for _ in 0..n {
1504                    let test_result = self.test_helper(&commit_log_test_function).await?;
1505                    test_results.push(test_result);
1506                }
1507            }
1508            None => {
1509                let test_result = self.test_helper(&commit_log_test_function).await?;
1510                test_results.push(test_result);
1511            }
1512        }
1513        Ok(test_results)
1514    }
1515
1516    async fn test_helper(
1517        &mut self,
1518        commit_log_test_function: &CommitLogTestFunction,
1519    ) -> Result<TestResult, CommitLogError> {
1520        let mut test_result = TestResult {
1521            save_remote_commit_log_results: None,
1522            publish_commit_log_results: None,
1523            is_forked: None,
1524        };
1525        match commit_log_test_function {
1526            CommitLogTestFunction::PublishCommitLogsToRemote => {
1527                let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1528                test_result.publish_commit_log_results = Some(publish_commit_log_results);
1529            }
1530            CommitLogTestFunction::SaveRemoteCommitLog => {
1531                let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1532                test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1533            }
1534            CommitLogTestFunction::CheckForkedState => {
1535                self.update_forked_state().await?;
1536                let is_forked = self.get_all_fork_statuses()?;
1537                test_result.is_forked = Some(is_forked);
1538            }
1539            CommitLogTestFunction::All => {
1540                // Order is save; update fork status; publish
1541                let save_remote_commit_log_results = self.save_remote_commit_log().await?;
1542                test_result.save_remote_commit_log_results = Some(save_remote_commit_log_results);
1543                self.update_forked_state().await?;
1544                let is_forked = self.get_all_fork_statuses()?;
1545                test_result.is_forked = Some(is_forked);
1546                let publish_commit_log_results = self.publish_commit_logs_to_remote().await?;
1547                test_result.publish_commit_log_results = Some(publish_commit_log_results);
1548            }
1549        }
1550        Ok(test_result)
1551    }
1552}
1553
1554#[cfg(test)]
1555mod stored_cursor_tests {
1556    use super::*;
1557
1558    /// A conversation with `count` published entries on rows 10, 20, ...,
1559    /// whose last row is a skipped one after them.
1560    fn conversation(id: u8, count: usize) -> ConversationCursorInfo {
1561        let entry_rowids: Vec<i64> = (1..=count as i64).map(|n| n * 10).collect();
1562        ConversationCursorInfo {
1563            conversation_id: vec![id],
1564            num_entries_published: count,
1565            last_entry_published_sequence_id: count as i64,
1566            last_entry_published_rowid: entry_rowids.last().copied().unwrap_or(0) + 5,
1567            entry_rowids,
1568        }
1569    }
1570
1571    /// One conversation's backlog spans two requests and the second fails:
1572    /// the cursor moves to the last entry of the first request, so the next
1573    /// turn starts from entry 1001, not entry 1. A fully stored backlog moves
1574    /// past its last row, and a publish that stored nothing moves nothing.
1575    // verifies: FORK-021
1576    #[xmtp_common::test(unwrap_try = true)]
1577    fn a_partly_stored_backlog_resumes_from_its_first_unstored_entry() {
1578        let info = conversation(1, 1500);
1579        assert_eq!(info.stored_cursor(1000), Some(10_000));
1580        assert_eq!(info.stored_cursor(1), Some(10));
1581        assert_eq!(info.stored_cursor(1500), Some(15_005));
1582        assert_eq!(info.stored_cursor(0), None);
1583    }
1584
1585    /// A conversation whose rows are all skipped publishes nothing and moves
1586    /// past them.
1587    #[xmtp_common::test(unwrap_try = true)]
1588    fn a_conversation_with_only_skipped_rows_moves_past_them() {
1589        assert_eq!(conversation(2, 0).stored_cursor(0), Some(5));
1590    }
1591}
1592
1593#[cfg(all(test, not(target_arch = "wasm32")))]
1594mod publication_snapshot_tests {
1595    use super::*;
1596    use crate::groups::UpdateAdminListType::{AddSuper, RemoveSuper};
1597    use crate::prelude::*;
1598    use crate::tester;
1599    use std::sync::{Arc, Mutex, mpsc};
1600    use xmtp_db::mock::MockDbQuery;
1601
1602    // verifies: FORK-085
1603    #[xmtp_common::test(unwrap_try = true)]
1604    async fn publication_excludes_a_demotion_applied_after_its_authority_snapshot() {
1605        tester!(alix, disable_workers);
1606        tester!(bo, disable_workers);
1607        let a = alix
1608            .create_group_with_members(&[bo.inbox_id()], None, None)
1609            .await?;
1610        bo.sync_welcomes().await?;
1611        let b = bo.group(&a.group_id)?;
1612        a.update_admin_list(AddSuper, bo.inbox_id().to_string())
1613            .await?;
1614        b.sync().await?;
1615        let mut worker = CommitLogWorker::new(alix.context.clone());
1616        worker.publish_commit_logs_to_remote().await?;
1617
1618        a.update_group_name("pending before demotion".into())
1619            .await?;
1620        b.sync().await?;
1621        let pending = alix.context.db().get_latest_log_for_group(&a.group_id)??;
1622        b.update_admin_list(RemoveSuper, alix.inbox_id().to_string())
1623            .await?;
1624        let demotion = bo.context.db().get_latest_log_for_group(&a.group_id)??;
1625
1626        let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
1627        let (resume_tx, resume_rx) = mpsc::channel();
1628        let gate = Arc::new(Mutex::new(Some((ready_tx, resume_rx))));
1629        let pause = Arc::new(move || {
1630            let barrier = gate.lock().unwrap().take();
1631            if let Some((ready, resume)) = barrier {
1632                ready.send(()).unwrap();
1633                resume.recv_timeout(Duration::from_secs(30)).unwrap();
1634            }
1635        });
1636        let context = alix.context.clone();
1637        let group_id = a.group_id;
1638        let preparation = std::thread::spawn(move || {
1639            let mut conn = MockDbQuery::new();
1640            // Forward every read to the real database. Pause at the first read
1641            // outside the state transaction, even if the maximum-row read is
1642            // incorrectly moved out of that transaction in a future change.
1643            let db = context.db();
1644            let pause_cursor = pause.clone();
1645            conn.expect_get_local_commit_log_cursor()
1646                .returning(move |id| {
1647                    pause_cursor();
1648                    db.get_local_commit_log_cursor(id)
1649                });
1650            let db = context.db();
1651            conn.expect_get_latest_chain_start_rowid()
1652                .returning(move |id| {
1653                    pause();
1654                    db.get_latest_chain_start_rowid(id)
1655                });
1656            let db = context.db();
1657            conn.expect_get_last_cursor()
1658                .returning(move |id, kind| db.get_last_cursor(id, kind));
1659            let db = context.db();
1660            conn.expect_get_local_commit_log_after_cursor()
1661                .returning(move |id, cursor, order| {
1662                    db.get_local_commit_log_after_cursor(id, cursor, order)
1663                });
1664            CommitLogWorker::new(context)
1665                .prepare_conversation_publish(&conn, &group_id)
1666                .unwrap()
1667                .unwrap()
1668        });
1669
1670        xmtp_common::time::timeout(Duration::from_secs(30), ready_rx).await??;
1671        a.sync().await?;
1672        assert!(
1673            !alix
1674                .context
1675                .db()
1676                .find_group(&a.group_id)??
1677                .should_publish_commit_log
1678        );
1679        assert_eq!(
1680            alix.context
1681                .db()
1682                .get_latest_log_for_group(&a.group_id)??
1683                .commit_sequence_id,
1684            demotion.commit_sequence_id
1685        );
1686        resume_tx.send(())?;
1687        let prepared = preparation.join().unwrap();
1688
1689        let mut stored = Vec::new();
1690        alix.context
1691            .api()
1692            .publish_commit_log(prepared.entries, &mut stored)
1693            .await?;
1694        let keys = worker.group_keys(&a.group_id)??;
1695        let topic = Topic::new_commit_log(keys.commit_log_id());
1696        let remote = alix
1697            .context
1698            .api()
1699            .query_commit_log([(topic, Cursor(0))].into())
1700            .await?;
1701        let entries = remote
1702            .iter()
1703            .map(|entry| {
1704                open_entry(
1705                    &keys,
1706                    a.group_id.as_slice(),
1707                    entry.cursor.0,
1708                    entry.sealed.as_ref().unwrap(),
1709                )
1710                .unwrap()
1711            })
1712            .collect::<Vec<_>>();
1713        assert!(
1714            entries
1715                .iter()
1716                .any(|entry| entry.commit_sequence_id == pending.commit_sequence_id as u64)
1717        );
1718        assert!(
1719            entries
1720                .iter()
1721                .all(|entry| entry.commit_sequence_id != demotion.commit_sequence_id as u64)
1722        );
1723        assert_eq!(
1724            prepared.cursor_info.stored_cursor(stored.len()),
1725            Some(i64::from(pending.rowid))
1726        );
1727        assert!(
1728            worker
1729                .prepare_conversation_publish(&alix.context.db(), &a.group_id)?
1730                .is_none()
1731        );
1732    }
1733}
1734
1735// These fixtures use native pool errors. Browser connection lifecycle tests
1736// cover closed persistent handles in the WASM database module.
1737#[cfg(all(test, not(target_arch = "wasm32")))]
1738mod disconnect_propagation_tests {
1739    //! Pins that a dropped-pool signal survives this owner's error types, so
1740    //! `needs_db_reconnect()` stays `true` for the worker that surfaces them.
1741    use super::CommitLogError;
1742    use crate::core::reconnect::NeedsDbReconnect;
1743    use crate::groups::GroupError;
1744    use xmtp_db::{ConnectionError, PlatformStorageError, StorageError};
1745
1746    /// A `StorageError` that signals the connection pool was dropped.
1747    fn disconnect_storage() -> StorageError {
1748        StorageError::Platform(PlatformStorageError::PoolNeedsConnection)
1749    }
1750
1751    /// A `ConnectionError` that signals the connection pool was dropped.
1752    fn disconnect_connection() -> ConnectionError {
1753        ConnectionError::Platform(PlatformStorageError::PoolNeedsConnection)
1754    }
1755
1756    #[xmtp_common::test]
1757    fn commit_log_error_forwards_disconnect() {
1758        assert!(CommitLogError::Connection(disconnect_connection()).needs_db_reconnect());
1759        assert!(
1760            CommitLogError::GroupError(GroupError::Storage(disconnect_storage()))
1761                .needs_db_reconnect()
1762        );
1763        assert!(
1764            CommitLogError::SyncError(crate::groups::summary::SyncSummary::other(
1765                GroupError::Storage(disconnect_storage()),
1766            ))
1767            .needs_db_reconnect()
1768        );
1769        // A transient (non-disconnect) connection error must not stop the worker.
1770        assert!(
1771            !CommitLogError::Connection(ConnectionError::DisconnectInTransaction)
1772                .needs_db_reconnect()
1773        );
1774    }
1775}