Skip to content

perf(dispatch): index Client event bus and Dispatcher by event discriminant#57

Open
devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1776970764-indexed-dispatch
Open

perf(dispatch): index Client event bus and Dispatcher by event discriminant#57
devin-ai-integration[bot] wants to merge 2 commits intomainfrom
devin/1776970764-indexed-dispatch

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 23, 2026

Summary

Both the Client subscription bus (Client::on_event / Client::on_any) and the high-level Dispatcher (teamtalk::dispatch::Dispatcher) used to linearly scan every registered subscription/handler on every event, even though the filter check was just mem::discriminant(expected) == mem::discriminant(event). Dispatch cost was therefore O(total_subs) per event, not O(subscribers-actually-watching-this-event).

This PR indexes both stores by mem::Discriminant<Event> so dispatch only walks handlers that can possibly match the current event, while preserving the visible firing order.

Changes:

  • crates/teamtalk/src/client/bus/subscription.rsEventBus now maintains two secondary indexes on top of the single insertion-order Vec<Subscription>:

    • by_event: HashMap<Discriminant<Event>, Vec<usize>> — indices of event-specific subscribers;
    • any_indices: Vec<usize> — indices of wildcard subscribers.
    • subscribe, unsubscribe, unsubscribe_group, and clear keep the indexes consistent (rebuild on removal, rather than tombstones).
    • dispatch merges the specific + wildcard index slices in ascending order, so cross-bucket firing order is identical to the pre-change linear scan.
    • Subscription drops its event: Option<Event> field; the discriminant is now implicit in bucket placement and an explicit event_filter: Option<Discriminant<Event>> is used for index rebuilds after unsubscribe.
  • crates/teamtalk/src/dispatch/dispatcher.rs / source.rs — same two-index pattern applied to Dispatcher::handlers. The per-entry filter field is dropped from HandlerEntry (bucket placement is the source of truth), and process_event merges the specific + wildcard slices.

  • crates/teamtalk/src/client/core/mod.rs — new mock-only helper Client::mock_dispatch_bus_for_tests(event, source) so integration tests can exercise the bus path synchronously without spinning the full poll loop. Gated by #[cfg(feature = "mock")] alongside the other existing mock_*_for_tests helpers.

  • crates/teamtalk/tests/indexed_dispatch_tests.rs (new, 9 tests):

    • specific handler fires only for its discriminant;
    • wildcard fires for every event;
    • insertion order is preserved across interleaved specific + wildcard registrations (the key behavioural invariant of the merge);
    • an unrelated event skips the whole bucket and only fans out to wildcards;
    • DispatchFlow::Stop from a wildcard slot surfaces as the step result;
    • Client::on_event/on_any register through both buckets and dispatch merges in insertion order;
    • unsubscribe_event rebuilds the index and later dispatches skip the removed sub;
    • unsubscribe_event_group removes from specific and wildcard buckets simultaneously;
    • clear_event_subscriptions resets both buckets.

Total test count locally: 305 passed, 0 failed (296 pre-existing + 9 new), cargo fmt / cargo clippy --all-features --all-targets -- -D warnings clean.

Complexity

Before: O(total_subs) per event (every sub checked for discriminant + filter match).
After: O(specific_for_this_event + wildcards) per event. The per-subscription filter-match check (user id, channel id, text type, predicate, etc.) is unchanged — only the discriminant filter moved into the bucket lookup.

Not changed

  • bot/router/dispatch.rs is intentionally left linear: route ordering is semantically load-bearing there (the matched_command/matched_command_path / auto-help fallback logic depends on walking routes in registration order, interleaving Any / Event / Command matchers). Indexing routes by event discriminant would change dispatch semantics; that belongs to a separate behavioural PR if wanted.

Review & Testing Checklist for Human

  • Verify that the merge loop in EventBus::dispatch and Dispatcher::process_event preserves strict insertion order across the specific + any index lists when a handler is registered as wildcard between two specific handlers for the current event. Test insertion_order_is_preserved_across_specific_and_wildcard and client_bus_specific_and_wildcard_dispatch_in_insertion_order pin this down, but a second pair of eyes on the while si < … || ai < … merge is worth it.
  • Spot-check unsubscribe / unsubscribe_group — they rebuild indexes via retain + rebuild_indices. Ensure there's no subtle index-invalidation path I missed (the rebuild is deliberately O(n) on removal rather than tombstones to keep dispatch zero-cost).
  • Confirm you're OK with Client::mock_dispatch_bus_for_tests becoming part of the public mock-feature test surface (follows the same pattern as existing mock_apply_event_for_tests / mock_set_connection_state_for_tests).

Notes

Link to Devin session: https://app.devin.ai/sessions/71fdd6196cb74723a2e277bb81993a9c
Requested by: @BlindMaster24


Open in Devin Review

Dispatch used to linearly scan every registered subscription (on the
Client event bus) and every handler (on the high-level Dispatcher)
just to filter by event kind, which becomes O(total_subs) per event
regardless of how few handlers actually care about a given discriminant.

This change indexes both stores by mem::Discriminant<Event>:

* EventBus (client/bus/subscription.rs): subscriptions are still owned
  in a single insertion-order Vec, but two secondary indexes
  (by_event: HashMap<Discriminant<Event>, Vec<usize>> plus
  any_indices: Vec<usize>) are maintained on subscribe, unsubscribe,
  unsubscribe_group, and clear. Dispatch merges the specific + wildcard
  index slices in ascending index order, so the visible firing order
  is identical to the previous implementation. Subscriptions only store
  the discriminant (not the full Event value) because that is all the
  filter check ever used.

* Dispatcher (dispatch/dispatcher.rs): same two-index pattern, built up
  as handlers are added via add_handler / add_handler_any. The inner
  HandlerEntry no longer carries its own event filter — the entry's
  bucket placement encodes it.

Behaviour is preserved:
* every handler/subscription that would have fired before still fires;
* cross-bucket firing order is preserved because both index lists stay
  in insertion (and therefore ascending) order and are merged;
* wildcards continue to see every event;
* unsubscribe/unsubscribe_group/clear rebuild the index so index
  integrity is restored without introducing tombstones.

A new mock-only helper Client::mock_dispatch_bus_for_tests lets
integration tests exercise the bus path synchronously without spinning
the full poll loop.

tests/indexed_dispatch_tests.rs (9 new tests) pins down the invariants:
specific-only firing, wildcard fan-out, insertion-order merging,
unrelated-event skip, Stop flow propagation, and EventBus
unsubscribe / unsubscribe_event_group / clear_event_subscriptions
behaviour across buckets.
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration[bot]

This comment was marked as resolved.

…NTS.md

Devin Review flagged a two-line inline comment in `Subscription::matches`
explaining why the discriminant filter check was removed. AGENTS.md
("Coding Style & Naming Conventions") requires no inline comments in
library code; the commit drops the comment without changing behaviour.
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