Skip to content

fix(xmtp_mls): forward db-disconnect signal through SyncSummary and sibling errors#3716

Open
insipx wants to merge 1 commit into
mainfrom
insipx/disconnect-classification
Open

fix(xmtp_mls): forward db-disconnect signal through SyncSummary and sibling errors#3716
insipx wants to merge 1 commit into
mainfrom
insipx/disconnect-classification

Conversation

@insipx

@insipx insipx commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Summary

A dropped DB pool (PoolNeedsConnection) that surfaced during a sync was being misclassified as not-a-disconnect, so the worker supervisor never dropped the pool to reconnect — it retried forever on a dead pool. The worst path (GroupError::SyncFailedToWait) is also is_retryable == true, making it a true hot-loop.

Root cause: SyncSummary — the aggregate sync error — had no NeedsDbReconnect impl at all. Any PoolNeedsConnection landing in its publish_errors / post_commit_errors / other / per-message process.errored got wrapped into GroupError::Sync / SyncFailedToWait / DeviceSyncError::Sync / CommitLogError::SyncError, all of which hit _ => false in needs_db_reconnect. The disconnect signal was permanently buried.

This is the same bug class the existing disconnect_propagation_tests were written to prevent — the SyncSummary path just wasn't covered.

Fix — forward the disconnect signal everywhere it was being dropped

  • SqlKeyStoreError::db_needs_connection() (new, in xmtp_db) — mirrors StorageError/ConnectionError, so the OpenMLS key-store disconnect check lives in one place instead of three nested-variant matches.
  • SyncSummary: NeedsDbReconnect (new) — scans all four error sources, including the per-message process.errored failures (which is_retryable deliberately skips, but which can still carry a pool drop).
  • GroupMessageProcessingError: NeedsDbReconnect (new) — forwards the directly-wrapped DB variants (Storage/Db/Identity/Client/ClearPendingCommit) and the OpenMLS state-I/O wrappers (OpenMlsProcessMessage, OpenMlsProcessMessageWithAppData, MergeStagedCommit), which carry a drop as StorageError(SqlKeyStoreError::Connection(_)) — the most likely per-message disconnect carriers.
  • GroupError::needs_db_reconnectSync / SyncFailedToWait now forward to SyncSummary; SqlKeyStore forwarded.
  • TaskWorkerError::needs_db_reconnectGroup / LoadGroup / DeviceSync forward the full classification instead of structurally matching only the inner Storage variant.
  • CommitLogErrorSyncError / KeystoreError forwarded.
  • DeviceSyncErrorSync forwarded.

Safety: no false positives

Every new arm bottoms out at db_needs_connection(), which matches only PoolNeedsConnection (and the pre-existing Pool(_)). A non-pool-drop connection error like DisconnectInTransaction stays classified false, so a healthy pool is never torn down needlessly. Each new test includes a benign-error negative case to pin this.

Rebase

Rebased onto latest main (post-#3744 TaskRunner rework); TaskWorkerError merge keeps the new LoadGroup variant forwarding.

Test Plan

  • cargo check -p xmtp_mls --tests — compiles clean
  • just lint-rust — clippy / fmt / hakari clean
  • cargo nextest run -p xmtp_mls disconnect_propagation_tests — 10/10 pass (3 new: sync_summary_forwards_disconnect, group_message_processing_error_forwards_openmls_disconnect, task_worker_error_forwards_disconnect)
  • cargo nextest run -p xmtp_mls -E 'test(worker)' — 33/33 pass (no regressions)

🤖 Generated with Claude Code

Note

Forward db-disconnect signals through SyncSummary and sibling error types in xmtp_mls

Several NeedsDbReconnect trait implementations previously dropped pool-disconnect signals when errors were wrapped in SyncSummary, SyncError, KeystoreError, or OpenMLS storage error paths, causing reconnect logic to silently miss these conditions.

  • SyncSummary gets a new NeedsDbReconnect impl that checks all contained error collections (publish_errors, post_commit_errors, other, process.errored).
  • GroupError, CommitLogError, and DeviceSyncError now delegate to inner SyncSummary::needs_db_reconnect() instead of returning false.
  • GroupMessageProcessingError gets a new NeedsDbReconnect impl covering OpenMLS StorageError and MergeCommitError paths.
  • TaskWorkerError now fully delegates DeviceSync(e) to e.needs_db_reconnect() rather than only forwarding the Storage sub-variant.
📊 Macroscope summarized 0f3d67a. 8 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@insipx insipx requested a review from a team as a code owner June 3, 2026 23:06
@claude

claude Bot commented Jun 3, 2026

Copy link
Copy Markdown

Claude finished @insipx's task —— View job


Code Review Completed ✓

Review Date: June 10, 2026


Summary

This PR correctly fixes a critical bug where database disconnects were being permanently buried when wrapped in SyncSummary and related error types. The implementation is sound and comprehensive.

Strengths

  1. Comprehensive coverage — The fix addresses all four error sources in SyncSummary (publish, post-commit, other, and per-message process.errored), including the tricky per-message path that is_retryable deliberately skips.

  2. No false positives — Every new needs_db_reconnect arm bottoms out at db_needs_connection(), which matches only PoolNeedsConnection (and pre-existing Pool(_)). Benign errors like DisconnectInTransaction correctly stay false.

  3. Excellent test coverage — The three new tests (sync_summary_forwards_disconnect, group_message_processing_error_forwards_openmls_disconnect, task_worker_error_forwards_disconnect) cover all the new propagation paths and include negative cases to prevent false positives.

  4. Proper delegation patternTaskWorkerError now delegates to e.needs_db_reconnect() instead of structural matching, which is more maintainable and catches all paths.

Minor Observations (Not Blocking)

  1. Test isolation — The disconnect tests rely on helper functions disconnect_storage() and disconnect_connection() that aren't shown in the diff. Assuming these exist and create the appropriate error variants.

  2. Codecov gap — 11 lines missing coverage (90.43% patch coverage). The uncovered lines are likely in less-common error paths:

    • crates/xmtp_mls/src/groups/mls_sync.rs: 6 lines (likely the new OpenMLS wrapper forwarding in GroupMessageProcessingError::needs_db_reconnect at lines 289-297)
    • crates/xmtp_mls/src/groups/commit_log.rs: 2 lines (the SyncError and KeystoreError(Connection) arms at lines 151, 153)
    • crates/xmtp_mls/src/groups/error.rs: 2 lines (the Sync/SyncFailedToWait forwarding at line 658)
    • crates/xmtp_mls/src/worker/tasks.rs: 1 line (likely the DeviceSync forwarding at line 53)

    These are acceptable gaps for error-path coverage — hitting them would require simulating a pool drop at very specific points in message processing.

  3. Consistency — The PR correctly maintains the existing distinction between is_retryable (sync-operation success/failure) and needs_db_reconnect (worker hot-loop prevention).

Code Quality

  • Architecture: Follows the existing trait-based forwarding pattern consistently
  • Safety: No unwraps, all error paths properly delegated
  • Performance: Zero-cost abstractions, no allocations in the happy path
  • Documentation: Doc comments explain the "why" (hot-loop prevention)

Verdict

Approve — This is a well-crafted fix for a critical runtime bug. The implementation is correct, thorough, and well-tested.


macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Jun 3, 2026
@macroscopeapp

macroscopeapp Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found.

You can customize Macroscope's approvability policy. Learn more.

@codecov

codecov Bot commented Jun 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.66055% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.49%. Comparing base (8ad390a) to head (0f3d67a).

Files with missing lines Patch % Lines
crates/xmtp_mls/src/groups/mls_sync.rs 60.00% 6 Missing ⚠️
crates/xmtp_mls/src/groups/commit_log.rs 50.00% 1 Missing ⚠️
crates/xmtp_mls/src/groups/error.rs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3716      +/-   ##
==========================================
+ Coverage   84.45%   84.49%   +0.03%     
==========================================
  Files         408      408              
  Lines       59772    59878     +106     
==========================================
+ Hits        50483    50594     +111     
+ Misses       9289     9284       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…ibling 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>
@insipx insipx force-pushed the insipx/disconnect-classification branch from fae72a0 to 0f3d67a Compare June 10, 2026 02:57
@macroscopeapp macroscopeapp Bot dismissed their stale review June 10, 2026 02:57

Dismissing prior approval to re-evaluate 0f3d67a

Comment on lines +276 to +301
impl crate::worker::NeedsDbReconnect for GroupMessageProcessingError {
/// Forwards a dropped-pool signal from storage-bearing variants so a
/// per-message disconnect (landing in `SyncSummary::process.errored`) isn't lost.
fn needs_db_reconnect(&self) -> bool {
use super::app_data::ProcessMessageWithAppDataError;
match self {
Self::Storage(s) => s.db_needs_connection(),
Self::Db(c) => c.db_needs_connection(),
Self::Identity(i) => i.needs_db_reconnect(),
Self::Client(c) => c.db_needs_connection(),
// OpenMLS state-I/O carries a pool drop as
// `StorageError(SqlKeyStoreError::Connection(_))`; forward it.
Self::ClearPendingCommit(e) => e.db_needs_connection(),
Self::OpenMlsProcessMessage(ProcessMessageError::StorageError(e)) => {
e.db_needs_connection()
}
Self::OpenMlsProcessMessageWithAppData(ProcessMessageWithAppDataError::OpenMls(
ProcessMessageError::StorageError(e),
)) => e.db_needs_connection(),
Self::MergeStagedCommit(openmls::group::MergeCommitError::StorageError(e)) => {
e.db_needs_connection()
}
_ => false,
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium groups/mls_sync.rs:276

GroupMessageProcessingError::needs_db_reconnect() returns false for Self::Intent(i), hiding database disconnects that surface through IntentError::Storage(StorageError::Connection(PoolNeedsConnection)). The worker will retry on a dead pool because the disconnect signal is lost. Consider matching on Self::Intent and delegating to IntentError::needs_db_reconnect() if available, or add a variant check for the Storage wrapper.

            Self::MergeStagedCommit(openmls::group::MergeCommitError::StorageError(e)) => {
                e.db_needs_connection()
            }
+            Self::Intent(i) => i.needs_db_reconnect(),
             _ => false,
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mls_sync.rs around lines 276-301:

`GroupMessageProcessingError::needs_db_reconnect()` returns `false` for `Self::Intent(i)`, hiding database disconnects that surface through `IntentError::Storage(StorageError::Connection(PoolNeedsConnection))`. The worker will retry on a dead pool because the disconnect signal is lost. Consider matching on `Self::Intent` and delegating to `IntentError::needs_db_reconnect()` if available, or add a variant check for the `Storage` wrapper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant