Current section
Files
Jump to
Current section
Files
lib/aerospike/transport/fake.ex
defmodule Aerospike.Transport.Fake do
@moduledoc false
@behaviour Aerospike.Cluster.NodeTransport
use GenServer
alias Aerospike.Error
@typedoc "Symbolic node identifier used by tests to address scripted replies."
@type node_id :: term()
@typedoc "Scripted reply for a single info or command call."
@type scripted_reply ::
{:ok, %{String.t() => String.t()}}
| {:ok, binary()}
| {:error, Error.t()}
@typedoc "Scripted event yielded by an open stream."
@type stream_event :: {:frame, binary()} | :done | {:error, Error.t()}
@typedoc "Scripted reply returned when a stream is opened."
@type stream_reply :: {:ok, [stream_event()]} | {:error, Error.t()}
@typedoc "Concrete connection handle returned by `connect/3`."
@opaque conn :: %__MODULE__{fake: pid(), node_id: node_id(), ref: reference()}
defmodule Stream do
@moduledoc false
@enforce_keys [:fake, :node_id, :ref]
defstruct [:fake, :node_id, :ref]
end
@typedoc "Concrete stream handle returned by `stream_open/4`."
@opaque stream :: %Stream{fake: pid(), node_id: node_id(), ref: reference()}
@enforce_keys [:fake, :node_id, :ref]
defstruct [:fake, :node_id, :ref]
## Public scripting API
@doc """
Starts a fake transport instance.
Options:
* `:nodes` — a list of `{node_id, host, port}` triples to pre-register.
Equivalent to calling `register_node/4` for each entry after start.
* `:default_reply` — reply returned when a script queue is empty. Must
be a `scripted_reply`. Defaults to an `:no_script` error.
* `:name` — optional registered name for the GenServer.
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
{name, opts} = Keyword.pop(opts, :name)
gen_opts = if name, do: [name: name], else: []
GenServer.start_link(__MODULE__, opts, gen_opts)
end
@doc """
Registers a node under `node_id` reachable at `host:port`. Later
`connect/3` calls with that host and port resolve to `node_id`.
"""
@spec register_node(pid(), node_id(), String.t(), :inet.port_number()) :: :ok
def register_node(fake, node_id, host, port)
when is_binary(host) and is_integer(port) do
GenServer.call(fake, {:register_node, node_id, host, port})
end
@doc """
Appends a scripted info reply for `node_id`. `commands` must match exactly
the list passed to `info/2` (same order).
"""
@spec script_info(pid(), node_id(), [String.t()], %{String.t() => String.t()}) :: :ok
def script_info(fake, node_id, commands, %{} = reply) when is_list(commands) do
GenServer.call(fake, {:script, {:info, node_id, commands}, {:ok, reply}})
end
@doc """
Appends a scripted info error for `node_id` and the given command list.
"""
@spec script_info_error(pid(), node_id(), [String.t()], Error.t()) :: :ok
def script_info_error(fake, node_id, commands, %Error{} = error)
when is_list(commands) do
GenServer.call(fake, {:script, {:info, node_id, commands}, {:error, error}})
end
@doc """
Appends a scripted command reply for `node_id`. The reply is returned
verbatim by the next `command/2` call for a connection addressing that
node, regardless of request bytes.
"""
@spec script_command(pid(), node_id(), {:ok, binary()} | {:error, Error.t()}) :: :ok
def script_command(fake, node_id, {:ok, bytes} = reply) when is_binary(bytes) do
GenServer.call(fake, {:script, {:command, node_id}, reply})
end
def script_command(fake, node_id, {:error, %Error{}} = reply) do
GenServer.call(fake, {:script, {:command, node_id}, reply})
end
@doc """
Appends a scripted multi-frame command reply for `node_id`.
The reply is returned verbatim by the next `command_stream/4` call for a
connection addressing that node, regardless of request bytes.
"""
@spec script_command_stream(pid(), node_id(), {:ok, binary()} | {:error, Error.t()}) :: :ok
def script_command_stream(fake, node_id, {:ok, bytes} = reply) when is_binary(bytes) do
GenServer.call(fake, {:script, {:command_stream, node_id}, reply})
end
def script_command_stream(fake, node_id, {:error, %Error{}} = reply) do
GenServer.call(fake, {:script, {:command_stream, node_id}, reply})
end
@doc """
Appends a scripted stream-open reply for `node_id`.
Successful stream scripts must include `:done` to terminate cleanly. The
fake returns each queued event in order, and closes the handle when it sees
`:done` or a scripted transport error.
"""
@spec script_stream(pid(), node_id(), stream_reply()) :: :ok
def script_stream(fake, node_id, {:ok, events}) when is_list(events) do
GenServer.call(fake, {:script, {:stream, node_id}, {:ok, events}})
end
def script_stream(fake, node_id, {:error, %Error{}} = reply) do
GenServer.call(fake, {:script, {:stream, node_id}, reply})
end
@doc """
Marks `node_id` as disconnected. Subsequent `info/2` and `command/2`
calls on connections addressing that node return a `:network_error`
without consuming any script entry.
"""
@spec disconnect(pid(), node_id()) :: :ok
def disconnect(fake, node_id) do
GenServer.call(fake, {:disconnect, node_id})
end
@doc """
Appends a scripted `connect/3` outcome for `node_id`. The next
`connect/3` call resolving to that node consumes the head of the queue
instead of the default success path. When the queue is empty the
default behaviour resumes (success unless the node is `disconnect/2`-ed).
This lets tests script per-connect-attempt failures during pool warm-up
without flipping the global disconnected flag.
"""
@spec script_connect(pid(), node_id(), :ok | {:error, Error.t()}) :: :ok
def script_connect(fake, node_id, :ok) do
GenServer.call(fake, {:script_connect, node_id, :ok})
end
def script_connect(fake, node_id, {:error, %Error{}} = reply) do
GenServer.call(fake, {:script_connect, node_id, reply})
end
@doc """
Returns the number of times `connect/3` was invoked for `node_id`,
including failures from `script_connect/3` and `disconnect/2`.
"""
@spec connect_count(pid(), node_id()) :: non_neg_integer()
def connect_count(fake, node_id) do
GenServer.call(fake, {:connect_count, node_id})
end
@doc """
Returns the number of times `close/1` was invoked for a connection
addressing `node_id`. Useful for asserting idle eviction: a connect
followed by a close without a checkout in between indicates a
`handle_ping/2` eviction.
"""
@spec close_count(pid(), node_id()) :: non_neg_integer()
def close_count(fake, node_id) do
GenServer.call(fake, {:close_count, node_id})
end
@doc """
Returns the number of times `stream_close/1` was invoked for a stream
addressing `node_id`.
"""
@spec stream_close_count(pid(), node_id()) :: non_neg_integer()
def stream_close_count(fake, node_id) do
GenServer.call(fake, {:stream_close_count, node_id})
end
@doc """
Returns the deadline (in milliseconds) passed to the most recent
`command/3` call for any connection addressing `node_id`, or `nil` if
`command/3` has not been called for that node yet. Lets tests assert
that callers plumbed a read deadline through the behaviour.
"""
@spec last_command_deadline(pid(), node_id()) :: non_neg_integer() | nil
def last_command_deadline(fake, node_id) do
GenServer.call(fake, {:last_command_deadline, node_id})
end
@doc """
Returns the request passed to the most recent `command/4` call for any
connection addressing `node_id`, or `nil` if `command/4` has not been
called for that node yet.
"""
@spec last_command_request(pid(), node_id()) :: iodata() | nil
def last_command_request(fake, node_id) do
GenServer.call(fake, {:last_command_request, node_id})
end
@doc """
Returns the `opts` keyword list passed to the most recent `command/4`
call for any connection addressing `node_id`, or `nil` if `command/4`
has not been called for that node yet. Lets tests assert that callers
plumbed capability-gated options (currently `:use_compression`) through
the behaviour.
"""
@spec last_command_opts(pid(), node_id()) :: keyword() | nil
def last_command_opts(fake, node_id) do
GenServer.call(fake, {:last_command_opts, node_id})
end
@doc """
Clears a prior `disconnect/2` for `node_id`. Scripted replies resume.
"""
@spec reconnect(pid(), node_id()) :: :ok
def reconnect(fake, node_id) do
GenServer.call(fake, {:reconnect, node_id})
end
@doc """
Overrides the default reply returned when a script queue is empty.
"""
@spec set_default_reply(pid(), scripted_reply()) :: :ok
def set_default_reply(fake, reply) do
GenServer.call(fake, {:set_default_reply, reply})
end
## NodeTransport callbacks
@impl Aerospike.Cluster.NodeTransport
def connect(host, port, opts)
when is_binary(host) and is_integer(port) and is_list(opts) do
case Keyword.fetch(opts, :fake) do
{:ok, fake} -> GenServer.call(fake, {:connect, host, port})
:error -> {:error, fake_missing_error()}
end
end
@impl Aerospike.Cluster.NodeTransport
def close(%__MODULE__{fake: fake, ref: ref}) do
GenServer.call(fake, {:close, ref})
end
@impl Aerospike.Cluster.NodeTransport
def info(%__MODULE__{fake: fake, ref: ref}, commands) when is_list(commands) do
GenServer.call(fake, {:consume, ref, {:info, commands}})
end
@impl Aerospike.Cluster.NodeTransport
def command(%__MODULE__{fake: fake, ref: ref}, request, deadline_ms, opts \\ [])
when (is_binary(request) or is_list(request)) and is_integer(deadline_ms) and
deadline_ms >= 0 and is_list(opts) do
GenServer.call(fake, {:consume, ref, {:command, request, deadline_ms, opts}})
end
@impl Aerospike.Cluster.NodeTransport
def command_stream(%__MODULE__{fake: fake, ref: ref}, request, deadline_ms, opts \\ [])
when (is_binary(request) or is_list(request)) and is_integer(deadline_ms) and
deadline_ms >= 0 and is_list(opts) do
GenServer.call(fake, {:consume, ref, {:command_stream, deadline_ms, opts}})
end
@impl Aerospike.Cluster.NodeTransport
def login(%__MODULE__{}, _opts), do: {:ok, :ok_no_token}
@impl Aerospike.Cluster.NodeTransport
def stream_open(%__MODULE__{fake: fake, ref: ref}, request, deadline_ms, opts \\ [])
when (is_binary(request) or is_list(request)) and is_integer(deadline_ms) and
deadline_ms >= 0 and is_list(opts) do
GenServer.call(fake, {:consume, ref, {:stream_open, request, deadline_ms, opts}})
end
@impl Aerospike.Cluster.NodeTransport
def stream_read(%Stream{fake: fake, ref: ref}, deadline_ms)
when is_integer(deadline_ms) and deadline_ms >= 0 do
GenServer.call(fake, {:stream_read, ref, deadline_ms})
end
@impl Aerospike.Cluster.NodeTransport
def stream_close(%Stream{fake: fake, ref: ref}) do
GenServer.call(fake, {:stream_close, ref})
end
## GenServer callbacks
@impl GenServer
def init(opts) do
state = %{
nodes: %{},
scripts: %{},
connect_scripts: %{},
connect_counts: %{},
close_counts: %{},
stream_close_counts: %{},
last_command_deadlines: %{},
last_command_requests: %{},
last_command_opts: %{},
conns: %{},
streams: %{},
disconnected: MapSet.new(),
default_reply: Keyword.get(opts, :default_reply, default_no_script_reply())
}
nodes = Keyword.get(opts, :nodes, [])
{:ok, Enum.reduce(nodes, state, ®ister_node_reduce/2)}
end
@impl GenServer
def handle_call({:register_node, node_id, host, port}, _from, state) do
{:reply, :ok, put_node(state, node_id, host, port)}
end
def handle_call({:script, key, reply}, _from, state) do
queue = Map.get(state.scripts, key, :queue.new())
scripts = Map.put(state.scripts, key, :queue.in(reply, queue))
{:reply, :ok, %{state | scripts: scripts}}
end
def handle_call({:disconnect, node_id}, _from, state) do
{:reply, :ok, %{state | disconnected: MapSet.put(state.disconnected, node_id)}}
end
def handle_call({:reconnect, node_id}, _from, state) do
{:reply, :ok, %{state | disconnected: MapSet.delete(state.disconnected, node_id)}}
end
def handle_call({:set_default_reply, reply}, _from, state) do
{:reply, :ok, %{state | default_reply: reply}}
end
def handle_call({:script_connect, node_id, outcome}, _from, state) do
queue = Map.get(state.connect_scripts, node_id, :queue.new())
scripts = Map.put(state.connect_scripts, node_id, :queue.in(outcome, queue))
{:reply, :ok, %{state | connect_scripts: scripts}}
end
def handle_call({:connect_count, node_id}, _from, state) do
{:reply, Map.get(state.connect_counts, node_id, 0), state}
end
def handle_call({:close_count, node_id}, _from, state) do
{:reply, Map.get(state.close_counts, node_id, 0), state}
end
def handle_call({:stream_close_count, node_id}, _from, state) do
{:reply, Map.get(state.stream_close_counts, node_id, 0), state}
end
def handle_call({:last_command_deadline, node_id}, _from, state) do
{:reply, Map.get(state.last_command_deadlines, node_id), state}
end
def handle_call({:last_command_request, node_id}, _from, state) do
{:reply, Map.get(state.last_command_requests, node_id), state}
end
def handle_call({:last_command_opts, node_id}, _from, state) do
{:reply, Map.get(state.last_command_opts, node_id), state}
end
def handle_call({:connect, host, port}, _from, state) do
case Map.fetch(state.nodes, {host, port}) do
{:ok, node_id} ->
state = bump_connect_count(state, node_id)
{outcome, state} = next_connect_outcome(state, node_id)
do_connect(state, node_id, host, port, outcome)
:error ->
{:reply, {:error, unknown_host_error(host, port)}, state}
end
end
def handle_call({:close, ref}, _from, state) do
state =
case Map.fetch(state.conns, ref) do
{:ok, node_id} -> bump_close_count(state, node_id)
:error -> state
end
{:reply, :ok, %{state | conns: Map.delete(state.conns, ref)}}
end
def handle_call({:stream_read, ref, deadline_ms}, _from, state) do
with {:ok, %{node_id: node_id}} <- fetch_stream(state, ref),
:ok <- check_connected(state, node_id) do
state = record_stream_read_deadline(state, node_id, deadline_ms)
{reply, state} = consume_stream_event(state, ref, node_id)
{:reply, reply, state}
else
{:error, %Error{}} = err -> {:reply, err, state}
end
end
def handle_call({:stream_close, ref}, _from, state) do
state =
case Map.fetch(state.streams, ref) do
{:ok, {node_id, _events}} -> bump_stream_close_count(state, node_id)
:error -> state
end
{:reply, :ok, %{state | streams: Map.delete(state.streams, ref)}}
end
def handle_call({:consume, ref, {:stream_open, request, deadline_ms, opts}}, _from, state) do
with {:ok, node_id} <- fetch_conn(state, ref),
:ok <- check_connected(state, node_id) do
state = record_stream_open_deadline(state, node_id, deadline_ms, opts)
{reply, state} = consume_script(state, node_id, {:stream_open, request, deadline_ms, opts})
case reply do
{:ok, events} ->
stream_ref = make_ref()
stream = %Stream{fake: self(), node_id: node_id, ref: stream_ref}
streams = Map.put(state.streams, stream_ref, {node_id, :queue.from_list(events)})
{:reply, {:ok, stream}, %{state | streams: streams}}
{:error, %Error{}} = err ->
{:reply, err, state}
other ->
{:reply, other, state}
end
else
{:error, %Error{}} = err -> {:reply, err, state}
end
end
def handle_call({:consume, ref, kind}, _from, state) do
with {:ok, node_id} <- fetch_conn(state, ref),
:ok <- check_connected(state, node_id) do
state = record_command_deadline(state, node_id, kind)
{reply, state} = consume_script(state, node_id, kind)
{:reply, reply, state}
else
{:error, %Error{}} = err -> {:reply, err, state}
end
end
## Helpers
defp bump_connect_count(state, node_id) do
counts = Map.update(state.connect_counts, node_id, 1, &(&1 + 1))
%{state | connect_counts: counts}
end
defp bump_close_count(state, node_id) do
counts = Map.update(state.close_counts, node_id, 1, &(&1 + 1))
%{state | close_counts: counts}
end
defp next_connect_outcome(state, node_id) do
case Map.get(state.connect_scripts, node_id) do
nil ->
{:default, state}
queue ->
case :queue.out(queue) do
{{:value, outcome}, rest} ->
{outcome, %{state | connect_scripts: Map.put(state.connect_scripts, node_id, rest)}}
{:empty, _} ->
{:default, state}
end
end
end
defp do_connect(state, _node_id, _host, _port, {:error, %Error{}} = err) do
{:reply, err, state}
end
defp do_connect(state, node_id, host, port, :ok) do
open_conn(state, node_id, host, port)
end
defp do_connect(state, node_id, host, port, :default) do
if MapSet.member?(state.disconnected, node_id) do
{:reply, {:error, connection_refused_error(host, port)}, state}
else
open_conn(state, node_id, host, port)
end
end
defp open_conn(state, node_id, _host, _port) do
ref = make_ref()
conn = %__MODULE__{fake: self(), node_id: node_id, ref: ref}
{:reply, {:ok, conn}, %{state | conns: Map.put(state.conns, ref, node_id)}}
end
defp register_node_reduce({node_id, host, port}, state) when is_binary(host) do
put_node(state, node_id, host, port)
end
defp put_node(state, node_id, host, port) do
%{state | nodes: Map.put(state.nodes, {host, port}, node_id)}
end
defp fetch_conn(state, ref) do
case Map.fetch(state.conns, ref) do
{:ok, node_id} -> {:ok, node_id}
:error -> {:error, closed_conn_error()}
end
end
defp check_connected(state, node_id) do
if MapSet.member?(state.disconnected, node_id) do
{:error, network_error("node #{inspect(node_id)} is disconnected")}
else
:ok
end
end
defp consume_script(state, node_id, kind) do
key = script_key(node_id, kind)
case Map.get(state.scripts, key) do
nil ->
{state.default_reply, state}
queue ->
case :queue.out(queue) do
{{:value, reply}, rest} ->
{reply, %{state | scripts: Map.put(state.scripts, key, rest)}}
{:empty, _} ->
{state.default_reply, state}
end
end
end
defp script_key(node_id, {:info, commands}), do: {:info, node_id, commands}
defp script_key(node_id, {:command, _request, _deadline_ms, _opts}), do: {:command, node_id}
defp script_key(node_id, {:command_stream, _deadline_ms, _opts}), do: {:command_stream, node_id}
defp script_key(node_id, {:stream_open, _request, _deadline_ms, _opts}), do: {:stream, node_id}
defp record_command_deadline(state, node_id, {:command, request, deadline_ms, opts}) do
%{
state
| last_command_deadlines: Map.put(state.last_command_deadlines, node_id, deadline_ms),
last_command_requests: Map.put(state.last_command_requests, node_id, request),
last_command_opts: Map.put(state.last_command_opts, node_id, opts)
}
end
defp record_command_deadline(state, node_id, {:command_stream, deadline_ms, opts}) do
%{
state
| last_command_deadlines: Map.put(state.last_command_deadlines, node_id, deadline_ms),
last_command_opts: Map.put(state.last_command_opts, node_id, opts)
}
end
defp record_command_deadline(state, _node_id, {:info, _commands}), do: state
defp record_stream_open_deadline(state, _node_id, _deadline_ms, _opts), do: state
defp record_stream_read_deadline(state, _node_id, _deadline_ms), do: state
defp bump_stream_close_count(state, node_id) do
counts = Map.update(state.stream_close_counts, node_id, 1, &(&1 + 1))
%{state | stream_close_counts: counts}
end
defp fetch_stream(state, ref) do
case Map.fetch(state.streams, ref) do
{:ok, {node_id, events}} -> {:ok, %{node_id: node_id, events: events}}
:error -> {:error, closed_stream_error()}
end
end
defp consume_stream_event(state, ref, node_id) do
case Map.fetch(state.streams, ref) do
{:ok, {^node_id, events}} ->
case :queue.out(events) do
{{:value, {:frame, bytes}}, rest} ->
streams = Map.put(state.streams, ref, {node_id, rest})
{{:ok, bytes}, %{state | streams: streams}}
{{:value, :done}, _rest} ->
{:done, %{state | streams: Map.delete(state.streams, ref)}}
{{:value, {:error, %Error{}} = err}, _rest} ->
{err, %{state | streams: Map.delete(state.streams, ref)}}
{:empty, _} ->
{state.default_reply, state}
end
:error ->
{{:error, closed_stream_error()}, state}
{:ok, {_other_node_id, _events}} ->
{{:error, closed_stream_error()}, state}
end
end
defp default_no_script_reply do
{:error,
%Error{
code: :no_script,
message: "Aerospike.Transport.Fake: no scripted reply available"
}}
end
defp fake_missing_error do
%Error{
code: :connection_error,
message: "Aerospike.Transport.Fake: :fake option missing from connect opts"
}
end
defp unknown_host_error(host, port) do
%Error{
code: :connection_error,
message: "Aerospike.Transport.Fake: no node registered at #{host}:#{port}"
}
end
defp connection_refused_error(host, port) do
%Error{
code: :connection_error,
message: "Aerospike.Transport.Fake: connection refused at #{host}:#{port}"
}
end
defp closed_conn_error do
%Error{
code: :network_error,
message: "Aerospike.Transport.Fake: connection handle closed or unknown"
}
end
defp closed_stream_error do
%Error{
code: :network_error,
message: "Aerospike.Transport.Fake: stream handle closed or unknown"
}
end
defp network_error(message) do
%Error{code: :network_error, message: message}
end
end