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.
Cleans up expired sticky mappings.
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
Cleans up expired sticky mappings.
This function should be called periodically (e.g., every hour) to remove stale sticky node assignments that have exceeded their TTL.
Returns
:ok- Cleanup completed
@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