465 lines
14 KiB
Elixir
465 lines
14 KiB
Elixir
defmodule Postgrex.SimpleConnection do
|
|
@moduledoc ~S"""
|
|
A generic connection suitable for simple queries and pubsub functionality.
|
|
|
|
On its own, a SimpleConnection server only maintains a connection. To execute
|
|
queries, process results, or relay notices you must implement a callback module
|
|
with the SimpleConnection behaviour.
|
|
|
|
## Example
|
|
|
|
The SimpleConnection behaviour abstracts common client/server interactions,
|
|
along with optional mechanisms for running queries or relaying notifications.
|
|
|
|
Let's start with a minimal callback module that executes a query and relays
|
|
the result back to the caller.
|
|
|
|
defmodule MyConnection do
|
|
@behaviour Postgrex.SimpleConnection
|
|
|
|
@impl true
|
|
def init(_args) do
|
|
{:ok, %{from: nil}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:query, query}, from, state) do
|
|
{:query, query, %{state | from: from}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_result(results, state) when is_list(results) do
|
|
SimpleConnection.reply(state.from, results)
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
@impl true
|
|
def handle_result(%Postgrex.Error{} = error, state) do
|
|
SimpleConnection.reply(state.from, error)
|
|
|
|
{:noreply, state}
|
|
end
|
|
end
|
|
|
|
# Start the connection
|
|
{:ok, pid} = SimpleConnection.start_link(MyConnection, [], database: "demo")
|
|
|
|
# Execute a literal query
|
|
SimpleConnection.call(pid, {:query, "SELECT 1"})
|
|
# => %Postgrex.Result{rows: [["1"]]}
|
|
|
|
We start a connection by passing the callback module, callback options, and
|
|
server options to `SimpleConnection.start_link/3`. The `init/1` function
|
|
receives any callback options and returns the callback state.
|
|
|
|
Queries are sent through `SimpleConnection.call/2`, executed on the server,
|
|
and the result is handed off to `handle_result/2`. At that point the callback
|
|
can process the result before replying back to the caller with
|
|
`SimpleConnection.reply/2`.
|
|
|
|
## Building a PubSub Connection
|
|
|
|
With the `notify/3` callback you can also build a pubsub server on top of
|
|
`LISTEN/NOTIFY`. Here's a naive pubsub implementation:
|
|
|
|
defmodule MyPubSub do
|
|
@behaviour Postgrex.SimpleConnection
|
|
|
|
defstruct [:from, listeners: %{}]
|
|
|
|
@impl true
|
|
def init(args) do
|
|
{:ok, struct!(__MODULE__, args)}
|
|
end
|
|
|
|
@impl true
|
|
def notify(channel, payload, state) do
|
|
for pid <- state.listeners[channel] do
|
|
send(pid, {:notice, channel, payload})
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_call({:listen, channel}, {pid, _} = from, state) do
|
|
listeners = Map.update(state.listeners, channel, [pid], &[pid | &1])
|
|
|
|
{:query, ~s(LISTEN "#{channel}"), %{state | from: from, listeners: listeners}}
|
|
end
|
|
|
|
def handle_call({:query, query}, from, state) do
|
|
{:query, query, %{state | from: from}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_result(_results, state) do
|
|
SimpleConnection.reply(state.from, :ok)
|
|
|
|
{:noreply, %{state | from: nil}}
|
|
end
|
|
end
|
|
|
|
# Start the connection
|
|
{:ok, pid} = SimpleConnection.start_link(MyPubSub, [], database: "demo")
|
|
|
|
# Start listening to the "demo" channel
|
|
SimpleConnection.call(pid, {:listen, "demo"})
|
|
# => %Postgrex.Result{command: :listen}
|
|
|
|
# Notify all listeners
|
|
SimpleConnection.call(pid, {:query, ~s(NOTIFY "demo", 'hello')})
|
|
# => %Postgrex.Result{command: :notify}
|
|
|
|
# Check the inbox to see the notice message
|
|
flush()
|
|
# => {:notice, "demo", "hello"}
|
|
|
|
See `Postgrex.Notifications` for a more complex implementation that can
|
|
unlisten, handle process exits, and persist across reconnection.
|
|
|
|
## Name registration
|
|
|
|
A `Postgrex.SimpleConnection` is bound to the same name registration rules as a
|
|
`GenServer`. Read more about them in the `GenServer` docs.
|
|
"""
|
|
|
|
@behaviour :gen_statem
|
|
|
|
require Logger
|
|
|
|
alias Postgrex.Protocol
|
|
|
|
@doc false
|
|
defstruct idle_interval: 5000,
|
|
protocol: nil,
|
|
auto_reconnect: false,
|
|
reconnect_backoff: 500,
|
|
state: nil
|
|
|
|
## PUBLIC API ##
|
|
|
|
@type query :: iodata
|
|
@type state :: term
|
|
|
|
@typedoc since: "0.17.0"
|
|
@type from :: {pid, term}
|
|
|
|
@doc """
|
|
Callback for process initialization.
|
|
|
|
This is called once and before the Postgrex connection is established.
|
|
"""
|
|
@callback init(term) :: {:ok, state}
|
|
|
|
@doc """
|
|
Callback for processing or relaying pubsub notifications.
|
|
"""
|
|
@callback notify(binary, binary, state) :: :ok
|
|
|
|
@doc """
|
|
Invoked after connecting or reconnecting.
|
|
|
|
This may be called multiple times if `:auto_reconnect` is true.
|
|
"""
|
|
@callback handle_connect(state) :: {:noreply, state} | {:query, query, state}
|
|
|
|
@doc """
|
|
Invoked after disconnection.
|
|
|
|
This is invoked regardless of the `:auto_reconnect` option.
|
|
"""
|
|
@callback handle_disconnect(state) :: {:noreply, state}
|
|
|
|
@doc """
|
|
Callback for `SimpleConnection.call/3`.
|
|
|
|
Replies must be sent with `SimpleConnection.reply/2`.
|
|
"""
|
|
@callback handle_call(term, from, state) ::
|
|
{:noreply, state} | {:query, query, state}
|
|
|
|
@doc """
|
|
Callback for `Kernel.send/2`.
|
|
"""
|
|
@callback handle_info(term, state) :: {:noreply, state} | {:query, query, state}
|
|
|
|
@doc """
|
|
Callback for processing or relaying queries executed via `{:query, query, state}`.
|
|
|
|
Either a list of successful query results or an error will be passed to this callback.
|
|
A list is passed because the simple query protocol allows multiple commands to be
|
|
issued in a single query.
|
|
"""
|
|
@callback handle_result([Postgrex.Result.t()] | Postgrex.Error.t(), state) ::
|
|
{:noreply, state}
|
|
|
|
@optional_callbacks handle_call: 3,
|
|
handle_connect: 1,
|
|
handle_disconnect: 1,
|
|
handle_info: 2,
|
|
handle_result: 2
|
|
|
|
@doc """
|
|
Replies to the given client.
|
|
|
|
Wrapper for `:gen_statem.reply/2`.
|
|
"""
|
|
def reply({caller_pid, from} = _from, reply) when is_pid(caller_pid) do
|
|
:gen_statem.reply(from, reply)
|
|
end
|
|
|
|
@doc """
|
|
Calls the given server.
|
|
|
|
Wrapper for `:gen_statem.call/3`.
|
|
"""
|
|
def call(server, message, timeout \\ 5000) do
|
|
with {__MODULE__, reason} <- :gen_statem.call(server, {message, self()}, timeout) do
|
|
exit({reason, {__MODULE__, :call, [server, message, timeout]}})
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def child_spec(opts) do
|
|
%{id: __MODULE__, start: {__MODULE__, :start_link, opts}}
|
|
end
|
|
|
|
@doc """
|
|
Start the connection process and connect to Postgres.
|
|
|
|
The options that this function accepts are the same as those accepted by
|
|
`Postgrex.start_link/1`, as well as the extra options `:sync_connect`,
|
|
`:auto_reconnect`, `:reconnect_backoff`, and `:configure`.
|
|
|
|
## Options
|
|
|
|
* `:auto_reconnect` - automatically attempt to reconnect to the database
|
|
in event of a disconnection. See the
|
|
[note about async connect and auto-reconnects](#module-async-connect-and-auto-reconnects)
|
|
above. Defaults to `false`, which means the process terminates.
|
|
|
|
* `:configure` - A function to run before every connect attempt to dynamically
|
|
configure the options as a `{module, function, args}`, where the current
|
|
options will prepended to `args`. Defaults to `nil`.
|
|
|
|
* `:idle_interval` - while also accepted on `Postgrex.start_link/1`, it has
|
|
a default of `5000ms` in `Postgrex.SimpleConnection` (instead of 1000ms).
|
|
|
|
* `:reconnect_backoff` - time (in ms) between reconnection attempts when
|
|
`auto_reconnect` is enabled. Defaults to `500`.
|
|
|
|
* `:sync_connect` - controls if the connection should be established on boot
|
|
or asynchronously right after boot. Defaults to `true`.
|
|
"""
|
|
@spec start_link(module, term, Keyword.t()) :: {:ok, pid} | {:error, Postgrex.Error.t() | term}
|
|
def start_link(module, args, opts) do
|
|
{name, opts} = Keyword.pop(opts, :name)
|
|
|
|
opts = Keyword.put_new(opts, :sync_connect, true)
|
|
connection_opts = Postgrex.Utils.default_opts(opts)
|
|
start_args = {module, args, connection_opts}
|
|
|
|
case name do
|
|
nil ->
|
|
:gen_statem.start_link(__MODULE__, start_args, [])
|
|
|
|
atom when is_atom(atom) ->
|
|
:gen_statem.start_link({:local, atom}, __MODULE__, start_args, [])
|
|
|
|
{:global, _term} = tuple ->
|
|
:gen_statem.start_link(tuple, __MODULE__, start_args, [])
|
|
|
|
{:via, via_module, _term} = tuple when is_atom(via_module) ->
|
|
:gen_statem.start_link(tuple, __MODULE__, start_args, [])
|
|
|
|
other ->
|
|
raise ArgumentError, """
|
|
expected :name option to be one of the following:
|
|
|
|
* nil
|
|
* atom
|
|
* {:global, term}
|
|
* {:via, module, term}
|
|
|
|
Got: #{inspect(other)}
|
|
"""
|
|
end
|
|
end
|
|
|
|
## CALLBACKS ##
|
|
|
|
@state :no_state
|
|
|
|
@doc false
|
|
@impl :gen_statem
|
|
def callback_mode, do: :handle_event_function
|
|
|
|
@doc false
|
|
@impl :gen_statem
|
|
def init({mod, args, opts}) do
|
|
case mod.init(args) do
|
|
{:ok, mod_state} ->
|
|
idle_timeout = opts[:idle_timeout]
|
|
|
|
if idle_timeout do
|
|
Logger.warning(
|
|
":idle_timeout in Postgrex.SimpleConnection is deprecated, " <>
|
|
"please use :idle_interval instead"
|
|
)
|
|
end
|
|
|
|
{idle_interval, opts} = Keyword.pop(opts, :idle_interval, idle_timeout || 5000)
|
|
{auto_reconnect, opts} = Keyword.pop(opts, :auto_reconnect, false)
|
|
{reconnect_backoff, opts} = Keyword.pop(opts, :reconnect_backoff, 500)
|
|
|
|
state = %__MODULE__{
|
|
idle_interval: idle_interval,
|
|
auto_reconnect: auto_reconnect,
|
|
reconnect_backoff: reconnect_backoff,
|
|
state: {mod, mod_state}
|
|
}
|
|
|
|
put_opts(mod, opts)
|
|
|
|
if opts[:sync_connect] do
|
|
case handle_event(:internal, {:connect, :init}, @state, state) do
|
|
{:keep_state, state} -> {:ok, @state, state}
|
|
{:keep_state, state, actions} -> {:ok, @state, state, actions}
|
|
{:stop, _reason, _state} = stop -> stop
|
|
end
|
|
else
|
|
{:ok, @state, state, {:next_event, :internal, {:connect, :init}}}
|
|
end
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
@impl :gen_statem
|
|
def handle_event(type, content, statem_state, state)
|
|
|
|
def handle_event(:internal, {:connect, _}, @state, %{state: {mod, mod_state}} = state) do
|
|
opts =
|
|
case Keyword.get(opts(mod), :configure) do
|
|
{module, fun, args} -> apply(module, fun, [opts(mod) | args])
|
|
fun when is_function(fun, 1) -> fun.(opts(mod))
|
|
nil -> opts(mod)
|
|
end
|
|
|
|
case Protocol.connect(opts) do
|
|
{:ok, protocol} ->
|
|
state = %{state | protocol: protocol}
|
|
|
|
with {:keep_state, state, _} <- maybe_handle(mod, :handle_connect, [mod_state], state) do
|
|
{:keep_state, state}
|
|
end
|
|
|
|
{:error, reason} ->
|
|
if state.auto_reconnect do
|
|
{:keep_state, state, {{:timeout, :backoff}, state.reconnect_backoff, nil}}
|
|
else
|
|
{:stop, reason, state}
|
|
end
|
|
end
|
|
end
|
|
|
|
def handle_event({:timeout, :backoff}, nil, @state, state) do
|
|
{:keep_state, state, {:next_event, :internal, {:connect, :reconnect}}}
|
|
end
|
|
|
|
def handle_event({:call, from}, {msg, caller_pid}, @state, %{state: {mod, mod_state}} = state) do
|
|
# We have to do a hack here to carry the actual caller PID over to the handle_call/3
|
|
# callback, because gen_statem uses a proxy process to do calls with timeout != :infinity.
|
|
# This results in the caller PID not being the same as the PID in the "from" tuple,
|
|
# so things like Postgrex.Notifications cannot use that "from"'s PID to register
|
|
# notification handlers. This approach is paired with reconstructing the proper
|
|
# "from" tuple in the reply/2 function in this module.
|
|
callback_from = {caller_pid, from}
|
|
handle(mod, :handle_call, [msg, callback_from, mod_state], from, state)
|
|
end
|
|
|
|
def handle_event(:timeout, nil, @state, %{protocol: protocol} = state) do
|
|
case Protocol.ping(protocol) do
|
|
{:ok, protocol} ->
|
|
{:keep_state, %{state | protocol: protocol}, {:timeout, state.idle_interval, nil}}
|
|
|
|
{error, reason, protocol} ->
|
|
reconnect_or_stop(error, reason, protocol, state)
|
|
end
|
|
end
|
|
|
|
def handle_event(:info, msg, @state, %{protocol: protocol, state: {mod, mod_state}} = state) do
|
|
opts = [notify: &mod.notify(&1, &2, mod_state)]
|
|
|
|
case Protocol.handle_info(msg, opts, protocol) do
|
|
{:ok, protocol} ->
|
|
{:keep_state, %{state | protocol: protocol}, {:timeout, state.idle_interval, nil}}
|
|
|
|
{:unknown, protocol} ->
|
|
maybe_handle(mod, :handle_info, [msg, mod_state], %{state | protocol: protocol})
|
|
|
|
{error, reason, protocol} ->
|
|
reconnect_or_stop(error, reason, protocol, state)
|
|
end
|
|
end
|
|
|
|
def handle_event(:info, msg, @state, %{state: {mod, mod_state}} = state) do
|
|
maybe_handle(mod, :handle_info, [msg, mod_state], state)
|
|
end
|
|
|
|
@doc false
|
|
@impl :gen_statem
|
|
def format_status(_opts, [_pdict, @state, state]) do
|
|
state
|
|
end
|
|
|
|
## Helpers
|
|
|
|
defp maybe_handle(mod, fun, args, state) do
|
|
if function_exported?(mod, fun, length(args)) do
|
|
handle(mod, fun, args, nil, state)
|
|
else
|
|
{:keep_state, state, {:timeout, state.idle_interval, nil}}
|
|
end
|
|
end
|
|
|
|
defp handle(mod, fun, args, from, state) do
|
|
case apply(mod, fun, args) do
|
|
{:noreply, mod_state} ->
|
|
{:keep_state, %{state | state: {mod, mod_state}}, {:timeout, state.idle_interval, nil}}
|
|
|
|
{:query, query, mod_state} ->
|
|
opts = [notify: &mod.notify(&1, &2, mod_state)]
|
|
|
|
state = %{state | state: {mod, mod_state}}
|
|
|
|
with {:ok, results, protocol} <- Protocol.handle_simple(query, opts, state.protocol),
|
|
{:ok, protocol} <- Protocol.checkin(protocol) do
|
|
state = %{state | protocol: protocol}
|
|
|
|
handle(mod, :handle_result, [results, mod_state], from, state)
|
|
else
|
|
{:error, %Postgrex.Error{} = error, protocol} ->
|
|
handle(mod, :handle_result, [error, mod_state], from, %{state | protocol: protocol})
|
|
|
|
{:disconnect, reason, protocol} ->
|
|
reconnect_or_stop(:disconnect, reason, protocol, state)
|
|
end
|
|
end
|
|
end
|
|
|
|
defp reconnect_or_stop(error, reason, protocol, %{state: {mod, mod_state}} = state)
|
|
when error in [:error, :disconnect] do
|
|
{:keep_state, state, _actions} = maybe_handle(mod, :handle_disconnect, [mod_state], state)
|
|
|
|
if state.auto_reconnect do
|
|
{:keep_state, state, {:next_event, :internal, {:connect, :reconnect}}}
|
|
else
|
|
{:stop, reason, %{state | protocol: protocol}}
|
|
end
|
|
end
|
|
|
|
defp opts(mod), do: Process.get(mod)
|
|
|
|
defp put_opts(mod, opts), do: Process.put(mod, opts)
|
|
end
|