Skip to content

feat(xmtp_mls): StreamRouter — per-stream decode, dedup, and delivery over the bidi transport#3826

Merged
tylerhawkes merged 1 commit into
mainfrom
tyler/xip83-9-stream-router
Jul 8, 2026
Merged

feat(xmtp_mls): StreamRouter — per-stream decode, dedup, and delivery over the bidi transport#3826
tylerhawkes merged 1 commit into
mainfrom
tyler/xip83-9-stream-router

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The client-level StreamRouter (XIP-83 client integration, phase B2): one router per client, sitting on the process-level BidiTransport (#3825 — this PR is stacked on it). Consumers get decoded, deduplicated streams (stream_messages, stream_conversations) whose subscribe is the catch-up: each stream leases its topics with durable resume cursors and the server replays from there.

Design

  • One task per stream, nothing shared. Each stream owns its lease, its dedup state, and its bounded delivery channel. A stall in one (e.g. a multi-second recovery sync in the pipeline) backs up only that stream's own lease — siblings are never delayed. The router task only seeds, leases, and spawns.
  • Decode via the shared pipeline seams (process_one / process_welcome_one from refactor(xmtp_mls): extract shared message-processing pipeline #3823). Decrypt/store happens once per unique message; replay copies resolve from the DB fast-path.
  • Dedup: steady state is a per-topic GlobalCursor high-water mark. During a stream's catch-up window (subscribe → its CatchUpComplete), wire order is legitimately not cursor-monotonic (the transport's documented replay semantics), so the window instead checks a frozen subscribe-time floor + an exact-identity seen-set, pre-seeded with locally-stored messages above the durable cursor (the streaming pipeline stores without advancing refresh_state, so the durable cursor alone is not a delivery floor).
  • Welcome streams keep a per-stream known-welcome set (streams with different filters must never suppress each other) and seed the wire from the durable EntityKind::Welcome cursor.
  • Streams outlive router handles; dropping a RouterStream is the unsubscribe (lease deref → refcounted wire removal).

Adversarial review (2 independent passes) — all findings fixed in this PR

Cross-stream welcome suppression via shared dedup state; router-lifetime contract violation (handles dropping killed live streams); catch-up window missing its seed floor (sibling replay leaked pre-subscribe history); recovery-sync fallback double-delivery (window now records the delivered identity, not just the envelope's); welcome wire seed read from group_cursors max instead of the durable Welcome cursor (replay storms + mixed-originator mis-seeding); re-subscribe re-delivery of everything streamed since the last full sync (messages_newer_than seeding restored, mirroring the legacy stream); inline decode starving sibling streams (solved structurally by per-stream tasks).

Test plan

  • Live integration (local node): live delivery; catch-up replay from durable cursors; re-subscribe does not re-deliver (pins the stored-message seeding); two sibling conversation streams both receive the same welcome (pins per-stream dedup).
  • Unit: catch-up window semantics (frozen-seed floor, identity dedup that doesn't swallow leapfrogged replays, delivered-identity recording, window→live handoff).
  • clippy clean, fmt clean; full suite unaffected.

🤖 Generated with Claude Code

Note

Add StreamRouter for per-stream decode, dedup, and delivery over the bidi transport

  • Introduces StreamRouter (native-only) with stream_messages and stream_conversations APIs that lease topics via a shared BidiTransport, spawn per-stream consumer tasks, and return bounded RouterStreams.
  • Adds StreamDedup with Syncing/Live states to correctly deduplicate messages during the catch-up window (using frozen seeds + exact-identity sets) before switching to high-water-mark dedup in steady state.
  • Updates QueryGroupMessage::messages_newer_than to return Vec<(GroupId, Cursor)> so cursors are attributed to their originating group, which is required for correct per-topic position seeding on resubscribe.
  • fold_stored seeds per-topic resume positions and the exact-identity seen set from durable storage before leasing live topics, enabling no-redelivery resubscribe.
  • Adds live integration tests in stream_router_tests.rs covering live delivery, cursor catch-up, resubscribe idempotency, and sibling welcome stream delivery.
  • Behavioral Change: messages_newer_than signature change is a breaking API update affecting all callers, including stream_messages.rs and the mock in mock.rs.

Macroscope summarized 3dc778d.

@tylerhawkes tylerhawkes requested a review from a team as a code owner July 6, 2026 22:27
@claude

claude Bot commented Jul 6, 2026

Copy link
Copy Markdown

Claude finished @tylerhawkes's task —— View job


Code Review Complete

Review Date: 2026-07-07 23:30 UTC

Tasks

  • Read stream_router.rs implementation
  • Read stream_router_tests.rs
  • Review other changed files
  • Analyze for issues not already identified
  • Provide concise feedback

Review Feedback

I've reviewed this PR focusing on issues not already covered by Macroscope's automated review. Most of the critical correctness issues identified by Macroscope appear to have been resolved in commit 1572f61. Here are additional observations:

1. Documentation Quality

The module-level documentation (lines 1-48 in stream_router.rs) is exceptionally clear. It explains the architecture, cursor/dedup semantics, and backpressure policy thoroughly. This is excellent for maintainability.

2. Test Coverage Gaps ⚠️

The integration tests (stream_router_tests.rs) are solid for happy-path scenarios but miss several error cases:

  • Wire death recovery: No test verifying that when lease.next() returns None, the stream properly ends and consumers can re-subscribe
  • Backpressure drop: No test for the bounded channel filling up and the stream being dropped
  • Multiple groups in one stream: stream_messages accepts Vec<GroupId> but tests only use single-group subscriptions
  • Filter effectiveness: stream_conversations accepts filters (conversation_type, consent_states) but no tests verify filtering works correctly

Recommendation: Add integration tests for multi-group message streams and conversation filtering.

3. Performance Consideration: Sequential Processing 🔍

In MessageConsumer::deliver_batch (lines 541-607), messages within a batch are processed sequentially with an await per message. The comment on line 566 explains this is intentional (to race against kill), but for large catch-up batches this could be slow.

The process_one call can trigger a "long recovery sync" (per your own comment line 566). If a batch contains 100 messages, and 10 of them require recovery syncs, that's potentially many seconds of linear processing.

Not necessarily a bug, but worth considering whether batches could be processed with limited concurrency (e.g., futures::stream::iter(...).buffer_unordered(N)) while still racing against kill.

4. API Ergonomics: Stream Depth Parameter 🤔

Both stream_messages and stream_conversations require callers to pass a depth: usize parameter. The API would be cleaner if this defaulted to DEFAULT_STREAM_DEPTH (currently only exported as a const).

Suggestion: Consider adding convenience methods:

pub async fn stream_messages_default(
    &self,
    group_ids: Vec<GroupId>,
) -> Result<RouterStream<StoredGroupMessage>, RouterError> {
    self.stream_messages(group_ids, DEFAULT_STREAM_DEPTH).await
}

Or use Rust's Default trait / builder pattern.

5. Minor: Typo in Comment 📝

Line 334 in stream_router.rs:

// Seed every group from its durable cursor; the cursored lease makes

"cursored" is not standard English. Consider "cursor-based lease" or "cursored-lease".


Summary

The implementation is solid overall. The adversarial review process clearly worked — the major correctness issues were caught and fixed. The code is well-documented and the architecture (one task per stream) is sound.

Main suggestions:

  1. Expand integration test coverage (multi-group, filtering, error paths)
  2. Consider batch processing performance for large catch-ups
  3. Minor API ergonomics improvement for stream depth

No blocking issues identified beyond what Macroscope already flagged.


Comment thread crates/xmtp_mls/src/subscriptions/stream_router.rs Outdated
Comment on lines +611 to +614
if self.known.contains(&typed.cursor) {
continue;
}

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 subscriptions/stream_router.rs:611

WelcomeConsumer::deliver_batch skips any welcome whose cursor is already in self.known and continues without calling process_welcome_one. That bypasses ProcessWelcomeFuture's known-cursor fallback (load_from_store + re-process), so a stale entry in db.group_cursors() — e.g. after partial local data loss or an out-of-sync cache — causes the welcome to be dropped permanently instead of rebuilding the conversation. Consider removing the self.known.contains pre-check so process_welcome_one always runs and its existing dedup/rebuild logic handles known cursors.

Suggested change
if self.known.contains(&typed.cursor) {
continue;
}
// Sequential against this stream's own set — exactly the
// serialization `process_welcome_one`'s contract requires.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around lines 611-614:

`WelcomeConsumer::deliver_batch` skips any welcome whose cursor is already in `self.known` and `continue`s without calling `process_welcome_one`. That bypasses `ProcessWelcomeFuture`'s known-cursor fallback (`load_from_store` + re-process), so a stale entry in `db.group_cursors()` — e.g. after partial local data loss or an out-of-sync cache — causes the welcome to be dropped permanently instead of rebuilding the conversation. Consider removing the `self.known.contains` pre-check so `process_welcome_one` always runs and its existing dedup/rebuild logic handles known cursors.

@macroscopeapp

macroscopeapp Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

8 blocking correctness issues found. New feature introducing ~800 lines of StreamRouter logic with complex dedup and async patterns. Multiple unresolved HIGH severity comments identify potential bugs including message loss scenarios and cross-topic dedup collisions that warrant human review.

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

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.42054% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.95%. Comparing base (4dda49c) to head (3dc778d).

Files with missing lines Patch % Lines
crates/xmtp_mls/src/subscriptions/stream_router.rs 92.40% 30 Missing ⚠️
...rates/xmtp_db/src/encrypted_store/group_message.rs 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3826      +/-   ##
==========================================
+ Coverage   84.83%   84.95%   +0.11%     
==========================================
  Files         410      411       +1     
  Lines       62115    62519     +404     
==========================================
+ Hits        52694    53110     +416     
+ Misses       9421     9409      -12     

☔ 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.

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-8-bidi-transport branch from 01b36e2 to aec0b1a Compare July 7, 2026 17:43
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from 03dd434 to 374435a Compare July 7, 2026 17:43
Comment thread crates/xmtp_mls/src/subscriptions/stream_router.rs Outdated
Comment thread crates/xmtp_mls/src/subscriptions/stream_router.rs
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-8-bidi-transport branch from aec0b1a to 13cf512 Compare July 7, 2026 19:02
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from 374435a to e1da5c0 Compare July 7, 2026 19:02
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-8-bidi-transport branch from 13cf512 to c9d257b Compare July 7, 2026 20:42
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from e1da5c0 to a9b5e5c Compare July 7, 2026 20:42
Comment thread crates/xmtp_mls/src/subscriptions/stream_router.rs
@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from a9b5e5c to 1572f61 Compare July 7, 2026 21:14
Base automatically changed from tyler/xip83-8-bidi-transport to main July 7, 2026 21:21
/// scoped per group, so a cross-group apply would swallow other groups'
/// deliveries) — and collect them all as the window's exact-identity seen-set
/// (exact identity is safe across groups).
fn fold_stored(

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 subscriptions/stream_router.rs:462

fold_stored adds every locally-stored cursor above the durable floor into the new stream's seen set, so when two subscribe_messages subscriptions target the same group the second one treats messages already stored by the first as already-delivered and skips them in MessageConsumer::deliver_batch. The later subscriber never receives those recent messages, breaking independent catch-up for sibling streams.

The seen set should be seeded only from identities the current stream itself has already delivered, not from all messages in local storage.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around line 462:

`fold_stored` adds every locally-stored cursor above the durable floor into the new stream's `seen` set, so when two `subscribe_messages` subscriptions target the same group the second one treats messages already stored by the first as already-delivered and skips them in `MessageConsumer::deliver_batch`. The later subscriber never receives those recent messages, breaking independent catch-up for sibling streams.

The `seen` set should be seeded only from identities the current stream itself has already delivered, not from all messages in local storage.

Comment thread crates/xmtp_mls/src/subscriptions/stream_router.rs Outdated
where
Context: XmtpSharedContext + 'static,
{
async fn run(mut self, mut kill: oneshot::Receiver<()>) {

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.

🟠 High subscriptions/stream_router.rs:620

A client that creates a group or DM after subscribing to the conversation stream never receives that conversation on the stream. The old conversation stream merged local NewGroup events (emitted by create_group/find_or_create_dm) so the creating client saw its own new conversation immediately, but WelcomeConsumer::run only processes events from the welcome-topic lease and has no path for local creation events. Consider merging local events into the consumer loop (or having the router fan them into self.tx) so self-created conversations are still delivered.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around line 620:

A client that creates a group or DM after subscribing to the conversation stream never receives that conversation on the stream. The old conversation stream merged local `NewGroup` events (emitted by `create_group`/`find_or_create_dm`) so the creating client saw its own new conversation immediately, but `WelcomeConsumer::run` only processes events from the welcome-topic lease and has no path for local creation events. Consider merging local events into the consumer loop (or having the router fan them into `self.tx`) so self-created conversations are still delivered.

Comment thread crates/xmtp_api_d14n/src/queries/bidi_transport.rs
Comment on lines +219 to +246
seen: HashSet<Cursor>,
},
Live,
}

impl StreamDedup {
/// `seen` starts with the identities already stored locally (delivered by
/// an earlier stream or a sync) — the durable cursor alone is not a
/// delivery floor, because the streaming pipeline stores without
/// advancing it.
fn syncing(seeds: HashMap<Topic, GlobalCursor>, seen: HashSet<Cursor>) -> Self {
Self::Syncing { seeds, seen }
}

fn has_seen(&self, position: &GlobalCursor, topic: &Topic, cursor: &Cursor) -> bool {
match self {
Self::Syncing { seeds, seen } => {
seeds.get(topic).is_some_and(|seed| seed.has_seen(cursor)) || seen.contains(cursor)
}
Self::Live => position.has_seen(cursor),
}
}

fn record(&mut self, cursor: Cursor) {
if let Self::Syncing { seen, .. } = self {
seen.insert(cursor);
}
}

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.

🟠 High subscriptions/stream_router.rs:219

StreamDedup::has_seen uses a stream-wide HashSet<Cursor> during the catch-up window, but Cursor does not include the topic. Sequence ids are not scoped per group, so two different group-message topics can legitimately produce the same (originator_id, sequence_id). When one group's message is recorded first, a second group's message with the same cursor will be treated as already seen and silently skipped for the rest of the catch-up window. Consider keying the seen set by (Topic, Cursor) so that cross-topic collisions don't cause false dedup.

        seen: HashSet<(Topic, Cursor)>,
+    fn has_seen(&self, position: &GlobalCursor, topic: &Topic, cursor: &Cursor) -> bool {
+        match self {
+            Self::Syncing { seeds, seen } => {
+                seeds.get(topic).is_some_and(|seed| seed.has_seen(cursor))
+                    || seen.contains(&(topic.clone(), *cursor))
+            }
+            Self::Live => position.has_seen(cursor),
+        }
+    }
+
+    fn record(&mut self, topic: &Topic, cursor: Cursor) {
+        if let Self::Syncing { seen, .. } = self {
+            seen.insert((topic.clone(), cursor));
+        }
+    }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around lines 219-246:

`StreamDedup::has_seen` uses a stream-wide `HashSet<Cursor>` during the catch-up window, but `Cursor` does not include the topic. Sequence ids are not scoped per group, so two different group-message topics can legitimately produce the same `(originator_id, sequence_id)`. When one group's message is recorded first, a second group's message with the same cursor will be treated as already seen and silently skipped for the rest of the catch-up window. Consider keying the `seen` set by `(Topic, Cursor)` so that cross-topic collisions don't cause false dedup.

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from 1572f61 to e0c8817 Compare July 7, 2026 22:19
tokio::select! {
cmd = cmds.recv(), if !handles_gone => match cmd {
None => handles_gone = true,
Some(cmd) => self.subscribe(cmd).await,

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 subscriptions/stream_router.rs:287

RouterTask::run handles each command by awaiting self.subscribe(cmd) inline on the single event loop. When a subscribe call blocks on DB reads or transport.lease(...).await, every subsequent stream_messages/stream_conversations call stalls on its reply channel, and ends notifications are not processed — so dropped streams are not reaped or unsubscribed until the blocked subscribe completes. Consider spawning each subscribe into its own task so the run loop continues servicing other commands and ends notifications.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around line 287:

`RouterTask::run` handles each command by awaiting `self.subscribe(cmd)` inline on the single event loop. When a subscribe call blocks on DB reads or `transport.lease(...).await`, every subsequent `stream_messages`/`stream_conversations` call stalls on its reply channel, and `ends` notifications are not processed — so dropped streams are not reaped or unsubscribed until the blocked subscribe completes. Consider spawning each subscribe into its own task so the `run` loop continues servicing other commands and `ends` notifications.

) -> bool {
for proto in batch {
let typed =
match xmtp_proto::types::GroupMessage::try_from(V3ProtoGroupMessage::from(proto)) {

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 subscriptions/stream_router.rs:548

deliver_batch silently skips any wire envelope that fails GroupMessage::try_from(...), logging a warning and continue-ing. Because the cursor is not advanced for the skipped message, the durable cursor stays behind it, so every subsequent re-subscribe replays the same undecodable envelope. The message is never delivered and no error surfaces to the caller — the caller only sees the stream go quiet while the network keeps re-sending the dead letter. Consider advancing the topic's position (or recording an error/dedup entry) for envelopes that fail decoding so the cursor moves past them, or surfacing the decode error to the consumer.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around line 548:

`deliver_batch` silently skips any wire envelope that fails `GroupMessage::try_from(...)`, logging a warning and `continue`-ing. Because the cursor is not advanced for the skipped message, the durable cursor stays behind it, so every subsequent re-subscribe replays the same undecodable envelope. The message is never delivered and no error surfaces to the caller — the caller only sees the stream go quiet while the network keeps re-sending the dead letter. Consider advancing the topic's position (or recording an error/dedup entry) for envelopes that fail decoding so the cursor moves past them, or surfacing the decode error to the consumer.

@tylerhawkes tylerhawkes force-pushed the tyler/xip83-9-stream-router branch from e0c8817 to 3dc778d Compare July 7, 2026 23:23
None => continue,
}
}
Err(e) => send_or_kill(&self.tx, kill, Err(e)).await,

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.

🟠 High subscriptions/stream_router.rs:708

When send_or_kill returns Err at line 704, the welcome has already been fully processed by process_welcome_one — the durable welcome cursor is advanced and the group is stored — but the MlsGroup never reaches the subscriber. The missed conversation is not replayed on re-subscribe because the cursor is already advanced and known is rebuilt from db.group_cursors(), so it is permanently lost for that stream consumer. Consider buffering each outcome and retrying the send before advancing the cursor, or registering the group for replay before discarding it on send failure.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/subscriptions/stream_router.rs around line 708:

When `send_or_kill` returns `Err` at line 704, the welcome has already been fully processed by `process_welcome_one` — the durable welcome cursor is advanced and the group is stored — but the `MlsGroup` never reaches the subscriber. The missed conversation is not replayed on re-subscribe because the cursor is already advanced and `known` is rebuilt from `db.group_cursors()`, so it is permanently lost for that stream consumer. Consider buffering each `outcome` and retrying the send before advancing the cursor, or registering the group for replay before discarding it on send failure.

@tylerhawkes tylerhawkes enabled auto-merge (squash) July 8, 2026 18:28
enum StreamDedup {
Syncing {
/// Per-topic durable positions frozen at subscribe.
seeds: HashMap<Topic, GlobalCursor>,

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.

this is the same as https://xmtp.github.io/libxmtp/xmtp_proto/types/struct.TopicCursor.html which derefs to a hashmap

@tylerhawkes tylerhawkes merged commit e7423ca into main Jul 8, 2026
45 of 46 checks passed
@tylerhawkes tylerhawkes deleted the tyler/xip83-9-stream-router branch July 8, 2026 19:23
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.

2 participants