Current section

Files

Jump to
redix lib redix connection.ex
Raw

lib/redix/connection.ex

defmodule Redix.Connection do
@moduledoc false
use Connection
alias Redix.Protocol
require Logger
@initial_state %{
socket: nil,
tail: "",
opts: nil,
queue: :queue.new,
reconnection_attempts: 0,
}
@default_timeout 5000
@socket_opts [:binary, active: false]
## Callbacks
@doc false
def init(opts) do
{:connect, :init, Dict.merge(@initial_state, opts: opts)}
end
@doc false
def connect(info, s)
def connect(info, %{opts: opts} = s) do
{host, port, socket_opts, timeout} = tcp_connection_opts(opts)
case :gen_tcp.connect(host, port, socket_opts, timeout) do
{:ok, socket} ->
setup_socket_buffers(socket)
auth_and_select_db(%{s | socket: socket, reconnection_attempts: 0})
{:error, reason} ->
Logger.error "Error connecting to Redis (#{host_for_logging(s)}): #{inspect reason}"
handle_connection_error(s, info, reason)
end
end
@doc false
def disconnect(reason, s)
def disconnect(:stop, %{socket: nil} = s) do
{:stop, :normal, s}
end
def disconnect(:stop, %{socket: socket} = s) do
:gen_tcp.close(socket)
{:stop, :normal, %{s | socket: nil}}
end
def disconnect({:error, reason} = error, %{queue: queue} = s) do
Logger.error "Disconnected from Redis (#{host_for_logging(s)}): #{inspect reason}"
queue
|> :queue.to_list
|> Stream.map(&extract_client_from_queued_item/1)
|> Enum.map(&Connection.reply(&1, error))
# Backoff with 0 to churn through all the commands in the mailbox before
# reconnecting.
s = %{s | socket: nil, queue: :queue.new, tail: ""}
backoff_or_stop(s, 0, reason)
end
@doc false
def handle_call(operation, from, s)
def handle_call(_operation, _from, %{socket: nil} = s) do
{:reply, {:error, :closed}, s}
end
def handle_call({:command, args}, from, s) do
s
|> enqueue({:command, from})
|> send_noreply(Protocol.pack(args))
end
def handle_call({:pipeline, commands}, from, s) do
s
|> enqueue({:pipeline, from, length(commands)})
|> send_noreply(Enum.map(commands, &Protocol.pack/1))
end
@doc false
def handle_cast(operation, s)
def handle_cast(:stop, s) do
{:disconnect, :stop, s}
end
@doc false
def handle_info(msg, s)
def handle_info({:tcp, socket, data}, %{socket: socket} = s) do
reactivate_socket(s)
s = new_data(s, s.tail <> data)
{:noreply, s}
end
def handle_info({:tcp_closed, socket}, %{socket: socket} = s) do
{:disconnect, {:error, :tcp_closed}, s}
end
def handle_info({:tcp_error, socket, reason}, %{socket: socket} = s) do
{:disconnect, {:error, reason}, s}
end
## Helper functions
defp new_data(s, <<>>) do
%{s | tail: <<>>}
end
defp new_data(s, data) do
{from, parser, new_queue} = dequeue(s)
case parser.(data) do
{:ok, resp, rest} ->
Connection.reply(from, format_resp(resp))
s = %{s | queue: new_queue}
new_data(s, rest)
{:error, :incomplete} ->
%{s | tail: data}
end
end
defp dequeue(s) do
case :queue.out(s.queue) do
{{:value, {:command, from}}, new_queue} ->
{from, &Protocol.parse/1, new_queue}
{{:value, {:pipeline, from, ncommands}}, new_queue} ->
{from, &Protocol.parse_multi(&1, ncommands), new_queue}
{:empty, _} ->
raise "still got data but the queue is empty"
end
end
defp send_noreply(%{socket: socket} = s, data) do
case :gen_tcp.send(socket, data) do
:ok ->
{:noreply, s}
{:error, _reason} = error ->
{:disconnect, error, s}
end
end
# Enqueues `val` in the state.
defp enqueue(%{queue: queue} = s, val) do
%{s | queue: :queue.in(val, queue)}
end
# Extracts the TCP connection options (host, port and socket opts) from the
# given `opts`.
defp tcp_connection_opts(opts) do
host = to_char_list(Keyword.fetch!(opts, :host))
port = Keyword.fetch!(opts, :port)
socket_opts = @socket_opts ++ Keyword.fetch!(opts, :socket_opts)
timeout = opts[:timeout] || @default_timeout
{host, port, socket_opts, timeout}
end
# Reactivates the socket with `active: :once`.
defp reactivate_socket(%{socket: socket} = _s) do
:ok = :inet.setopts(socket, active: :once)
end
# Setups the `:buffer` option of the given socket.
defp setup_socket_buffers(socket) do
{:ok, [sndbuf: sndbuf, recbuf: recbuf, buffer: buffer]} =
:inet.getopts(socket, [:sndbuf, :recbuf, :buffer])
buffer = buffer |> max(sndbuf) |> max(recbuf)
:ok = :inet.setopts(socket, [buffer: buffer])
end
defp extract_client_from_queued_item({:command, from}), do: from
defp extract_client_from_queued_item({:pipeline, from, _}), do: from
defp format_resp(%Redix.Error{} = err), do: {:error, err}
defp format_resp(resp), do: {:ok, resp}
defp auth_and_select_db(s) do
case auth(s, s.opts[:password]) do
{:ok, s} ->
case select_db(s, s.opts[:database]) do
{:ok, s} ->
reactivate_socket(s)
{:ok, s}
o ->
o
end
o ->
o
end
end
defp auth(s, nil) do
{:ok, s}
end
defp auth(%{socket: socket} = s, password) when is_binary(password) do
case :gen_tcp.send(socket, Protocol.pack(["AUTH", password])) do
:ok ->
case wait_for_response(s) do
{:ok, "OK", s} ->
{:ok, s}
{:ok, error, s} ->
{:stop, error, s}
{:error, reason} ->
{:stop, reason, s}
end
{:error, reason} ->
{:stop, reason, s}
end
end
defp select_db(s, nil) do
{:ok, s}
end
defp select_db(%{socket: socket} = s, db) do
case :gen_tcp.send(socket, Protocol.pack(["SELECT", db])) do
:ok ->
case wait_for_response(s) do
{:ok, "OK", s} ->
{:ok, s}
{:ok, error, s} ->
{:stop, error, s}
{:error, reason} ->
{:stop, reason, s}
end
{:error, reason} ->
{:stop, reason, s}
end
end
defp wait_for_response(%{socket: socket} = s) do
case :gen_tcp.recv(socket, 0) do
{:ok, data} ->
data = s.tail <> data
case Protocol.parse(data) do
{:ok, value, rest} ->
{:ok, value, %{s | tail: rest}}
{:error, :incomplete} ->
wait_for_response(%{s | tail: data})
end
{:error, _} = err ->
err
end
end
defp host_for_logging(%{opts: opts} = _s) do
"#{opts[:host]}:#{opts[:port]}"
end
# If `info` is :backoff then this is a *reconnection* attempt, so if there's
# an error let's try to just reconnect after a backoff time (if we're under
# the max number of retries). If `info` is :init, then this is the first
# connection attempt so if it fails let's just die.
defp handle_connection_error(s, :init, reason),
do: {:stop, reason, s}
defp handle_connection_error(s, :backoff, reason),
do: backoff_or_stop(s, s.opts[:backoff], reason)
# This function is called every time we want to try and reconnect. It returns
# {:backoff, ...} if we're under the max number of allowed reconnection
# attempts (or if there's no such limit), {:stop, ...} otherwise.
defp backoff_or_stop(s, backoff, stop_reason) do
s = update_in(s.reconnection_attempts, &(&1 + 1))
if attempt_to_reconnect?(s) do
{:backoff, backoff, s}
else
{:stop, stop_reason, s}
end
end
defp attempt_to_reconnect?(%{opts: opts, reconnection_attempts: attempts}) do
max_attempts = opts[:max_reconnection_attempts]
is_nil(max_attempts) or (max_attempts > 0 and attempts <= max_attempts)
end
end