Mesh (Mesh v0.1.4)
View SourceMesh - A distributed actor system with capability-based routing.
Mesh provides a simple, unified API for working with distributed actors across an Erlang/Elixir cluster. Actors are distributed and routed using shards, which are computed from the actor ID and mapped to nodes based on capabilities.
Architecture
Mesh uses a three-layer architecture:
- Sharding: Computes a shard (0..4095) from the actor ID using
:erlang.phash2/2 - Capability Routing: Determines which nodes support a given capability
- Actor Placement: Routes actors to owner nodes via RPC
Quick Start
# Register capabilities this node supports
Mesh.register_capabilities([:game, :chat])
# Call a process
{:ok, pid, response} = Mesh.call(%Mesh.Request{
module: MyApp.GameActor,
id: "player_123",
payload: %{action: "move"},
capability: :game
})
# Query cluster topology
Mesh.nodes_for(:game)
#=> [:node1@host, :node2@host]
Mesh.all_capabilities()
#=> [:game, :chat, :payment]Examples
# Game server node
Mesh.register_capabilities([:game])
# Chat server node
Mesh.register_capabilities([:chat])
# Multi-purpose node
Mesh.register_capabilities([:game, :chat, :payment])
# Call processes on different capabilities
{:ok, _pid, _response} = Mesh.call(%Mesh.Request{
module: MyApp.GameActor, id: "player_123",
payload: %{hp: 100}, capability: :game
})
{:ok, _pid, _response} = Mesh.call(%Mesh.Request{
module: MyApp.ChatActor, id: "room_456",
payload: %{msg: "Hello"}, capability: :chat
})
Summary
Types
Unique identifier for an actor (typically a string)
List of capability atoms
Capability atom identifying actor type
Arbitrary data payload sent to an actor
Shard number (0..4095)
Functions
Returns all capabilities registered across the entire cluster.
Synchronously calls a virtual process with the given request.
Asynchronously casts a message to a virtual process.
Returns the list of nodes that support a given capability.
Determines which node owns a given shard for a specific capability.
Registers capabilities that this node supports.
Computes the shard number for a given actor ID.
Types
@type actor_id() :: String.t()
Unique identifier for an actor (typically a string)
@type capabilities() :: [capability()]
List of capability atoms
@type capability() :: atom()
Capability atom identifying actor type
Arbitrary data payload sent to an actor
@type shard() :: non_neg_integer()
Shard number (0..4095)
Functions
@spec all_capabilities() :: capabilities()
Returns all capabilities registered across the entire cluster.
This aggregates capabilities from all connected nodes and returns a unique list.
Returns
- List of unique capability atoms registered in the cluster
Examples
# Node 1 registered [:game]
# Node 2 registered [:chat]
# Node 3 registered [:game, :payment]
Mesh.all_capabilities()
#=> [:game, :chat, :payment]
# No nodes registered any capabilities
Mesh.all_capabilities()
#=> []Notes
- Results include capabilities from all connected nodes
- Duplicates are automatically removed
- Updates when nodes join/leave or register new capabilities
@spec call(Mesh.Request.t()) :: {:ok, pid(), term()} | {:error, term()}
Synchronously calls a virtual process with the given request.
This is the primary API for making synchronous calls to processes in the mesh. The function:
- Computes the shard from the actor ID
- Determines which node owns that shard for the given capability
- Makes an RPC call to that node's ActorOwner
- Lazily creates the process if it doesn't exist (using
init_argif provided) - Forwards the payload to the process via
GenServer.calland returns the response
Parameters
request- A%Mesh.Request{}struct containing::module- The GenServer module (required):id- Unique identifier for the process (required):payload- Data to send (required):capability- The capability type (required):init_arg- Optional argument passed tostart_link/2on first creation
Returns
{:ok, pid, response}- Success with process PID and response{:error, reason}- Failure (e.g., no nodes support the capability)
Examples
# Simple call
{:ok, pid, score} = Mesh.call(%Mesh.Request{
module: MyApp.Counter,
id: "counter_1",
payload: %{action: :increment},
capability: :counter
})
# With custom initialization
{:ok, pid, _} = Mesh.call(%Mesh.Request{
module: MyApp.GameActor,
id: "player_123",
payload: %{action: :spawn},
capability: :game,
init_arg: %{starting_level: 5}
})
@spec cast(Mesh.Request.t()) :: :ok | {:error, term()}
Asynchronously casts a message to a virtual process.
Similar to call/1 but uses GenServer.cast instead of GenServer.call,
returning immediately without waiting for a response.
Parameters
request- A%Mesh.Request{}struct (same ascall/1)
Returns
:ok- Message was sent successfully{:error, reason}- Failure (e.g., no nodes support the capability)
Examples
# Fire and forget
:ok = Mesh.cast(%Mesh.Request{
module: MyApp.Logger,
id: "system_logger",
payload: %{event: "user_login", user_id: 123},
capability: :logging
})
@spec nodes_for(capability()) :: [node()]
Returns the list of nodes that support a given capability.
This is useful for understanding cluster topology and debugging routing issues.
Parameters
capability- The capability atom to query
Returns
- List of node atoms that support the capability
- Empty list if no nodes support the capability
Examples
Mesh.nodes_for(:game)
#=> [:node1@host, :node2@host, :node3@host]
Mesh.nodes_for(:chat)
#=> [:node2@host]
Mesh.nodes_for(:unknown)
#=> []Notes
- Only returns nodes currently connected to the cluster
- Updates automatically when nodes join/leave
- Results are eventually consistent across the cluster
@spec owner_node(shard(), capability()) :: {:ok, node()} | {:error, :no_nodes}
Determines which node owns a given shard for a specific capability.
This combines the hash ring with capability information to determine the owner node. Shards are distributed in round-robin fashion across nodes that support the capability.
Parameters
shard- Shard number (0..4095)capability- Capability atom
Returns
{:ok, node}- Success with the owner node atom{:error, :no_nodes}- No nodes support the capability
Examples
Mesh.owner_node(2451, :game)
#=> {:ok, :node1@host}
Mesh.owner_node(2451, :game)
#=> {:ok, :node1@host} # Always the same owner
# Error if no nodes support capability
Mesh.owner_node(2451, :unknown)
#=> {:error, :no_nodes}Notes
- Owner may change when nodes join/leave the cluster
- Uses modulo operation:
rem(shard, length(nodes)) - Ensures even distribution across available nodes
@spec register_capabilities(capabilities()) :: :ok
Registers capabilities that this node supports.
Capabilities determine which types of actors this node can host. Once registered, the node will participate in the hash ring for those capabilities and may be assigned shards to manage.
This should typically be called during application startup or node initialization.
Parameters
capabilities- List of capability atoms (e.g.,[:game, :chat])
Examples
# Single capability
Mesh.register_capabilities([:game])
# Multiple capabilities
Mesh.register_capabilities([:game, :chat, :payment])
# Register all capabilities
Mesh.register_capabilities([:game, :chat, :payment, :analytics])Notes
- Capabilities are propagated to all nodes in the cluster
- Registering capabilities triggers shard synchronization
- You can register capabilities at any time (not just at startup)
- Capabilities are stored in memory and lost on node restart
Computes the shard number for a given actor ID.
Uses :erlang.phash2/2 to hash the actor ID into a shard number (0..4095).
The same actor ID always produces the same shard number, ensuring deterministic
actor placement.
Parameters
actor_id- The actor identifier (string)
Returns
- Integer from 0 to 4095 representing the shard number
Examples
Mesh.shard_for("player_123")
#=> 2451
Mesh.shard_for("player_123")
#=> 2451 # Always the same shard
Mesh.shard_for("player_456")
#=> 891 # Different actor, different shardNotes
- Shard count is configurable (default: 4096)
- Hash function is deterministic and uniform
- Used internally by
invoke/3for routing