Packages

Aerospike driver for Elixir with an OTP-native cluster runtime

Current section

Files

Jump to
aerospike_driver lib aerospike cluster node.ex
Raw

lib/aerospike/cluster/node.ex

defmodule Aerospike.Cluster.Node do
@moduledoc false
alias Aerospike.Error
alias Aerospike.Protocol.Info, as: InfoParser
alias Aerospike.Protocol.PartitionMap, as: PartitionMapParser
alias Aerospike.Protocol.Peers
require Logger
@refresh_info_keys ["partition-generation", "cluster-stable", "peers-generation"]
@enforce_keys [:name, :host, :port, :conn]
defstruct [
:name,
:host,
:port,
:conn,
session: nil,
features: nil,
generation_seen: nil,
applied_gen: nil,
cluster_stable: nil,
peers_generation_seen: nil
]
@typedoc """
Cached login session returned by `Aerospike.Cluster.NodeTransport.login/2`:
* `nil` — no credentials configured, or the server has security
disabled / accepted the login without issuing a token.
* `{token, expires_at_monotonic_ms_or_nil}` — the server issued
`token` and its TTL has been translated into a monotonic-time
deadline (minus a small skew) so the caller can compare against
`System.monotonic_time(:millisecond)` directly.
"""
@type session :: nil | {token :: binary(), expires_at_ms :: integer() | nil}
@type t :: %__MODULE__{
name: String.t(),
host: String.t(),
port: :inet.port_number(),
conn: term(),
session: session(),
features: MapSet.t() | nil,
generation_seen: non_neg_integer() | nil,
applied_gen: non_neg_integer() | nil,
cluster_stable: String.t() | nil,
peers_generation_seen: non_neg_integer() | nil
}
@typedoc """
Options used by `seed/5` and `login/4` to drive the admin-protocol
login on a freshly opened info socket.
* `:user` / `:password` — both must be present for login to run. When
either is `nil`, `login/4` returns `{:ok, nil}` and no auth traffic
is sent. Matches the Tender's `validate_auth_pair!/2` contract.
"""
@type auth_opts :: [
user: String.t() | nil,
password: String.t() | nil,
auth_mode: :internal | :external | :pki,
login_timeout_ms: pos_integer() | nil,
cluster_name: String.t() | nil,
application_id: String.t() | nil
]
@typedoc """
Observations produced by `refresh/2` in addition to the updated struct:
* `:partition_generation` — the parsed `partition-generation` value
the node reported this refresh. The caller compares against its own
per-node-generation store to decide whether to refetch `replicas`.
* `:cluster_stable` — the raw `cluster-stable` hash reported this
refresh. The tend cycle folds these hashes into a per-cycle map and
feeds them to `PartitionMapMerge.verify_cluster_stable/1` (extracted
in a later step of the split).
* `:peers_generation` — the parsed `peers-generation` value the node
reported this refresh.
* `:peers_generation_changed?` — `true` when the freshly observed
`peers-generation` differs from the cached `peers_generation_seen`
on the incoming struct. A freshly-seen node (cache is `nil`) always
reports `true`, matching the Go/C/Java clients' first-cycle sentinel
behaviour. The tend cycle uses this flag to decide whether any node
warrants a `peers-clear-std` probe this cycle.
"""
@type refresh_observations :: %{
partition_generation: non_neg_integer(),
cluster_stable: String.t(),
peers_generation: non_neg_integer(),
peers_generation_changed?: boolean()
}
@doc """
Dials `host:port` through `transport`, runs the admin-protocol login (if
credentials are supplied), and fetches the `node` and `features` info
keys in one round trip.
Returns `{:ok, %Node{}}` with the node name reported by the server, the
parsed feature set, and the cached session. Returns `{:error, reason}`
on any step's failure; the caller is expected to close/ignore the
connection (a bootstrap failure surfaces before `conn` is owned by any
struct).
`connect_opts` is forwarded verbatim to `transport.connect/3`.
"""
@spec seed(module(), String.t(), :inet.port_number(), keyword(), auth_opts()) ::
{:ok, t()} | {:error, :no_node_info | Error.t()}
def seed(transport, host, port, connect_opts, auth_opts) do
with {:ok, conn} <- transport.connect(host, port, connect_opts),
{:ok, session} <- login(transport, conn, auth_opts),
{:ok, %{"node" => node_name} = info} <- transport.info(conn, seed_info_keys(auth_opts)),
:ok <- validate_cluster_name(node_name, info, Keyword.get(auth_opts, :cluster_name)),
:ok <- maybe_send_user_agent(transport, conn, node_name, info, auth_opts) do
features = parse_features(node_name, info)
node = %__MODULE__{
name: node_name,
host: host,
port: port,
conn: conn,
session: session,
features: features,
generation_seen: nil,
applied_gen: nil,
cluster_stable: nil,
peers_generation_seen: nil
}
{:ok, node}
else
{:error, :cluster_name_mismatch} ->
{:error,
Error.from_result_code(:invalid_node,
message: "discovered node does not match configured cluster name"
)}
{:error, %Error{}} = err ->
err
{:ok, _other} ->
{:error, :no_node_info}
end
end
@doc """
Runs the admin-protocol login on an already-connected info socket.
Returns `{:ok, nil}` when no credentials are configured, when the server
responded with `:ok_no_token` (PKI-anonymous), or when security is
disabled. Returns `{:ok, {token, expires_at_monotonic_ms | nil}}` on a
successful session-token login. Any transport-level error propagates as
`{:error, %Error{}}`.
`expires_at_monotonic_ms` is `System.monotonic_time(:millisecond)` plus
the server-reported TTL (seconds), minus a small skew so the client
drops the token before the server does. Matches Go's
`login_command.go`.
"""
@spec login(module(), term(), auth_opts()) :: {:ok, session()} | {:error, Error.t()}
def login(transport, conn, opts) do
auth_mode = Keyword.get(opts, :auth_mode, :internal)
user = Keyword.get(opts, :user)
password = Keyword.get(opts, :password)
if auth_mode == :pki do
do_login(transport, conn, auth_mode, user, password)
else
if is_nil(user) or is_nil(password) do
{:ok, nil}
else
do_login(transport, conn, auth_mode, user, password)
end
end
end
defp do_login(transport, conn, auth_mode, user, password) do
opts = [auth_mode: auth_mode, user: user, password: password] |> compact_nil_opts()
case transport.login(conn, opts) do
{:ok, {:session, token, ttl}} ->
{:ok, {token, session_expires_at(ttl)}}
{:ok, :ok_no_token} ->
{:ok, nil}
{:ok, :security_not_enabled} ->
{:ok, nil}
{:error, %Error{}} = err ->
err
end
end
defp compact_nil_opts(opts), do: Enum.reject(opts, fn {_key, value} -> is_nil(value) end)
defp seed_info_keys(auth_opts) do
keys = ["node", "features"]
keys
|> maybe_add_info_key("cluster-name", Keyword.get(auth_opts, :cluster_name))
|> maybe_add_info_key("build", Keyword.get(auth_opts, :application_id))
end
defp maybe_add_info_key(keys, _key, nil), do: keys
defp maybe_add_info_key(keys, key, _value), do: keys ++ [key]
defp validate_cluster_name(_node_name, _info, nil), do: :ok
defp validate_cluster_name(_node_name, %{"cluster-name" => expected}, expected), do: :ok
defp validate_cluster_name(node_name, info, expected) do
Logger.warning(fn ->
"Aerospike.Cluster.Node: #{node_name} rejected because cluster-name " <>
"#{inspect(Map.get(info, "cluster-name"))} does not match #{inspect(expected)}"
end)
{:error, :cluster_name_mismatch}
end
def fetch_peer_features(transport, conn, node_name, opts) do
keys =
["features"]
|> maybe_add_info_key("cluster-name", Keyword.get(opts, :cluster_name))
|> maybe_add_info_key("build", Keyword.get(opts, :application_id))
case transport.info(conn, keys) do
{:ok, info} ->
with :ok <- validate_cluster_name(node_name, info, Keyword.get(opts, :cluster_name)),
:ok <- maybe_send_user_agent(transport, conn, node_name, info, opts) do
{:ok, parse_features(node_name, info)}
else
{:error, :cluster_name_mismatch} ->
{:error,
Error.from_result_code(:invalid_node,
message: "discovered node does not match configured cluster name"
)}
end
{:error, %Error{} = err} ->
Logger.debug(fn ->
"Aerospike.Cluster.Node: peer #{node_name} features probe failed: #{err.message}"
end)
{:ok, MapSet.new()}
end
end
defp maybe_send_user_agent(transport, conn, _node_name, %{"build" => build}, opts) do
app_id = Keyword.get(opts, :application_id)
if is_binary(app_id) and server_supports_user_agent?(build) do
command = "user-agent-set:value=" <> Base.encode64(user_agent(app_id))
case transport.info(conn, [command]) do
{:ok, _} -> :ok
{:error, %Error{}} = err -> {:error, err}
end
else
:ok
end
end
defp maybe_send_user_agent(_transport, _conn, _node_name, _info, _opts), do: :ok
defp server_supports_user_agent?(build) when is_binary(build) do
case Regex.run(~r/^v?(\d+)\.(\d+)/, build) do
[_, major, minor] ->
{String.to_integer(major), String.to_integer(minor)} >= {8, 1}
_ ->
false
end
end
defp server_supports_user_agent?(_build), do: false
defp user_agent(app_id), do: "1,elixir-#{driver_version()},#{app_id}"
defp driver_version do
case Application.spec(:aerospike_driver, :vsn) do
nil -> "unknown"
vsn -> List.to_string(vsn)
end
end
# `ttl` is in seconds; subtract up to 60 s (or half the TTL, whichever is
# smaller) so the client drops the token before the server does
# (matches Go `login_command.go`).
defp session_expires_at(nil), do: nil
defp session_expires_at(ttl_seconds) when is_integer(ttl_seconds) and ttl_seconds > 0 do
skew = min(60, div(ttl_seconds, 2))
System.monotonic_time(:millisecond) + (ttl_seconds - skew) * 1_000
end
defp session_expires_at(_), do: nil
@doc """
Parses the `features` info-key reply into a MapSet of capability tokens.
Missing, non-binary, or otherwise unusable replies collapse to an empty
MapSet — the safe default (every optional capability stays off) that the
Tender's bootstrap path has historically taken on probe failure.
"""
@spec parse_features(String.t(), %{String.t() => String.t()}) :: MapSet.t()
def parse_features(node_name, info) do
case Map.fetch(info, "features") do
{:ok, value} when is_binary(value) ->
InfoParser.parse_features(value)
_ ->
Logger.debug(fn ->
"Aerospike.Cluster.Node: #{node_name} features key absent; assuming none"
end)
MapSet.new()
end
end
@doc """
Runs one refresh info round trip (`partition-generation`,
`cluster-stable`, and `peers-generation`) against the node.
On success returns `{:ok, node, observations}` where `node` carries the
updated `generation_seen`, `cluster_stable`, and `peers_generation_seen`
fields, and `observations` exposes the raw values (plus a
`peers_generation_changed?` flag) so the caller can decide whether a
subsequent `refresh_partitions/2` or cluster-wide peer refresh is
warranted this cycle.
Returns `{:error, reason}` for any transport failure or malformed reply.
The struct is left untouched on error — the caller is expected to count
the failure against its own lifecycle budget.
"""
@spec refresh(t(), module()) ::
{:ok, t(), refresh_observations()} | {:error, Error.t() | :malformed_reply}
def refresh(%__MODULE__{} = node, transport) do
case transport.info(node.conn, @refresh_info_keys) do
{:ok, info} ->
handle_refresh_info(node, info)
{:error, %Error{}} = err ->
err
end
end
# All three keys must parse for the refresh to count as a success.
# Missing or malformed `partition-generation` is the canonical failure
# signal (the rest of the tend cycle hangs off the generation value);
# missing or malformed `cluster-stable` is also a failure because the
# agreement guard cannot verify a node that did not report a hash;
# `peers-generation` is the trigger for cluster-wide peer discovery,
# and treating its absence as success would silently disable the
# trigger.
defp handle_refresh_info(node, %{
"partition-generation" => gen_value,
"cluster-stable" => cluster_stable,
"peers-generation" => peers_gen_value
})
when is_binary(cluster_stable) and cluster_stable != "" do
with {:ok, gen} <- PartitionMapParser.parse_partition_generation(gen_value),
{:ok, peers_gen} <- Peers.parse_generation(peers_gen_value) do
changed? = node.peers_generation_seen != peers_gen
updated = %__MODULE__{
node
| generation_seen: gen,
cluster_stable: cluster_stable,
peers_generation_seen: peers_gen
}
{:ok, updated,
%{
partition_generation: gen,
cluster_stable: cluster_stable,
peers_generation: peers_gen,
peers_generation_changed?: changed?
}}
else
:error -> {:error, :malformed_reply}
end
end
defp handle_refresh_info(_node, _info), do: {:error, :malformed_reply}
@doc """
Picks the peer-discovery info key for this node based on the cluster's
`:use_services_alternate` toggle. `peers-clear-alt` surfaces the
server's alternate-access addresses; `peers-clear-std` surfaces its
primary-access addresses. Reply format is identical.
"""
@spec peer_info_key(boolean()) :: String.t()
def peer_info_key(true), do: "peers-clear-alt"
def peer_info_key(_), do: "peers-clear-std"
@doc """
Fetches and parses the node's peer list.
The `:use_services_alternate` option chooses between `peers-clear-std`
(default) and `peers-clear-alt`. Returns `{:ok, node, peers}` where
`peers` is the list of peer maps from `Aerospike.Protocol.Peers`
(cross-node accumulation belongs to the caller). Returns
`{:error, reason}` when the info call fails or the reply cannot be
parsed; the node struct is unchanged on error.
"""
@spec refresh_peers(t(), module(), use_services_alternate: boolean()) ::
{:ok, t(), [map()]} | {:error, Error.t() | :malformed_reply}
def refresh_peers(%__MODULE__{} = node, transport, opts \\ []) do
alternate? = Keyword.get(opts, :use_services_alternate, false)
key = peer_info_key(alternate?)
case transport.info(node.conn, [key]) do
{:ok, %{^key => value}} ->
case Peers.parse_peers_clear_std(value) do
{:ok, %{peers: peers}} -> {:ok, node, peers}
:error -> {:error, :malformed_reply}
end
{:ok, _other} ->
{:error, :malformed_reply}
{:error, %Error{}} = err ->
err
end
end
@doc """
Fetches the node's `features` info-key reply on an already-connected
socket. Used by peer discovery, where `seed/5` is not called (the peer's
name is already known from the parent node's `peers-clear-std` reply).
Returns a MapSet of capability tokens; probe failures collapse to the
empty set.
"""
@spec fetch_features(module(), term(), String.t()) :: MapSet.t()
def fetch_features(transport, conn, node_name) do
case transport.info(conn, ["features"]) do
{:ok, info} ->
parse_features(node_name, info)
{:error, %Error{} = err} ->
Logger.debug(fn ->
"Aerospike.Cluster.Node: peer #{node_name} features probe failed: #{err.message}"
end)
MapSet.new()
end
end
@doc """
Fetches and parses the node's `replicas` reply.
Returns `{:ok, node, segments}` where `segments` is the list
`[{namespace, regime, ownership}]` produced by
`Aerospike.Protocol.PartitionMap.parse_replicas_with_regime/1`. The
caller is responsible for applying those segments against the partition
map (stale-regime detection, ETS writes); this function only does I/O
and parsing. Returns `{:error, reason}` on any failure; the struct is
unchanged.
"""
@spec refresh_partitions(t(), module()) ::
{:ok, t(), [{String.t(), non_neg_integer(), [{non_neg_integer(), non_neg_integer()}]}]}
| {:error, Error.t() | :malformed_reply}
def refresh_partitions(%__MODULE__{} = node, transport) do
case transport.info(node.conn, ["replicas"]) do
{:ok, %{"replicas" => value}} ->
segments = PartitionMapParser.parse_replicas_with_regime(value)
{:ok, node, segments}
{:ok, _other} ->
{:error, :malformed_reply}
{:error, %Error{}} = err ->
err
end
end
@doc """
Clears the per-cycle `cluster_stable` hash on the struct. Called at the
start of every tend cycle so `verify_cluster_stable/1` only ever
considers hashes captured within the current cycle.
"""
@spec clear_cluster_stable(t()) :: t()
def clear_cluster_stable(%__MODULE__{} = node), do: %__MODULE__{node | cluster_stable: nil}
@doc """
Records the fact that the caller successfully applied the node's
partition-map segments at `gen`. The struct's `applied_gen` moves
forward; nothing else changes. `gen` may be `nil` when a freshly
discovered peer reaches this stage before its first successful
`partition-generation` probe — matches the pre-split behaviour where
`applied_gen: nil` naturally re-triggers a fetch on the next cycle.
"""
@spec mark_partition_map_applied(t(), non_neg_integer() | nil) :: t()
def mark_partition_map_applied(%__MODULE__{} = node, gen)
when is_nil(gen) or is_integer(gen) do
%__MODULE__{node | applied_gen: gen}
end
@doc """
Closes the node's info socket through `transport`. Swallows any exit or
exception so `terminate/2` and lifecycle transitions can call it
unconditionally.
"""
@spec close(module(), term()) :: :ok
def close(transport, conn) do
transport.close(conn)
rescue
_ -> :ok
catch
_, _ -> :ok
end
end