Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Dispatch adapters: `:pid`, `:pubsub`, `:http`, `:bus`, `:named`, `:console`, `:logger`, `:noop`
- In-memory persistence via ETS or maps, no external DB dependency
- Middleware pipeline for cross-cutting concerns
- **Instance isolation**: `Jido.Signal.Instance` for multi-tenant/isolated infrastructure via `jido:` option

## Router System
- **Trie-based routing**: Efficient prefix tree for path matching with O(k) complexity (k = segments)
Expand Down
68 changes: 68 additions & 0 deletions INSTANCE_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Jido Instance Isolation Plan

**Status: ✅ IMPLEMENTED**

## Goal

Enable instance isolation where:
- **Default API** uses global supervisors (zero config, works out of the box)
- **Instance API** routes all operations through instance-scoped supervisors

```elixir
# Global (default) - uses Jido.Signal.Registry
Bus.start_link(name: :my_bus)

# Instance-scoped - uses MyApp.Jido.Signal.Registry
Instance.start_link(name: MyApp.Jido)
Bus.start_link(name: :my_bus, jido: MyApp.Jido)
```

## Pattern

- Functions with arity N use global supervisors
- Functions with arity N accept optional `jido:` option for instance scoping

## Isolation Scope

Instance-scoped resources:
- `Task.Supervisor` — async operations
- `Registry` — process lookup (Bus, etc.)
- `Ext.Registry` — signal extension lookup

## Key Invariant

When `jido: instance` is passed, **all** spawned tasks and processes route through that instance's supervisors. No silent fallback to globals within an instance context.

## Success Criteria

1. ✅ Existing code works unchanged (global supervisors)
2. ✅ Instance users get complete isolation with single `jido:` option
3. ✅ Cross-tenant signal contention eliminated
4. ✅ Easy to test isolation guarantees

## Implementation Details

### Changes Made

#### jido_signal package
- **New module**: `Jido.Signal.Names` - Resolves process names based on `jido:` option
- **New module**: `Jido.Signal.Instance` - Child spec for starting instance supervisors
- **Updated**: `Jido.Signal.Util.via_tuple/2` - Uses `Names.registry(opts)` for instance-scoped registry
- **Updated**: `Jido.Signal.Util.whereis/2` - Uses `Names.registry(opts)` for instance-scoped lookup
- **Updated**: `Jido.Signal.Bus.State` - Added `jido` field for storing instance
- **Updated**: `Jido.Signal.Bus.init/1` - Stores `jido` option in state
- **Updated**: `Jido.Signal.Ext.Registry` - Added `child_spec/1` for instance naming

### How It Works

1. When `Jido.Signal.Instance.start_link(name: MyApp.Jido)` is called, an instance supervisor starts
2. The instance supervisor starts: Registry, TaskSupervisor, and Ext.Registry with instance-scoped names
3. When `Bus.start_link(name: :my_bus, jido: MyApp.Jido)` is called, the `jido:` option is passed
4. `Util.via_tuple/2` resolves the registry name using `Names.registry(opts)`
5. Bus registers in the instance's registry instead of global
6. `Util.whereis/2` looks up in the correct registry based on `jido:` option

### Tests Added

- `test/jido_signal/instance_test.exs` - Tests `Names` module and `Instance` lifecycle
- `test/jido_signal/bus_instance_isolation_test.exs` - Tests Bus isolation between instances
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Jido.Signal transforms Elixir's message passing into a sophisticated communicati
- Middleware pipeline for cross-cutting concerns with timeout protection
- Complete signal history with replay capabilities
- Partitioned dispatch with rate limiting for horizontal scaling
- Instance isolation for multi-tenant deployments

### **Advanced Routing Engine**
- Trie-based pattern matching for optimal performance
Expand Down Expand Up @@ -387,6 +388,29 @@ Create point-in-time views of your signal log:
Enum.each(signals, &analyze_order_signal/1)
```

### Instance Isolation

For multi-tenant applications or testing, create isolated signal infrastructure:

```elixir
# Start an isolated instance with its own Registry, TaskSupervisor, etc.
{:ok, _} = Jido.Signal.Instance.start_link(name: MyApp.Jido)

# Start buses scoped to the instance
{:ok, _} = Jido.Signal.Bus.start_link(name: :tenant_bus, jido: MyApp.Jido)

# Lookup uses the correct instance registry
{:ok, bus_pid} = Jido.Signal.Bus.whereis(:tenant_bus, jido: MyApp.Jido)

# Multiple instances are completely isolated
{:ok, _} = Jido.Signal.Instance.start_link(name: TenantA.Jido)
{:ok, _} = Jido.Signal.Instance.start_link(name: TenantB.Jido)

# Same bus name, different instances = different processes
{:ok, _} = Jido.Signal.Bus.start_link(name: :events, jido: TenantA.Jido)
{:ok, _} = Jido.Signal.Bus.start_link(name: :events, jido: TenantB.Jido)
```

## Use Cases

### Microservices Communication
Expand Down
36 changes: 36 additions & 0 deletions guides/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,42 @@ See [Signals and Dispatch guide](signals-and-dispatch.md) for full circuit break

## Testing Approaches

### Instance Isolation for Tests

Use isolated instances to prevent test interference:

```elixir
defmodule MyApp.SignalTest do
use ExUnit.Case, async: true

alias Jido.Signal.Instance
alias Jido.Signal.Bus

setup do
# Create unique instance per test
instance = :"TestInstance_#{System.unique_integer([:positive])}"
{:ok, sup} = Instance.start_link(name: instance)

on_exit(fn ->
if Process.alive?(sup), do: Supervisor.stop(sup, :normal, 100)
end)

{:ok, instance: instance}
end

test "isolated bus operations", %{instance: instance} do
{:ok, bus} = Bus.start_link(name: :test_bus, jido: instance)
{:ok, _} = Bus.subscribe(bus, "test.*", dispatch: {:pid, target: self()})

signal = Jido.Signal.new!("test.event", %{value: 42})
{:ok, _} = Bus.publish(bus, [signal])

assert_receive {:signal, received}
assert received.data.value == 42
end
end
```

### Mock Adapters

```elixir
Expand Down
67 changes: 67 additions & 0 deletions guides/event-bus.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,73 @@ Replay signals from specific timestamp:
{:ok, user_signals} = Jido.Signal.Bus.replay(:my_bus, "user.*", timestamp)
```

## Instance Isolation

For multi-tenant applications or isolated testing, create instance-scoped signal infrastructure:

```elixir
alias Jido.Signal.Instance
alias Jido.Signal.Bus

# Start an isolated instance (starts its own Registry, TaskSupervisor, Ext.Registry)
{:ok, _} = Instance.start_link(name: MyApp.Jido)

# Start bus scoped to the instance
{:ok, _} = Bus.start_link(name: :tenant_bus, jido: MyApp.Jido)

# Lookup uses the instance's registry
{:ok, bus_pid} = Bus.whereis(:tenant_bus, jido: MyApp.Jido)

# Check if instance is running
Instance.running?(MyApp.Jido) # => true

# Stop instance and all its children
Instance.stop(MyApp.Jido)
```

### Multi-Tenant Isolation

Multiple instances are completely isolated from each other:

```elixir
# Start separate instances for each tenant
{:ok, _} = Instance.start_link(name: TenantA.Jido)
{:ok, _} = Instance.start_link(name: TenantB.Jido)

# Same bus name, different instances = different processes
{:ok, bus_a} = Bus.start_link(name: :events, jido: TenantA.Jido)
{:ok, bus_b} = Bus.start_link(name: :events, jido: TenantB.Jido)

# Completely isolated - signals don't cross instances
Bus.subscribe(bus_a, "order.*", dispatch: {:pid, target: tenant_a_handler})
Bus.subscribe(bus_b, "order.*", dispatch: {:pid, target: tenant_b_handler})

# Publish to tenant A only
Bus.publish(bus_a, [order_signal]) # Only tenant_a_handler receives
```

### Process Name Resolution

The `jido:` option controls which registry is used for process lookup:

```elixir
# Global (default) - uses Jido.Signal.Registry
Bus.start_link(name: :my_bus)

# Instance-scoped - uses MyApp.Jido.Signal.Registry
Bus.start_link(name: :my_bus, jido: MyApp.Jido)
```

Use `Jido.Signal.Names` to resolve process names programmatically:

```elixir
alias Jido.Signal.Names

Names.registry([]) # => Jido.Signal.Registry
Names.registry(jido: MyApp.Jido) # => MyApp.Jido.Signal.Registry
Names.task_supervisor(jido: MyApp.Jido) # => MyApp.Jido.Signal.TaskSupervisor
```

## Advanced Configuration

Configure bus with custom router and options:
Expand Down
16 changes: 15 additions & 1 deletion guides/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,22 @@ config = {:pid, [target: dead_pid, delivery_mode: :async]}
{:error, :process_not_alive} = Jido.Signal.Dispatch.dispatch(signal, config)
```

## Instance Isolation

For multi-tenant applications or isolated testing, start an isolated instance:

```elixir
# Start isolated instance
{:ok, _} = Jido.Signal.Instance.start_link(name: MyApp.Jido)

# Start bus scoped to the instance
{:ok, _} = Jido.Signal.Bus.start_link(name: :my_bus, jido: MyApp.Jido)
```

See [Event Bus](event-bus.md#instance-isolation) for complete multi-tenant examples.

## Next Steps

- [Signals and Dispatch](signals-and-dispatch.md) - Deep dive into signal structure, dispatch adapters, circuit breakers, and custom signal types
- [Event Bus](event-bus.md) - Pub/sub messaging, persistent subscriptions, Dead Letter Queue, and horizontal scaling with partitions
- [Event Bus](event-bus.md) - Pub/sub messaging, persistent subscriptions, Dead Letter Queue, instance isolation, and horizontal scaling
- [Signal Journal](signal-journal.md) - Persistence adapters (ETS, Mnesia), checkpointing, and causality tracking
1 change: 1 addition & 0 deletions lib/jido_signal/bus.ex
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ defmodule Jido.Signal.Bus do

state = %BusState{
name: name,
jido: Keyword.get(opts, :jido),
router: Keyword.get(opts, :router, Router.new!()),
child_supervisor: child_supervisor,
middleware: middleware_configs,
Expand Down
1 change: 1 addition & 0 deletions lib/jido_signal/bus/bus_state.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ defmodule Jido.Signal.Bus.State do

typedstruct do
field(:name, atom(), enforce: true)
field(:jido, atom() | nil, default: nil)
field(:router, Router.Router.t(), default: Router.new!())
field(:log, %{String.t() => Signal.t()}, default: %{})
field(:snapshots, %{String.t() => Snapshot.SnapshotRef.t()}, default: %{})
Expand Down
26 changes: 26 additions & 0 deletions lib/jido_signal/ext/registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,37 @@ defmodule Jido.Signal.Ext.Registry do

# Client API

@doc """
Returns a child_spec for starting the registry under a supervisor.

## Options

* `:name` - The name to register the process under (default: #{@registry_name})

"""
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, @registry_name)

%{
id: name,
start: {__MODULE__, :start_link, [opts]},
type: :worker,
restart: :permanent,
shutdown: 5000
}
end

@doc """
Starts the extension registry.

This is typically called by the application supervision tree
and doesn't need to be called manually.

## Options

* `:name` - The name to register the process under (default: #{@registry_name})

"""
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, :ok, Keyword.put_new(opts, :name, @registry_name))
Expand Down
Loading