Signal Routing in Jido
View SourceSignal routing in Jido enables sophisticated message handling patterns across your agent system. This guide explores Jido's trie-based routing system, from basic path patterns to advanced configuration.
Overview
The routing system determines how signals flow through your agent network by matching signal types against predefined patterns. It supports:
- Path-based routing with dot notation
- Wildcard pattern matching
- Priority-based handler execution
- Pattern matching functions
- Dynamic route management
Core Concepts
Path Patterns
Signal routes use a dot-notation syntax that supports three types of matching:
Exact Matches: Match specific segments
"user.created" # Matches exactly "user.created"
Single Wildcards: Match any single segment
"user.*.updated" # Matches "user.123.updated", "user.abc.updated", etc.
Multi-level Wildcards: Match zero or more segments
"audit.**" # Matches "audit.user", "audit.payment.processed", etc.
Handler Priority
Handlers execute in order based on:
- Path complexity (more specific paths execute first)
- Explicit priority (-100 to 100, higher executes first)
- Registration order (for equal priority/complexity)
Basic Usage
Creating a Router
{:ok, router} = Router.new([
# Simple route with default priority
{"user.created", %Instruction{action: HandleUserCreated}},
# High-priority audit logging
{"audit.**", %Instruction{action: AuditLogger}, 100},
# Pattern matching for specific conditions
{"payment.processed",
fn signal -> signal.data.amount > 1000 end,
%Instruction{action: HandleLargePayment}}
])
Routing Signals
# Create a signal
signal = %Signal{
type: "payment.processed",
data: %{amount: 2000}
}
# Route the signal
{:ok, instructions} = Router.route(router, signal)
Path Pattern Rules
Paths must follow these rules:
- Match the pattern:
^[a-zA-Z0-9.*_-]+(\.[a-zA-Z0-9.*_-]+)*$
- Cannot contain consecutive dots (..)
- Cannot contain consecutive multi-wildcards (
**...**
)
Examples:
# Valid patterns
"user.created"
"user.*.updated"
"audit.**"
"user.*.profile.**"
# Invalid patterns
"user..created" # Consecutive dots
"user.**.**" # Consecutive multi-wildcards
"user@123" # Invalid characters
Multiple Instruction Matching
One of Jido's powerful features is the ability to match multiple instructions for a single signal type. When multiple handlers match a signal, they are executed in a well-defined order based on path complexity, priority, and registration sequence.
Instruction Ordering
Instructions are ordered according to these rules:
Path Complexity (highest to lowest)
- More specific paths execute before wildcards
- Exact matches have highest precedence
- Single wildcards (*) have medium precedence
- Multi-level wildcards (**) have lowest precedence
Priority (-100 to 100, highest first)
- Higher priority handlers execute first
- Equal priority maintains registration order
Registration Order
- For equal complexity and priority, earlier registrations execute first
# Multiple handlers for the same path
Router.new([
# Executes first (highest priority)
{"user.created", %Instruction{action: AuditNewUser}, 100},
# Executes second (default priority)
{"user.created", %Instruction{action: CreateUserProfile}},
# Executes third (lowest priority)
{"user.created", %Instruction{action: NotifyAdmins}, -50}
])
# Complex pattern matching
Router.new([
# Executes first (exact match)
{"user.123.profile.updated", %Instruction{action: HandleSpecificUser}},
# Executes second (single wildcard)
{"user.*.profile.updated", %Instruction{action: HandleAnyUserProfile}},
# Executes third (multi-wildcard)
{"user.**", %Instruction{action: LogUserEvents}}
])
PID Delegation
Routes can be delegated directly to PIDs, enabling parent agents to route signals to their children or to other specific processes. This is particularly useful in hierarchical agent architectures.
# Create a child process
child_pid = spawn_link(fn ->
receive do
{:signal, signal} -> handle_signal(signal)
end
end)
# Route signals to the child
Router.new([
# Direct PID delegation
{"child.events", child_pid},
# Mixed routing - some to PID, some to handlers
{"child.*.important", %Instruction{action: ParentHandler}},
{"child.*", child_pid}
])
PID delegation features:
- Automatic Signal Wrapping: Signals are automatically wrapped in a
{:signal, signal}
tuple - Process Monitoring: Routes are validated against living processes
- Async Communication: Messages are sent asynchronously by default
- Error Handling: Graceful handling of dead process references
Example with multiple children:
defmodule ParentAgent do
use Jido.Agent
def init(children) do
routes = Enum.map(children, fn {type, pid} ->
# Each child handles its own type of signals
{"#{type}.**", pid}
end)
{:ok, router} = Router.new(routes)
{:ok, %{router: router, children: children}}
end
end
# Usage
children = [
{"orders", spawn_link(OrderProcessor)},
{"users", spawn_link(UserManager)},
{"payments", spawn_link(PaymentHandler)}
]
ParentAgent.start_link(children)
Advanced Features
Pattern Matching Functions
Use pattern matching functions for complex routing logic:
Router.new([
{"payment.processed",
fn signal ->
# Match payments over $1000 in USD
signal.data.amount > 1000 && signal.data.currency == "USD"
end,
%Instruction{action: HandleLargeUSDPayment}
}
])
Priority Management
Router.new([
# High priority (75-100): Critical system handlers
{"system.error", %Instruction{action: ErrorHandler}, 100},
# Medium priority (0-74): Business logic
{"user.created", %Instruction{action: CreateUser}, 50},
# Low priority (-100 to -1): Logging, metrics
{"**.processed", %Instruction{action: MetricsCollector}, -50}
])
Dynamic Route Management
Add or remove routes at runtime:
# Add new routes
{:ok, router} = Router.add(router, [
{"metrics.**", %Instruction{action: CollectMetrics}}
])
# Remove routes
{:ok, router} = Router.remove(router, "metrics.**")
Router Merging
Combine routes from multiple routers:
{:ok, router1} = Router.new([{"user.created", user_handler}])
{:ok, router2} = Router.new([{"payment.processed", payment_handler}])
# Merge router2's routes into router1
{:ok, merged} = Router.merge(router1, router2)
Best Practices
Route Design
Use consistent, hierarchical path patterns
# Good "user.profile.updated" "user.settings.changed" # Avoid "updateUserProfile" "change-settings-user"
Prefer specific routes over wildcards when possible
# Better "user.profile.updated" # More general "user.*.updated"
Document your path hierarchy
# Example path structure "domain.entity.action[.qualifier]" # e.g., "user.profile.updated.success"
Priority Management
- Reserve high priorities (75-100) for critical handlers
- Use default priority (0) for standard business logic
- Use low priorities (-100 to -75) for metrics/logging
- Document priority ranges for your application
Pattern Matching
- Keep match functions simple and fast
- Handle nil/missing data gracefully
- Avoid side effects in match functions
- Test edge cases thoroughly
Performance Optimization
- Monitor route count in production
- Use pattern matching sparingly
- Consider complexity scores when designing paths
- Profile routing performance under load
Error Handling
The router provides detailed errors for common issues:
# Invalid path pattern
{:error, %Error{type: :routing_error, message: "Path cannot contain consecutive dots"}}
# Priority out of bounds
{:error, %Error{type: :routing_error, message: "Priority value exceeds maximum allowed"}}
# No matching handlers
{:error, %Error{type: :routing_error, message: "No matching handlers found for signal"}}
Advanced Pattern Examples
Complex Wildcard Interactions
Router.new([
# Catch-all with lowest priority
{"**", %Instruction{action: CatchAll}, -100},
# More specific patterns take precedence
{"*.*.created", %Instruction{action: HandleCreation}},
{"user.**", %Instruction{action: HandleUserEvents}},
{"user.*.created", %Instruction{action: HandleUserCreation}},
{"user.123.created", %Instruction{action: HandleSpecificUser}}
])
State-Based Routing
Router.new([
{"order.status.changed",
fn signal ->
# Route based on order state transition
old_status = signal.data.old_status
new_status = signal.data.new_status
old_status == "pending" && new_status == "processing"
end,
%Instruction{action: HandleOrderProcessing}}
])
Implementation Details
The router uses several specialized structs:
Route
- Defines a single routing ruleTrieNode
- Internal trie structure nodeHandlerInfo
- Stores handler metadataPatternMatch
- Encapsulates pattern matching rules
The trie structure enables efficient path matching while maintaining proper execution order based on complexity and priority.
Testing Strategies
Test exact matches
test "routes exact path signal" do {:ok, router} = Router.new({"user.created", handler}) signal = %Signal{type: "user.created"} assert {:ok, [^handler]} = Router.route(router, signal) end
Test wildcard patterns
test "routes wildcard signal" do {:ok, router} = Router.new({"user.*.updated", handler}) signal = %Signal{type: "user.123.updated"} assert {:ok, [^handler]} = Router.route(router, signal) end
Test priority ordering
test "executes handlers in priority order" do {:ok, router} = Router.new([ {"test", handler1, 100}, {"test", handler2, 0} ]) signal = %Signal{type: "test"} assert {:ok, [^handler1, ^handler2]} = Router.route(router, signal) end