Router / Subscriptions¶
Overview¶
Router is the single source of truth for routing: it owns a route table that contains
only activated subscriptions (mirroring message brokers, where bindings live only while a
consumer is connected), and match() pre-computes a dispatch table routing event types to
registered handlers. Subscriptions is the managed subscription container a handler holds
(instead of a bare list), supporting runtime add/remove/replace for dynamic self-subscription.
Users usually do not build a Router directly — EventBus creates one internally and exposes it
as bus.router; EventHandler.on_activate registers the handler's subscription bundle by default
(activation is routing).
Architecture¶
EventHandler ──Subscriptions bundle──┐
├──> Router.route_table ──> {event_name: [handler_id, ...]}
on_activate(bus) registers ───┘ ↑
event-registry version + route version auto-rebuild
Router replaces the old Matcher (which passively scanned handler.subscriptions):
subscription data moved from "bare list on the handler + scan" to "a bundle the handler holds,
registered into the Router on activation".
Subscriptions¶
The managed subscription container held by a handler:
class Subscriptions:
def __init__(self, patterns: Iterable[str | Regex] | None = None) -> None
def add(self, pattern: str | Regex) -> None # append (dedup)
def remove(self, pattern: str | Regex) -> bool # remove
def replace(self, patterns: Iterable[str | Regex]) -> None # full replace (state machine)
@property
def patterns(self) -> tuple[str | Regex, ...] # read-only snapshot
@property
def is_bound(self) -> bool # registered on a Router (active)
def __iter__ / __len__ / __contains__ / __eq__(list|tuple) / __bool__
Changes auto-sync while active: on registration (Router.subscribe) the bundle binds to that
Router; any subsequent add/remove/replace bumps the Router version so the dispatch table rebuilds
lazily. Changes while inactive only update the declaration and take effect on the next
subscribe (activation).
Router¶
class Router:
def __init__(self, event_registry: EventRegistry) -> None
def subscribe(self, handler_id: str, subs: Subscriptions) -> None # register (idempotent)
def unsubscribe(self, handler_id: str) -> bool # revoke
def clear(self) -> None # revoke all
def match(self, event_name: str) -> list[str] # matched handler ids
@property
def routes(self) -> tuple[tuple[str, Subscriptions], ...] # active routes snapshot
@property
def dispatch_table(self) -> dict[str, list[str]] # read-only dispatch view
version: int # route version
Dispatch table¶
- Exact
strsubscriptions → reverse index, O(1) lookup Regexsubscriptions → separate scan list,fullmatchonly- Per event, matches follow subscription order and are deduped by handler; only handler ids are stored — the bus resolves instances from the handler registry.
Version-aware caching¶
Any change in the event-registry version or the Router version
(subscribe/unsubscribe/clear/bundle mutation) triggers a lazy rebuild on the next
match() / dispatch_table access.
match() call → check version (O(1) int compare) → stale? → _rebuild()
↓ fresh
lookup table (O(1) dict)
Lifecycle: Activation Is Routing¶
The EventHandler base class hooks routing registration into bus-lifecycle hooks
(overriders must call super()):
class EventHandler(ABC):
async def on_activate(self, bus: EventBus) -> None:
if self.handler_id is not None and self.subscriptions:
bus.router.subscribe(self.handler_id, self.subscriptions) # activation is routing
async def on_deactivate(self, bus: EventBus) -> None:
if self.handler_id is not None:
bus.router.unsubscribe(self.handler_id) # offline revokes
Triggered on registry activate (bus start) for every registered handler, and on register
into an already-bound registry. The route table contains only activated subscriptions — after
deactivate (bus stop) or handler unregister, the Router has no entry for it
(the broker forgets disconnected consumers).
Dynamic Self-subscription¶
Changing your own subscriptions at runtime means mutating your bundle (auto-synced with the live routes while active):
class GameStateHandler(EventHandler):
def __init__(self):
super().__init__(subscriptions=['game.start'])
async def handle(self, payload, bus_proxy, raw_event) -> None:
if raw_event.name == 'game.start':
# state machine: only care about the next stage
self.subscriptions.replace(['game.tick', 'game.over'])
# or incrementally at runtime:
# self.subscriptions.add('game.over')
# self.subscriptions.remove('game.start')
Changing while inactive only updates the declaration (applied on next activation), without errors.
Relationship with EventBus¶
EventBus creates a Router internally (injectable via router=), and the dispatch loop takes
matched ids from it, then resolves handler instances from the handler registry:
class EventBus:
def __init__(self, event_registry, handler_registry, router=None, ...):
self._router = router or Router(event_registry)
@property
def router(self) -> Router: ... # public access (subscription mgmt / debugging)
async def _dispatch_loop(self):
...
for handler_id in self._router.match(event.name):
handler = self._handlers.get(handler_id)
if handler is not None:
...
Users normally do not touch the Router; for runtime dynamic subscriptions they use the handler's own subscription bundle.
Why Pre-compute?¶
Without pre-computation every dispatch would iterate over all active subscriptions and run regex matching per event. With pre-computation:
- Exact
strsubscriptions → O(1) reverse-index lookup Regexsubscriptions → scan list only- Version-aware caching → rebuild only when the event registry or routes change, not per dispatch