Provides node selection strategies for distributed request execution.
This module implements various strategies for selecting target nodes from a list of available nodes. The selection strategy determines how requests are distributed across nodes in a cluster.
Supported Selection Strategies
:random
Selects a random node from the available nodes list. This provides simple load balancing with no guarantees about distribution fairness.
:hash
Uses consistent hashing based on the request ID to select a node. The same request ID will always map to the same node, which is useful for caching and stateful operations.
### {:hash, hash_key}
Uses consistent hashing based on a specific field from the request. The hash_key can
reference a field in request.args or a field directly on the request struct (like
user_id or device_id). This ensures requests with the same hash_key value always
go to the same node.
:round_robin
Distributes requests evenly across all nodes in a circular fashion. Uses an atomic counter for true global round-robin distribution across all processes.
Dynamic Node Resolution
Instead of a static list of nodes, you can provide a module-function-args tuple that will be called at runtime to get the current list of nodes:
nodes: {MyApp.NodeRegistry, :get_active_nodes, []}This allows for dynamic node discovery and automatic adaptation to cluster changes.
Node Selection for Retry/Fallback
The get_nodes/2 function returns a list of nodes suitable for the retry strategy:
:randomand:hashreturn a single-node list (the selected node):round_robinreturns all nodes (for fallback to other nodes on failure)
Fault Tolerance
- Nodes are validated before selection
- Empty node lists return
{:error, :no_nodes_available} - Invalid node formats are filtered out
- Failed node resolution is logged with details
Examples
# Random selection
config = %FunConfig{
nodes: ["node1@host", "node2@host", "node3@host"],
choose_node_mode: :random
}
{:ok, node} = NodeSelector.get_node(config, request)
# Hash by request ID (consistent)
config = %FunConfig{
nodes: ["node1@host", "node2@host"],
choose_node_mode: :hash
}
{:ok, node} = NodeSelector.get_node(config, request)
# Hash by custom field
request = %Request{
request_id: "req_123",
user_id: "user_456",
args: %{"session_id" => "sess_789"}
}
config = %FunConfig{
nodes: ["node1@host", "node2@host"],
choose_node_mode: {:hash, "user_id"}
}
{:ok, node} = NodeSelector.get_node(config, request)
# Round-robin (global, atomic counter)
config = %FunConfig{
nodes: ["node1@host", "node2@host", "node3@host"],
choose_node_mode: :round_robin
}
{:ok, node1} = NodeSelector.get_node(config, request)
{:ok, node2} = NodeSelector.get_node(config, request)
{:ok, node3} = NodeSelector.get_node(config, request)
{:ok, node1_again} = NodeSelector.get_node(config, request) # wraps aroundNotes
- Round-robin uses an atomic counter for true global distribution (no process dictionary)
- Hash functions use
:erlang.phash2/2for deterministic hashing - Dynamic node resolution happens on every call, allowing real-time cluster updates
- If a hash_key is not found in the request, falls back to random selection
- Returns
{:ok, node}on success or{:error, reason}on failure
Summary
Functions
Calculates a retry backoff delay based on the attempt number.
Validates that the choose_node_mode is a recognized strategy.
Selects a single target node based on the configuration and request.
Selects a list of target nodes based on the configuration and request.
Resets the round-robin counter.
Resolves dynamic node configuration to a concrete node list.
Resolves nodes and returns the raw node list regardless of configuration type.
Functions
@spec calculate_backoff( pos_integer(), keyword() ) :: non_neg_integer()
Calculates a retry backoff delay based on the attempt number.
Uses exponential backoff with jitter to prevent thundering herd problems.
Parameters
attempt- The current attempt number (1-based)opts- Options keyword list::base_ms- Base delay in milliseconds (default: 100):max_ms- Maximum delay in milliseconds (default: 5000):jitter- Whether to add random jitter (default: true)
Returns
- Delay in milliseconds before the next retry
Examples
iex> NodeSelector.calculate_backoff(1)
# Returns ~100ms (with jitter)
iex> NodeSelector.calculate_backoff(3)
# Returns ~400ms (with jitter)
iex> NodeSelector.calculate_backoff(5, max_ms: 2000)
# Returns capped at ~2000ms (with jitter)
@spec choose_node_valid?(PhoenixGenApi.Structs.FunConfig.t()) :: boolean()
Validates that the choose_node_mode is a recognized strategy.
Returns
trueif the mode is validfalseotherwise
@spec get_node(PhoenixGenApi.Structs.FunConfig.t(), PhoenixGenApi.Structs.Request.t()) :: {:ok, node :: atom() | String.t()} | {:error, term()}
Selects a single target node based on the configuration and request.
This function examines the choose_node_mode in the configuration and applies
the appropriate node selection strategy. If the nodes field is a tuple, it
will be called as a function to dynamically resolve the node list.
Parameters
config- AFunConfigstruct containing:nodes- Either a list of node names or a{module, function, args}tuplechoose_node_mode- The selection strategy (:random,:hash,{:hash, key},:round_robin)
request- ARequeststruct containing the request details
Returns
{:ok, node}- The selected node{:error, reason}- Selection failed
Examples
config = %FunConfig{
nodes: ["node1@host", "node2@host"],
choose_node_mode: :random
}
request = %Request{
request_id: "req_123",
request_type: "get_user",
user_id: "user_456",
args: %{"user_id" => "user_456"}
}
{:ok, node} = NodeSelector.get_node(config, request)
@spec get_nodes( PhoenixGenApi.Structs.FunConfig.t(), PhoenixGenApi.Structs.Request.t() ) :: {:ok, [atom() | String.t()]} | {:error, term()}
Selects a list of target nodes based on the configuration and request.
The returned list is ordered by preference for fallback/retry purposes:
:random- Returns a shuffled list of all nodes (selected node first):hash/{:hash, key}- Returns all nodes with the hashed node first:round_robin- Returns all nodes starting from the round-robin position
This is useful for the executor's fallback mechanism where if the primary node fails, it tries the remaining nodes in order.
Parameters
config- AFunConfigstructrequest- ARequeststruct
Returns
{:ok, [node, ...]}- Ordered list of nodes (primary first){:error, reason}- Selection failed
Examples
config = %FunConfig{
nodes: ["node1@host", "node2@host", "node3@host"],
choose_node_mode: :random
}
{:ok, [primary | fallbacks]} = NodeSelector.get_nodes(config, request)
@spec reset_round_robin() :: :ok
Resets the round-robin counter.
This is primarily useful for testing. In production, the counter should be allowed to increment naturally.
@spec resolve_nodes(PhoenixGenApi.Structs.FunConfig.t()) :: {:ok, PhoenixGenApi.Structs.FunConfig.t()} | {:error, term()}
Resolves dynamic node configuration to a concrete node list.
If nodes is an MFA tuple {module, function, args}, calls the function
to get the node list at runtime. If nodes is already a list, returns
the config unchanged. If nodes is :local, returns the config unchanged.
Parameters
config- AFunConfigstruct
Returns
{:ok, %FunConfig{}}- Config with resolved nodes{:error, reason}- Resolution failed
@spec resolve_nodes_list(PhoenixGenApi.Structs.FunConfig.t()) :: {:ok, [atom() | String.t()]} | {:error, term()}
Resolves nodes and returns the raw node list regardless of configuration type.
Unlike resolve_nodes/1 which returns the full config, this returns just
the list of nodes. Useful for getting all available nodes for retry strategies.
Parameters
config- AFunConfigstruct
Returns
{:ok, [node]}- List of resolved nodes{:error, reason}- Resolution failed