Current section
Files
Jump to
Current section
Files
lib/aerospike/cluster.ex
defmodule Aerospike.Cluster do
@moduledoc """
Read-side helpers over published cluster state.
`ready?/1`, routing, retry policy, and active-node helpers read the
cluster's ETS tables directly when the caller passes the cluster atom
or a pid registered under that atom. Transport sockets, tend-cycle state,
and node handles remain implementation details. `warm_up/2` is the one
explicit operator helper this module aggregates over the published
active-node view.
"""
alias Aerospike.Cluster.NodePool
alias Aerospike.Cluster.Router
alias Aerospike.Cluster.Tender
alias Aerospike.Error
alias Aerospike.Key
alias Aerospike.RetryPolicy
@typedoc """
Cluster identity accepted by read-side helpers.
Pass the cluster registration atom used at startup, or the pid of a
process registered under that atom. Unregistered pids raise
`ArgumentError`.
"""
@type cluster :: atom() | pid()
@typedoc """
ETS table names published for one cluster runtime.
These names are derived from the cluster identity and are used by the
read-side helpers to inspect partition ownership, node generation,
runtime metadata, and transaction tracking state.
"""
@type tables :: %{
owners: atom(),
node_gens: atom(),
meta: atom(),
txn_tracking: atom()
}
@typedoc """
Replica selection policy used by read routing.
`:master` always routes to the partition master. `:sequence` walks the
available replica list using the retry attempt index.
"""
@type replica_policy :: :master | :sequence
@typedoc """
Result returned by cluster routing helpers.
Successful routes return the selected node name. `:cluster_not_ready`
means the namespace has no complete partition map yet; `:no_master`
means the map is present but the target partition has no eligible owner.
"""
@type route_result :: {:ok, String.t()} | {:error, :cluster_not_ready | :no_master}
@typedoc """
Active node metadata returned by `nodes/1`.
The host and port are the direct-connect endpoint captured from the
Tender's current node handle. Unknown handles are represented with an
empty host and port `0`.
"""
@type node_info :: %{name: String.t(), host: String.t(), port: :inet.port_number()}
@typedoc """
Warm-up option accepted by `warm_up/2`.
* `:count` — number of connections to check out per active node.
`0` or omitted means the configured pool size. Values above the
pool size are capped to the pool size.
* `:pool_checkout_timeout` — timeout in milliseconds for each
individual pool checkout. Defaults to `5_000`.
"""
@type warm_up_option ::
{:count, non_neg_integer()}
| {:pool_checkout_timeout, non_neg_integer()}
@typedoc """
Per-node warm-up result returned inside `t:warm_up_result/0`.
`:status` is `:ok`, `:partial`, or `:error`. `:requested` is the capped
per-node target and `:warmed` is the number of successful checkouts.
`:error` is `nil` on full success or the checkout failure returned by
the pool.
"""
@type warm_up_node_result :: %{
required(:host) => String.t(),
required(:port) => :inet.port_number() | 0,
required(:status) => :ok | :partial | :error,
required(:requested) => non_neg_integer(),
required(:warmed) => non_neg_integer(),
required(:error) => Error.t() | nil
}
@typedoc """
Aggregate warm-up report returned by `warm_up/2`.
The `:nodes` map is keyed by node name and contains one
`t:warm_up_node_result/0` per active node.
"""
@type warm_up_result :: %{
status: :ok | :partial | :error,
requested_per_node: non_neg_integer(),
total_requested: non_neg_integer(),
total_warmed: non_neg_integer(),
nodes_total: non_neg_integer(),
nodes_ok: non_neg_integer(),
nodes_partial: non_neg_integer(),
nodes_error: non_neg_integer(),
nodes: %{String.t() => warm_up_node_result()}
}
@doc """
Returns the published ETS tables for `cluster`.
"""
@spec tables(cluster()) :: tables()
def tables(cluster)
def tables(cluster) when is_atom(cluster) do
%{
owners: :"#{cluster}_partition_map_owners",
node_gens: :"#{cluster}_partition_map_node_gens",
meta: :"#{cluster}_meta",
txn_tracking: :"#{cluster}_txn_tracking"
}
end
def tables(cluster) when is_pid(cluster), do: cluster |> registered_name!() |> tables()
def tables(_cluster),
do: raise(ArgumentError, "cluster identity must be an atom or a pid registered under one")
@doc """
Returns whether every configured namespace has a complete partition map.
"""
@spec ready?(cluster()) :: boolean()
def ready?(cluster) do
cluster
|> tables()
|> Map.fetch!(:meta)
|> read_meta(:ready, false)
end
@doc """
Returns the cluster-default retry policy published in `meta.:retry_opts`.
"""
@spec retry_policy(cluster()) :: RetryPolicy.t()
def retry_policy(cluster) do
cluster
|> tables()
|> Map.fetch!(:meta)
|> load_retry_policy()
end
@doc """
Returns the published active node-name snapshot.
"""
@spec active_nodes(cluster()) :: [String.t()]
def active_nodes(cluster) do
cluster
|> tables()
|> Map.fetch!(:meta)
|> read_meta(:active_nodes, [])
end
@doc """
Returns whether `node_name` appears in the published active-node snapshot.
"""
@spec active_node?(cluster(), String.t()) :: boolean()
def active_node?(cluster, node_name) when is_binary(node_name) do
node_name in active_nodes(cluster)
end
@doc """
Returns the published active node-name snapshot.
"""
@spec node_names(cluster()) :: [String.t()]
def node_names(cluster) do
active_nodes(cluster)
end
@doc """
Returns the published active nodes with their direct-connect host and port.
"""
@spec nodes(cluster()) :: [node_info()]
def nodes(cluster) do
cluster
|> node_names()
|> Enum.map(&node_info(cluster, &1))
end
@doc """
Verifies that the active node pools can serve checkouts through the normal path.
`:count` defaults to the configured pool size for the cluster and is capped
at that size. `:pool_checkout_timeout` controls each checkout attempt.
"""
@spec warm_up(cluster(), [warm_up_option()]) :: {:ok, warm_up_result()} | {:error, Error.t()}
def warm_up(cluster, opts \\ []) when is_list(opts) do
with true <- ready?(cluster) || {:error, Error.from_result_code(:cluster_not_ready)},
{:ok, pool_size} <- configured_pool_size(cluster),
{:ok, requested_count} <-
normalize_warm_up_count(Keyword.get(opts, :count, 0), pool_size),
node_names when is_list(node_names) and node_names != [] <- node_names(cluster) do
{:ok,
warm_up_result(
cluster,
node_names,
requested_count,
Keyword.get(opts, :pool_checkout_timeout, 5_000)
)}
else
[] -> {:error, Error.from_result_code(:cluster_not_ready)}
{:error, %Error{} = error} -> {:error, error}
end
end
@doc """
Routes a read for `key` under `policy` and `attempt`.
"""
@spec route_for_read(cluster(), Key.t(), replica_policy(), non_neg_integer()) :: route_result()
def route_for_read(cluster, %Key{} = key, policy, attempt)
when policy in [:master, :sequence] and is_integer(attempt) and attempt >= 0 do
Router.pick_for_read(tables(cluster), key, policy, attempt)
end
@doc """
Routes a write for `key` to its master node.
"""
@spec route_for_write(cluster(), Key.t()) :: route_result()
def route_for_write(cluster, %Key{} = key) do
Router.pick_for_write(tables(cluster), key)
end
defp registered_name!(cluster) when is_pid(cluster) do
case Process.info(cluster, :registered_name) do
{:registered_name, name} when is_atom(name) -> name
_ -> raise ArgumentError, "cluster identity must be an atom or a pid registered under one"
end
end
defp node_info(cluster, node_name) do
case Tender.node_handle(cluster, node_name) do
{:ok, %{host: host, port: port}} ->
%{name: node_name, host: host, port: port}
{:error, :unknown_node} ->
%{name: node_name, host: "", port: 0}
end
end
defp configured_pool_size(cluster) do
case :ets.lookup(tables(cluster).meta, {:runtime, :config, :pool_size}) do
[{{:runtime, :config, :pool_size}, pool_size}]
when is_integer(pool_size) and pool_size > 0 ->
{:ok, pool_size}
_ ->
{:error, Error.from_result_code(:cluster_not_ready)}
end
catch
:error, :badarg ->
{:error, Error.from_result_code(:cluster_not_ready)}
end
defp normalize_warm_up_count(count, pool_size) when is_integer(count) and count >= 0 do
{:ok, if(count == 0, do: pool_size, else: min(count, pool_size))}
end
defp normalize_warm_up_count(other, _pool_size) do
{:error,
Error.from_result_code(:invalid_argument,
message: ":count must be a non-negative integer, got: #{inspect(other)}"
)}
end
defp warm_up_result(cluster, node_names, requested_count, checkout_timeout) do
node_results =
Map.new(node_names, fn node_name ->
warm_up_node(cluster, node_name, requested_count, checkout_timeout)
end)
nodes_total = map_size(node_results)
total_requested = requested_count * nodes_total
total_warmed = Enum.reduce(node_results, 0, &sum_warmed_connections/2)
nodes_ok = count_node_results(node_results, :ok)
nodes_partial = count_node_results(node_results, :partial)
nodes_error = count_node_results(node_results, :error)
%{
status: warm_up_status(nodes_ok, nodes_partial, nodes_error),
requested_per_node: requested_count,
total_requested: total_requested,
total_warmed: total_warmed,
nodes_total: nodes_total,
nodes_ok: nodes_ok,
nodes_partial: nodes_partial,
nodes_error: nodes_error,
nodes: node_results
}
end
defp warm_up_node(cluster, node_name, requested_count, checkout_timeout) do
case Tender.node_handle(cluster, node_name) do
{:ok, %{pool: pool, host: host, port: port}} ->
case NodePool.warm_up(pool, requested_count, checkout_timeout) do
{:ok, warmed} ->
{node_name,
%{
host: host,
port: port,
requested: requested_count,
warmed: warmed,
status: :ok,
error: nil
}}
{:error, %Error{} = error, warmed} ->
{node_name,
%{
host: host,
port: port,
requested: requested_count,
warmed: warmed,
status: warm_up_node_status(warmed),
error: error
}}
end
{:error, :unknown_node} ->
{node_name,
%{
host: "",
port: 0,
requested: requested_count,
warmed: 0,
status: :error,
error: Error.from_result_code(:invalid_node, node: node_name)
}}
end
end
defp warm_up_node_status(0), do: :error
defp warm_up_node_status(_warmed), do: :partial
defp sum_warmed_connections({_node_name, %{warmed: warmed}}, acc), do: acc + warmed
defp count_node_results(node_results, status) do
Enum.count(node_results, fn {_node_name, result} -> result.status == status end)
end
defp warm_up_status(nodes_ok, 0, 0) when nodes_ok > 0, do: :ok
defp warm_up_status(0, 0, nodes_error) when nodes_error > 0, do: :error
defp warm_up_status(_nodes_ok, _nodes_partial, _nodes_error), do: :partial
defp read_meta(meta_tab, key, default) do
case :ets.lookup(meta_tab, key) do
[{^key, value}] -> value
_ -> default
end
catch
:error, :badarg -> default
end
defp load_retry_policy(meta_tab) do
RetryPolicy.load(meta_tab)
catch
:error, :badarg -> RetryPolicy.defaults()
end
end