Jido.Signal.Router (Jido v1.1.0-rc.2)

View Source

The Router module implements a high-performance, trie-based signal routing system designed specifically for agent-based architectures. It provides sophisticated message routing capabilities with support for exact matches, wildcards, pattern matching functions, and multiple dispatch targets.

Core Concepts

The Router organizes signal handlers into an efficient trie (prefix tree) structure that enables:

  • Fast path-based routing using dot-notation (e.g., "user.created.verified")
  • Priority-based handler execution (-100 to 100)
  • Complexity-based ordering for wildcard resolution
  • Dynamic route management (add/remove at runtime)
  • Pattern matching through custom functions
  • Multiple dispatch targets per route

Path Patterns

Routes use a dot-notation pattern system supporting:

  • Exact matches: "user.created"
  • Single wildcards: "user.*.updated" (matches one segment)
  • Multi-level wildcards: "audit.**" (matches zero or more segments)

Pattern rules:

  • Paths must match: ^[a-zA-Z0-9.*_-]+(.[a-zA-Z0-9.*_-]+)*$
  • Cannot contain consecutive dots (..)
  • Cannot contain consecutive multi-wildcards (**...**)

Handler Priority

Handlers execute in order based on:

  1. Path complexity (more specific paths execute first)
  2. Priority (-100 to 100, higher executes first)
  3. Registration order (for equal priority/complexity)

Dispatch Configurations

The router supports two types of targets:

  1. Instructions - Standard action instructions with parameters
  2. Dispatch Configs - Adapter-based dispatch configurations

Dispatch configs can be specified in two formats:

  1. Single adapter: {adapter, opts}
  2. Multiple adapters: [{adapter1, opts1}, {adapter2, opts2}]

Usage Examples

Basic route creation:

{: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 large payments
  {"payment.processed",
    fn signal -> signal.data.amount > 1000 end,
    %Instruction{action: HandleLargePayment}},

  # Single dispatch target
  {"metrics.collected", {MetricsAdapter, [type: :counter]}},

  # Multiple dispatch targets
  {"system.error", [
    {MetricsAdapter, [type: :error]},
    {AlertAdapter, [priority: :high]},
    {LogAdapter, [level: :error]}
  ]}
])

Dynamic route management:

# Add routes
{:ok, router} = Router.add(router, [
  {"metrics.**", {MetricsAdapter, [type: :gauge]}}
])

# Remove routes
{:ok, router} = Router.remove(router, "metrics.**")

Signal routing:

# Route to instruction
{:ok, [%Instruction{action: HandleUserCreated}]} = Router.route(router, %Signal{
  type: "user.created",
  data: %{id: "123"}
})

# Route to multiple dispatch targets
{:ok, [
  {MetricsAdapter, [type: :error]},
  {AlertAdapter, [priority: :high]},
  {LogAdapter, [level: :error]}
]} = Router.route(router, %Signal{
  type: "system.error",
  data: %{message: "Critical error"}
})

Path Complexity Scoring

The router uses a sophisticated scoring system to determine handler execution order:

  1. Base score from segment count (length * 2000)
  2. Exact match bonuses (3000 per segment, weighted by position)
  3. Wildcard penalties:
    • Single wildcard (): 1000 - position_index 100
    • Multi-wildcard (*): 2000 - position_index 200

This ensures more specific routes take precedence over wildcards, while maintaining predictable execution order.

Best Practices

  1. Route Design

    • Use consistent, hierarchical path patterns
    • Prefer exact matches over wildcards when possible
    • Keep path segments meaningful and well-structured
    • Document your path hierarchy
  2. Priority Management

    • Reserve high priorities (75-100) for critical handlers
    • Use default priority (0) for standard business logic
    • Reserve low priorities (-100 to -75) for metrics/logging
    • Document priority ranges for your application
  3. Pattern Matching

    • Keep match functions simple and fast
    • Handle nil/missing data gracefully
    • Avoid side effects in match functions
    • Test edge cases thoroughly
  4. Dispatch Configuration

    • Use single dispatch for simple routing
    • Use multiple dispatch for cross-cutting concerns
    • Keep adapter options minimal and focused
    • Document adapter requirements
  5. Performance Considerations

    • 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 error feedback for:

  • Invalid path patterns
  • Priority out of bounds
  • Invalid match functions
  • Missing handlers
  • Malformed signals
  • Invalid dispatch configurations

Implementation Details

The router uses several specialized structs:

  • Route - Defines a single routing rule
  • TrieNode - Internal trie structure node
  • HandlerInfo - Stores handler metadata
  • PatternMatch - Encapsulates pattern matching rules

See the corresponding typespecs for detailed field information.

See Also

Summary

Functions

Adds one or more routes to the router.

Filters a list of signals based on a pattern.

Checks if a route with the given ID exists in the router.

Lists all routes currently registered in the router.

Checks if a signal type matches a pattern.

Merges two routers by combining their routes.

Creates a new router with the given routes.

Creates a new router with the given routes, raising on error.

Normalizes route specifications into Route structs.

Removes one or more routes from the router.

Routes a signal through the router to find and execute matching handlers.

Validates one or more Route structs.

Types

match()

@type match() :: (Jido.Signal.t() -> boolean())

path()

@type path() :: String.t()

priority()

@type priority() :: non_neg_integer()

route_spec()

@type route_spec() ::
  {String.t(), target()}
  | {String.t(), target(), priority()}
  | {String.t(), match(), target()}
  | {String.t(), match(), target(), priority()}
  | {String.t(), pid()}

target()

wildcard_type()

@type wildcard_type() :: :single | :multi

Functions

add(router, routes)

Adds one or more routes to the router.

Parameters

  • router: The existing router struct
  • routes: A route specification or list of route specifications in one of these formats:
    • %Route{}
    • {path, instruction}
    • {path, instruction, priority}
    • {path, [match: match_fn], instruction}
    • {path, [match: match_fn], instruction, priority}

Returns

{:ok, updated_router} or {:error, reason}

filter(signals, pattern)

@spec filter([Jido.Signal.t()] | nil | any(), String.t() | nil | any()) :: [
  Jido.Signal.t()
]

Filters a list of signals based on a pattern.

Parameters

  • signals: List of signals to filter
  • pattern: Pattern to filter by (e.g. "user." or "audit.*")

Returns

  • List of signals whose types match the pattern

Examples

iex> signals = [
...>   %Signal{type: "user.created"},
...>   %Signal{type: "payment.processed"},
...>   %Signal{type: "user.updated"}
...> ]
iex> Router.filter(signals, "user.*")
[%Signal{type: "user.created"}, %Signal{type: "user.updated"}]

iex> Router.filter(nil, "user.*")
[]

iex> Router.filter([], nil)
[]

iex> Router.filter("not a list", "user.*")
[]

has_route?(router, route_path)

@spec has_route?(Jido.Signal.Router.Router.t(), String.t()) :: boolean()

Checks if a route with the given ID exists in the router.

Parameters

  • router: The router struct to check
  • route_id: The ID of the route to check for

Returns

  • true if the route exists
  • false otherwise

list(router)

Lists all routes currently registered in the router.

Returns a list of Route structs containing the path, instruction, priority and match function for each registered route.

Returns

{:ok, [%Route{}]} - List of Route structs

Examples

{:ok, routes} = Router.list(router)

# Returns:
[
  %Route{
    path: "user.created",
    instruction: %Instruction{action: MyApp.Actions.HandleUserCreated},
    priority: 0,
    match: nil
  },
  %Route{
    path: "payment.processed",
    instruction: %Instruction{action: MyApp.Actions.HandleLargePayment},
    priority: 90,
    match: #Function<1.123456789/1>
  }
]

matches?(type, pattern)

@spec matches?(String.t() | nil | any(), String.t() | nil | any()) :: boolean()

Checks if a signal type matches a pattern.

Parameters

  • type: The signal type to check (e.g. "user.created")
  • pattern: The pattern to match against (e.g. "user." or "audit.*")

Returns

  • true if the type matches the pattern
  • false otherwise

Examples

iex> Router.matches?("user.created", "user.*")
true

iex> Router.matches?("audit.user.created", "audit.**")
true

iex> Router.matches?("user.created", "payment.*")
false

iex> Router.matches?("user.profile.updated", "user.*")
false

iex> Router.matches?(nil, "user.*")
false

iex> Router.matches?("user.created", nil)
false

merge(router, routes)

Merges two routers by combining their routes.

Takes a target router and a list of routes from another router (obtained via list/1) and merges them together, preserving priorities and match functions.

Parameters

  • router: The target Router struct to merge into
  • routes: List of Route structs to merge in (from Router.list/1)

Returns

{:ok, merged_router} or {:error, reason}

Examples

{:ok, router1} = Router.new([{"user.created", instruction1}])
{:ok, router2} = Router.new([{"payment.processed", instruction2}])
{:ok, routes2} = Router.list(router2)

# Merge router2's routes into router1
{:ok, merged} = Router.merge(router1, routes2)

new(routes \\ nil)

@spec new(route_spec() | [route_spec()] | [Jido.Signal.Router.Route.t()] | nil) ::
  {:ok, Jido.Signal.Router.Router.t()} | {:error, term()}

Creates a new router with the given routes.

new!(routes \\ nil)

Creates a new router with the given routes, raising on error.

normalize(input)

@spec normalize(
  Jido.Signal.Router.Route.t()
  | [Jido.Signal.Router.Route.t()]
  | route_spec()
  | [route_spec()]
) :: {:ok, [Jido.Signal.Router.Route.t()]} | {:error, term()}

Normalizes route specifications into Route structs.

Parameters

  • input - One of:
    • Single Route struct
    • List of Route structs
    • List of route_spec tuples
    • {path, target} tuple where target is:
      • %Instruction{}
      • {adapter, opts}
      • [{adapter, opts}, ...]
    • {path, target, priority} tuple
    • {path, match_fn, target} tuple
    • {path, match_fn, target, priority} tuple

Returns

  • {:ok, [%Route{}]} - List of normalized Route structs
  • {:error, term()} - If normalization fails

remove(router, paths)

Removes one or more routes from the router.

Parameters

  • router: The Router struct to modify
  • paths: A path string or list of path strings to remove

Returns

  • {:ok, updated_router} - Routes removed successfully

Examples

{:ok, router} = Router.remove(router, "metrics.collected")
{:ok, router} = Router.remove(router, ["user.created", "user.updated"])

route(router, signal)

@spec route(Jido.Signal.Router.Router.t(), Jido.Signal.t()) ::
  {:ok, [Jido.Instruction.t()]} | {:error, term()}

Routes a signal through the router to find and execute matching handlers.

Parameters

  • router: The router struct to use for routing
  • signal: The signal to route

Returns

  • {:ok, [instruction]} - List of matching instructions, may be empty if no matches
  • {:error, term()} - Other errors that occurred during routing

Examples

{:ok, results} = Router.route(router, %Signal{
  type: "payment.processed",
  data: %{amount: 100}
})

validate(route)

Validates one or more Route structs.

Parameters

  • routes: A %Route{} struct or list of %Route{} structs to validate

Returns

  • {:ok, %Route{}} - Single validated Route struct
  • {:ok, [%Route{}]} - List of validated Route structs
  • {:error, term()} - If validation fails