Current section
Files
Jump to
Current section
Files
src/eredis_client.erl
%%
%% eredis_client
%%
%% The client is implemented as a gen_server which keeps one socket
%% open to a single Redis instance. Users call us using the API in
%% eredis.erl.
%%
%% The client works like this:
%% * When starting up, we connect to Redis with the given connection
%% information, or fail.
%% * Users calls us using gen_server:call, we send the request to Redis,
%% add the calling process at the end of the queue and reply with
%% noreply. We are then free to handle new requests and may reply to
%% the user later.
%% * We receive data on the socket, we parse the response and reply to
%% the client at the front of the queue. If the parser does not have
%% enough data to parse the complete response, we will wait for more
%% data to arrive.
%% * For pipeline commands, we include the number of responses we are
%% waiting for in each element of the queue. Responses are queued until
%% we have all the responses we need and then reply with all of them.
%%
-module(eredis_client).
-behaviour(gen_server).
-include("eredis.hrl").
%% API
-export([start_link/6, stop/1, select_database/2]).
-export([do_sync_command/2]).
%% gen_server callbacks
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-record(state, {
host :: string() | undefined,
port :: integer() | undefined,
password :: binary() | undefined,
database :: binary() | undefined,
reconnect_sleep :: reconnect_sleep() | undefined,
connect_timeout :: integer() | undefined,
socket :: port() | undefined,
parser_state :: #pstate{} | undefined,
queue :: eredis_queue() | undefined
}).
%%
%% API
%%
-spec start_link(Host::list(),
Port::integer(),
Database::integer() | undefined,
Password::string(),
ReconnectSleep::reconnect_sleep(),
ConnectTimeout::integer() | undefined) ->
{ok, Pid::pid()} | {error, Reason::term()}.
start_link(Host, Port, Database, Password, ReconnectSleep, ConnectTimeout) ->
gen_server:start_link(?MODULE, [Host, Port, Database, Password,
ReconnectSleep, ConnectTimeout], []).
stop(Pid) ->
gen_server:call(Pid, stop).
%%====================================================================
%% gen_server callbacks
%%====================================================================
init([Host, Port, Database, Password, ReconnectSleep, ConnectTimeout]) ->
State = #state{host = Host,
port = Port,
database = read_database(Database),
password = list_to_binary(Password),
reconnect_sleep = ReconnectSleep,
connect_timeout = ConnectTimeout,
parser_state = eredis_parser:init(),
queue = queue:new()},
case connect(State) of
{ok, NewState} ->
{ok, NewState};
{error, Reason} ->
{stop, Reason}
end.
handle_call({request, Req}, From, State) ->
do_request(Req, From, State);
handle_call({pipeline, Pipeline}, From, State) ->
do_pipeline(Pipeline, From, State);
handle_call(stop, _From, State) ->
{stop, normal, ok, State};
handle_call(_Request, _From, State) ->
{reply, unknown_request, State}.
handle_cast({request, Req}, State) ->
case do_request(Req, undefined, State) of
{reply, _Reply, State1} ->
{noreply, State1};
{noreply, State1} ->
{noreply, State1}
end;
handle_cast({request, Req, Pid}, State) ->
case do_request(Req, Pid, State) of
{reply, Reply, State1} ->
safe_send(Pid, {response, Reply}),
{noreply, State1};
{noreply, State1} ->
{noreply, State1}
end;
handle_cast(_Msg, State) ->
{noreply, State}.
%% Receive data from socket, see handle_response/2. Match `Socket' to
%% enforce sanity.
handle_info({tcp, Socket, Bs}, #state{socket = Socket} = State) ->
ok = inet:setopts(Socket, [{active, once}]),
{noreply, handle_response(Bs, State)};
handle_info({tcp, Socket, _}, #state{socket = OurSocket} = State)
when OurSocket =/= Socket ->
%% Ignore tcp messages when the socket in message doesn't match
%% our state. In order to test behavior around receiving
%% tcp_closed message with clients waiting in queue, we send a
%% fake tcp_close message. This allows us to ignore messages that
%% arrive after that while we are reconnecting.
{noreply, State};
handle_info({tcp_error, _Socket, _Reason}, State) ->
%% This will be followed by a close
{noreply, State};
%% Socket got closed, for example by Redis terminating idle
%% clients. If desired, spawn of a new process which will try to reconnect and
%% notify us when Redis is ready. In the meantime, we can respond with
%% an error message to all our clients.
handle_info({tcp_closed, _Socket}, #state{reconnect_sleep = no_reconnect,
queue = Queue} = State) ->
reply_all({error, tcp_closed}, Queue),
%% If we aren't going to reconnect, then there is nothing else for
%% this process to do.
{stop, normal, State#state{socket = undefined}};
handle_info({tcp_closed, _Socket}, #state{queue = Queue} = State) ->
Self = self(),
spawn(fun() -> reconnect_loop(Self, State) end),
%% tell all of our clients what has happened.
reply_all({error, tcp_closed}, Queue),
%% Throw away the socket and the queue, as we will never get a
%% response to the requests sent on the old socket. The absence of
%% a socket is used to signal we are "down"
{noreply, State#state{socket = undefined, queue = queue:new()}};
%% Redis is ready to accept requests, the given Socket is a socket
%% already connected and authenticated.
handle_info({connection_ready, Socket}, #state{socket = undefined} = State) ->
{noreply, State#state{socket = Socket}};
%% eredis can be used in Poolboy, but it requires to support a simple API
%% that Poolboy uses to manage the connections.
handle_info(stop, State) ->
{stop, shutdown, State};
handle_info(_Info, State) ->
{stop, {unhandled_message, _Info}, State}.
terminate(_Reason, State) ->
case State#state.socket of
undefined -> ok;
Socket -> gen_tcp:close(Socket)
end,
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%%--------------------------------------------------------------------
%%% Internal functions
%%--------------------------------------------------------------------
-spec do_request(Req::iolist(), From::pid(), #state{}) ->
{noreply, #state{}} | {reply, Reply::any(), #state{}}.
%% @doc: Sends the given request to redis. If we do not have a
%% connection, returns error.
do_request(_Req, _From, #state{socket = undefined} = State) ->
{reply, {error, no_connection}, State};
do_request(Req, From, State) ->
case gen_tcp:send(State#state.socket, Req) of
ok ->
NewQueue = queue:in({1, From}, State#state.queue),
{noreply, State#state{queue = NewQueue}};
{error, Reason} ->
{reply, {error, Reason}, State}
end.
-spec do_pipeline(Pipeline::pipeline(), From::pid(), #state{}) ->
{noreply, #state{}} | {reply, Reply::any(), #state{}}.
%% @doc: Sends the entire pipeline to redis. If we do not have a
%% connection, returns error.
do_pipeline(_Pipeline, _From, #state{socket = undefined} = State) ->
{reply, {error, no_connection}, State};
do_pipeline(Pipeline, From, State) ->
case gen_tcp:send(State#state.socket, Pipeline) of
ok ->
NewQueue = queue:in({length(Pipeline), From, []}, State#state.queue),
{noreply, State#state{queue = NewQueue}};
{error, Reason} ->
{reply, {error, Reason}, State}
end.
-spec handle_response(Data::binary(), State::#state{}) -> NewState::#state{}.
%% @doc: Handle the response coming from Redis. This includes parsing
%% and replying to the correct client, handling partial responses,
%% handling too much data and handling continuations.
handle_response(Data, #state{parser_state = ParserState,
queue = Queue} = State) ->
case eredis_parser:parse(ParserState, Data) of
%% Got complete response, return value to client
{ReturnCode, Value, NewParserState} ->
NewQueue = reply({ReturnCode, Value}, Queue),
State#state{parser_state = NewParserState,
queue = NewQueue};
%% Got complete response, with extra data, reply to client and
%% recurse over the extra data
{ReturnCode, Value, Rest, NewParserState} ->
NewQueue = reply({ReturnCode, Value}, Queue),
handle_response(Rest, State#state{parser_state = NewParserState,
queue = NewQueue});
%% Parser needs more data, the parser state now contains the
%% continuation data and we will try calling parse again when
%% we have more data
{continue, NewParserState} ->
State#state{parser_state = NewParserState}
end.
%% @doc: Sends a value to the first client in queue. Returns the new
%% queue without this client. If we are still waiting for parts of a
%% pipelined request, push the reply to the the head of the queue and
%% wait for another reply from redis.
reply(Value, Queue) ->
case queue:out(Queue) of
{{value, {1, From}}, NewQueue} ->
safe_reply(From, Value),
NewQueue;
{{value, {1, From, Replies}}, NewQueue} ->
safe_reply(From, lists:reverse([Value | Replies])),
NewQueue;
{{value, {N, From, Replies}}, NewQueue} when N > 1 ->
queue:in_r({N - 1, From, [Value | Replies]}, NewQueue);
{empty, Queue} ->
%% Oops
error_logger:info_msg("Nothing in queue, but got value from parser~n"),
throw(empty_queue)
end.
%% @doc Send `Value' to each client in queue. Only useful for sending
%% an error message. Any in-progress reply data is ignored.
-spec reply_all(any(), eredis_queue()) -> ok.
reply_all(Value, Queue) ->
case queue:peek(Queue) of
empty ->
ok;
{value, Item} ->
safe_reply(receipient(Item), Value),
reply_all(Value, queue:drop(Queue))
end.
receipient({_, From}) ->
From;
receipient({_, From, _}) ->
From.
safe_reply(undefined, _Value) ->
ok;
safe_reply(Pid, Value) when is_pid(Pid) ->
safe_send(Pid, {response, Value});
safe_reply(From, Value) ->
gen_server:reply(From, Value).
safe_send(Pid, Value) ->
try erlang:send(Pid, Value)
catch
Err:Reason ->
error_logger:info_msg("Failed to send message to ~p with reason ~p~n", [Pid, {Err, Reason}])
end.
%% @doc: Helper for connecting to Redis, authenticating and selecting
%% the correct database. These commands are synchronous and if Redis
%% returns something we don't expect, we crash. Returns {ok, State} or
%% {SomeError, Reason}.
connect(State) ->
{ok, {AFamily, Addr}} = get_addr(State#state.host),
case gen_tcp:connect(Addr, State#state.port,
[AFamily | ?SOCKET_OPTS], State#state.connect_timeout) of
{ok, Socket} ->
case authenticate(Socket, State#state.password) of
ok ->
case select_database(Socket, State#state.database) of
ok ->
{ok, State#state{socket = Socket}};
{error, Reason} ->
{error, {select_error, Reason}}
end;
{error, Reason} ->
{error, {authentication_error, Reason}}
end;
{error, Reason} ->
{error, {connection_error, Reason}}
end.
get_addr(Hostname) ->
case inet:parse_address(Hostname) of
{ok, {_,_,_,_} = Addr} -> {ok, {inet, Addr}};
{ok, {_,_,_,_,_,_,_,_} = Addr} -> {ok, {inet6, Addr}};
{error, einval} ->
case inet:getaddr(Hostname, inet6) of
{error, _} ->
case inet:getaddr(Hostname, inet) of
{ok, Addr}-> {ok, {inet, Addr}};
{error, _} = Res -> Res
end;
{ok, Addr} -> {ok, {inet6, Addr}}
end
end.
select_database(_Socket, undefined) ->
ok;
select_database(_Socket, <<"0">>) ->
ok;
select_database(Socket, Database) ->
do_sync_command(Socket, ["SELECT", " ", Database, "\r\n"]).
authenticate(_Socket, <<>>) ->
ok;
authenticate(Socket, Password) ->
do_sync_command(Socket, ["AUTH", " \"", Password, "\"\r\n"]).
%% @doc: Executes the given command synchronously, expects Redis to
%% return "+OK\r\n", otherwise it will fail.
do_sync_command(Socket, Command) ->
ok = inet:setopts(Socket, [{active, false}]),
case gen_tcp:send(Socket, Command) of
ok ->
%% Hope there's nothing else coming down on the socket..
case gen_tcp:recv(Socket, 0, ?RECV_TIMEOUT) of
{ok, <<"+OK\r\n">>} ->
ok = inet:setopts(Socket, [{active, once}]),
ok;
Other ->
{error, {unexpected_data, Other}}
end;
{error, Reason} ->
{error, Reason}
end.
%% @doc: Loop until a connection can be established, this includes
%% successfully issuing the auth and select calls. When we have a
%% connection, give the socket to the redis client.
reconnect_loop(Client, #state{reconnect_sleep = ReconnectSleep} = State) ->
case catch(connect(State)) of
{ok, #state{socket = Socket}} ->
Client ! {connection_ready, Socket},
gen_tcp:controlling_process(Socket, Client),
Msgs = get_all_messages([]),
[Client ! M || M <- Msgs];
{error, _Reason} ->
timer:sleep(ReconnectSleep),
reconnect_loop(Client, State);
%% Something bad happened when connecting, like Redis might be
%% loading the dataset and we got something other than 'OK' in
%% auth or select
_ ->
timer:sleep(ReconnectSleep),
reconnect_loop(Client, State)
end.
read_database(undefined) ->
undefined;
read_database(Database) when is_integer(Database) ->
list_to_binary(integer_to_list(Database)).
get_all_messages(Acc) ->
receive
M ->
[M | Acc]
after 0 ->
lists:reverse(Acc)
end.