Skip to content

Commit 0f3d67a

Browse files
insipxclaude
andcommitted
fix(xmtp_mls): forward db-disconnect signal through SyncSummary and sibling error variants
A dropped DB pool (PoolNeedsConnection) surfacing during a sync was misclassified as not-a-disconnect: SyncSummary had no NeedsDbReconnect impl, so any disconnect landing in its publish/post-commit/other/per-message errors got wrapped into GroupError::Sync / SyncFailedToWait / DeviceSyncError::Sync / CommitLogError::SyncError and hit `_ => false`. The worker supervisor never dropped the pool to reconnect — it retried forever on a dead pool (GroupError::SyncFailedToWait is also is_retryable, a true hot-loop). Fix — forward the signal everywhere it was dropped: - SqlKeyStoreError gains db_needs_connection() (mirrors StorageError / ConnectionError) so the OpenMLS key-store disconnect check lives in one place. - SyncSummary: NeedsDbReconnect scans all four error sources, including the per-message process.errored failures that is_retryable deliberately skips. - GroupMessageProcessingError: NeedsDbReconnect forwards the directly-wrapped DB variants and the OpenMLS state-I/O wrappers (process/merge), which carry a drop as StorageError(SqlKeyStoreError::Connection(_)). - GroupError: Sync / SyncFailedToWait / SqlKeyStore forwarded. - TaskWorkerError: Group / LoadGroup / DeviceSync forward the full classification instead of structurally matching only ::Storage. - CommitLogError: SyncError / KeystoreError forwarded. - DeviceSyncError: Sync forwarded. Every new arm bottoms out at db_needs_connection(), which matches only PoolNeedsConnection / Pool(_) — benign connection errors (e.g. DisconnectInTransaction) stay false, pinned by negative test cases. Rebased on main (post #3744 TaskRunner rework; tasks.rs merged with the new LoadGroup variant). Adds 3 disconnect-propagation tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 8ad390a commit 0f3d67a

8 files changed

Lines changed: 198 additions & 8 deletions

File tree

crates/xmtp_db/src/sql_key_store.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,14 @@ pub enum SqlKeyStoreError {
311311
Connection(#[from] crate::ConnectionError),
312312
}
313313

314+
impl SqlKeyStoreError {
315+
/// True when the pool can't currently hand out a connection. Mirrors
316+
/// [`StorageError::db_needs_connection`](crate::StorageError::db_needs_connection).
317+
pub fn db_needs_connection(&self) -> bool {
318+
matches!(self, Self::Connection(c) if c.db_needs_connection())
319+
}
320+
}
321+
314322
impl RetryableError for SqlKeyStoreError {
315323
fn is_retryable(&self) -> bool {
316324
use SqlKeyStoreError::*;

crates/xmtp_mls/src/groups/commit_log.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,16 +147,18 @@ impl NeedsDbReconnect for CommitLogError {
147147
// A readd send failure wraps an inner CommitLogError; forward.
148148
Self::FailedToSendReadd { source, .. } => source.needs_db_reconnect(),
149149
Self::FailedReadds { errors } => errors.iter().any(|e| e.needs_db_reconnect()),
150+
// A dropped pool can be wrapped inside a SyncSummary; forward.
151+
Self::SyncError(s) => s.needs_db_reconnect(),
152+
// OpenMLS key-store ops surface a disconnect as a Connection error.
153+
Self::KeystoreError(e) => e.db_needs_connection(),
150154
// Remaining variants can't carry a dropped-pool signal.
151155
Self::Diesel(_)
152156
| Self::Api(_)
153157
| Self::Prost(_)
154-
| Self::KeystoreError(_)
155158
| Self::CryptoError(_)
156159
| Self::TryFromSliceError(_)
157160
| Self::Conversion(_)
158161
| Self::GroupReaddValidationError(_)
159-
| Self::SyncError(_)
160162
| Self::MissingLatestCommitSequenceId { .. } => false,
161163
}
162164
}

crates/xmtp_mls/src/groups/error.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -653,6 +653,11 @@ impl crate::worker::NeedsDbReconnect for GroupError {
653653
Self::MlsStore(s) => s.needs_db_reconnect(),
654654
Self::Identity(i) => i.needs_db_reconnect(),
655655
Self::DeviceSync(d) => d.needs_db_reconnect(),
656+
// A dropped pool can be wrapped inside a SyncSummary's
657+
// publish/post-commit/other/per-message errors; forward it.
658+
Self::Sync(s) | Self::SyncFailedToWait(s) => s.needs_db_reconnect(),
659+
// OpenMLS key-store ops surface a disconnect as a Connection error.
660+
Self::SqlKeyStore(e) => e.db_needs_connection(),
656661
_ => false,
657662
}
658663
}

crates/xmtp_mls/src/groups/mls_sync.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,33 @@ impl RetryableError for GroupMessageProcessingError {
273273
}
274274
}
275275

276+
impl crate::worker::NeedsDbReconnect for GroupMessageProcessingError {
277+
/// Forwards a dropped-pool signal from storage-bearing variants so a
278+
/// per-message disconnect (landing in `SyncSummary::process.errored`) isn't lost.
279+
fn needs_db_reconnect(&self) -> bool {
280+
use super::app_data::ProcessMessageWithAppDataError;
281+
match self {
282+
Self::Storage(s) => s.db_needs_connection(),
283+
Self::Db(c) => c.db_needs_connection(),
284+
Self::Identity(i) => i.needs_db_reconnect(),
285+
Self::Client(c) => c.db_needs_connection(),
286+
// OpenMLS state-I/O carries a pool drop as
287+
// `StorageError(SqlKeyStoreError::Connection(_))`; forward it.
288+
Self::ClearPendingCommit(e) => e.db_needs_connection(),
289+
Self::OpenMlsProcessMessage(ProcessMessageError::StorageError(e)) => {
290+
e.db_needs_connection()
291+
}
292+
Self::OpenMlsProcessMessageWithAppData(ProcessMessageWithAppDataError::OpenMls(
293+
ProcessMessageError::StorageError(e),
294+
)) => e.db_needs_connection(),
295+
Self::MergeStagedCommit(openmls::group::MergeCommitError::StorageError(e)) => {
296+
e.db_needs_connection()
297+
}
298+
_ => false,
299+
}
300+
}
301+
}
302+
276303
impl GroupMessageProcessingError {
277304
pub(crate) fn commit_result(&self) -> CommitResult {
278305
use super::app_data::ProcessMessageWithAppDataError;

crates/xmtp_mls/src/groups/summary.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,24 @@ impl RetryableError for SyncSummary {
3030
}
3131
}
3232

33+
impl crate::worker::NeedsDbReconnect for SyncSummary {
34+
/// Scans every error source (incl. per-message `process.errored`, which
35+
/// `is_retryable` skips) so a dropped pool wrapped in a summary still surfaces.
36+
fn needs_db_reconnect(&self) -> bool {
37+
self.publish_errors.iter().any(|e| e.needs_db_reconnect())
38+
|| self
39+
.post_commit_errors
40+
.iter()
41+
.any(|e| e.needs_db_reconnect())
42+
|| self.other.as_ref().is_some_and(|e| e.needs_db_reconnect())
43+
|| self
44+
.process
45+
.errored
46+
.iter()
47+
.any(|(_, e)| e.needs_db_reconnect())
48+
}
49+
}
50+
3351
impl SyncSummary {
3452
/// synced a single message successfully
3553
pub fn single(msg: MessageIdentifier) -> Self {

crates/xmtp_mls/src/worker.rs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,10 @@ mod disconnect_propagation_tests {
446446
!CommitLogError::Connection(ConnectionError::DisconnectInTransaction)
447447
.needs_db_reconnect()
448448
);
449+
// A dropped pool wrapped in a SyncSummary (was `SyncError(_) => false`).
450+
let mut summary = crate::groups::summary::SyncSummary::default();
451+
summary.add_other(GroupError::Db(disconnect_connection()));
452+
assert!(CommitLogError::SyncError(summary).needs_db_reconnect());
449453
}
450454

451455
#[xmtp_common::test]
@@ -463,6 +467,10 @@ mod disconnect_propagation_tests {
463467
DeviceSyncError::Subscribe(SubscribeError::Db(disconnect_connection()))
464468
.needs_db_reconnect()
465469
);
470+
// A dropped pool wrapped in a SyncSummary (was caught by `_ => false`).
471+
let mut summary = crate::groups::summary::SyncSummary::default();
472+
summary.add_other(GroupError::Db(disconnect_connection()));
473+
assert!(DeviceSyncError::Sync(Box::new(summary)).needs_db_reconnect());
466474
assert!(!DeviceSyncError::Storage(benign_storage()).needs_db_reconnect());
467475
assert!(!DeviceSyncError::InvalidPayload.needs_db_reconnect());
468476
}
@@ -483,6 +491,128 @@ mod disconnect_propagation_tests {
483491
);
484492
assert!(!KeyPackagesCleanerError::Storage(benign_storage()).needs_db_reconnect());
485493
}
494+
495+
// SyncSummary previously had no NeedsDbReconnect impl, so a dropped pool in
496+
// any of its four error sources hot-looped the worker via `_ => false`.
497+
#[xmtp_common::test]
498+
fn sync_summary_forwards_disconnect() {
499+
use crate::groups::mls_sync::GroupMessageProcessingError;
500+
use crate::groups::summary::{ProcessSummary, SyncSummary};
501+
use xmtp_proto::types::Cursor;
502+
503+
// 1. disconnect in publish_errors
504+
let mut s = SyncSummary::default();
505+
s.add_publish_err(GroupError::Storage(disconnect_storage()));
506+
assert!(s.needs_db_reconnect(), "publish_errors disconnect");
507+
508+
// 2. disconnect in post_commit_errors
509+
let mut s = SyncSummary::default();
510+
s.add_post_commit_err(GroupError::Db(disconnect_connection()));
511+
assert!(s.needs_db_reconnect(), "post_commit_errors disconnect");
512+
513+
// 3. disconnect in `other`
514+
let mut s = SyncSummary::default();
515+
s.add_other(GroupError::Storage(disconnect_storage()));
516+
assert!(s.needs_db_reconnect(), "other disconnect");
517+
518+
// 4. disconnect in a per-message processing error (process.errored) —
519+
// the source is_retryable() deliberately skips.
520+
let mut process = ProcessSummary::default();
521+
process.errored.push((
522+
Cursor::new(1, 1u32),
523+
GroupMessageProcessingError::Db(disconnect_connection()),
524+
));
525+
let mut s = SyncSummary::default();
526+
s.add_process(process);
527+
assert!(s.needs_db_reconnect(), "process.errored disconnect");
528+
529+
// A benign sync error must NOT trip the contract (else the worker tears
530+
// down the pool needlessly on an ordinary per-message failure).
531+
let mut s = SyncSummary::default();
532+
s.add_publish_err(GroupError::Storage(benign_storage()));
533+
assert!(!s.needs_db_reconnect(), "benign publish error");
534+
535+
// And the forwarding must survive the GroupError wrappers the worker
536+
// actually inspects.
537+
let mut s = SyncSummary::default();
538+
s.add_publish_err(GroupError::Db(disconnect_connection()));
539+
assert!(
540+
GroupError::Sync(Box::new(s)).needs_db_reconnect(),
541+
"GroupError::Sync"
542+
);
543+
544+
let mut s = SyncSummary::default();
545+
s.add_other(GroupError::Storage(disconnect_storage()));
546+
assert!(
547+
GroupError::SyncFailedToWait(Box::new(s)).needs_db_reconnect(),
548+
"GroupError::SyncFailedToWait"
549+
);
550+
}
551+
552+
// OpenMLS state-I/O wrappers carry a pool drop as
553+
// `StorageError(SqlKeyStoreError::Connection(_))`; previously `_ => false`.
554+
#[xmtp_common::test]
555+
fn group_message_processing_error_forwards_openmls_disconnect() {
556+
use crate::groups::mls_sync::GroupMessageProcessingError;
557+
use openmls::group::MergeCommitError;
558+
use openmls::prelude::ProcessMessageError;
559+
use xmtp_db::sql_key_store::SqlKeyStoreError;
560+
561+
let disconnect_sql = || SqlKeyStoreError::Connection(disconnect_connection());
562+
let benign_sql = || SqlKeyStoreError::Connection(ConnectionError::DisconnectInTransaction);
563+
564+
// OpenMlsProcessMessage(StorageError(Connection(PoolNeedsConnection)))
565+
assert!(
566+
GroupMessageProcessingError::OpenMlsProcessMessage(ProcessMessageError::StorageError(
567+
disconnect_sql()
568+
))
569+
.needs_db_reconnect()
570+
);
571+
// MergeStagedCommit(StorageError(Connection(PoolNeedsConnection)))
572+
assert!(
573+
GroupMessageProcessingError::MergeStagedCommit(MergeCommitError::StorageError(
574+
disconnect_sql()
575+
))
576+
.needs_db_reconnect()
577+
);
578+
// A non-pool-drop connection error inside the same wrapper must NOT trip it.
579+
assert!(
580+
!GroupMessageProcessingError::OpenMlsProcessMessage(ProcessMessageError::StorageError(
581+
benign_sql()
582+
))
583+
.needs_db_reconnect()
584+
);
585+
// The directly-wrapped paths still work.
586+
assert!(GroupMessageProcessingError::Db(disconnect_connection()).needs_db_reconnect());
587+
assert!(!GroupMessageProcessingError::Storage(benign_storage()).needs_db_reconnect());
588+
}
589+
590+
// A disconnect can reach the task worker as Group(Db)/DeviceSync(Db), not
591+
// only as the structurally-matched ::Storage; previously those were `false`.
592+
#[xmtp_common::test]
593+
fn task_worker_error_forwards_disconnect() {
594+
use crate::worker::tasks::TaskWorkerError;
595+
assert!(
596+
TaskWorkerError::Group(GroupError::Db(disconnect_connection())).needs_db_reconnect(),
597+
"Group(Db) disconnect"
598+
);
599+
assert!(
600+
TaskWorkerError::Group(GroupError::MlsStore(MlsStoreError::Connection(
601+
disconnect_connection()
602+
)))
603+
.needs_db_reconnect(),
604+
"Group(MlsStore) disconnect"
605+
);
606+
assert!(
607+
TaskWorkerError::DeviceSync(DeviceSyncError::Db(disconnect_connection()))
608+
.needs_db_reconnect(),
609+
"DeviceSync(Db) disconnect"
610+
);
611+
assert!(
612+
!TaskWorkerError::Group(GroupError::InvalidGroupMembership).needs_db_reconnect(),
613+
"benign group error"
614+
);
615+
}
486616
}
487617

488618
#[cfg(test)]

crates/xmtp_mls/src/worker/device_sync/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,8 @@ impl NeedsDbReconnect for DeviceSyncError {
212212
Self::Group(e) => e.needs_db_reconnect(),
213213
Self::MlsStore(e) => e.needs_db_reconnect(),
214214
Self::Subscribe(e) => e.needs_db_reconnect(),
215+
// A dropped pool can be wrapped inside a SyncSummary; forward.
216+
Self::Sync(s) => s.needs_db_reconnect(),
215217
_ => false,
216218
}
217219
}

crates/xmtp_mls/src/worker/tasks.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,14 +45,12 @@ pub enum TaskWorkerError {
4545
impl NeedsDbReconnect for TaskWorkerError {
4646
fn needs_db_reconnect(&self) -> bool {
4747
match self {
48-
TaskWorkerError::Storage(s)
49-
| TaskWorkerError::DeviceSync(DeviceSyncError::Storage(s)) => s.db_needs_connection(),
48+
TaskWorkerError::Storage(s) => s.db_needs_connection(),
49+
// Forward the full classification: a dropped pool can reach these as
50+
// `Db`/`MlsStore`/`Sync`/... too, not only as `Storage`.
5051
TaskWorkerError::LoadGroup(e) => e.needs_db_reconnect(),
51-
// Forward through GroupError's own classifier so a dropped pool hiding
52-
// in a `Db`/`MlsStore` (not just `Storage`) variant still restarts the
53-
// worker instead of being retried on a dead connection.
5452
TaskWorkerError::Group(e) => e.needs_db_reconnect(),
55-
TaskWorkerError::DeviceSync(_) => false,
53+
TaskWorkerError::DeviceSync(e) => e.needs_db_reconnect(),
5654
TaskWorkerError::InvalidTaskData { .. } => false,
5755
TaskWorkerError::InvalidHash { .. } => false,
5856
TaskWorkerError::ReceiverLocked => false,

0 commit comments

Comments
 (0)