Packages
electric
1.7.6
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.10
1.6.9
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.16
1.4.16-beta-1
1.4.15
1.4.14
1.4.13
1.4.12
1.4.11
1.4.10
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.4
1.3.3
1.3.2
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.1.14
1.1.13
1.1.12
1.1.11
1.1.10
1.1.9
1.1.8
1.1.7
1.1.6
retired
1.1.5
retired
1.1.4
retired
1.1.3
retired
1.1.2
1.1.1
1.1.0
1.0.24
1.0.23
1.0.22
1.0.21
1.0.20
1.0.19
1.0.18
1.0.17
1.0.15
1.0.13
1.0.12
1.0.11
1.0.10
1.0.9
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
1.0.0-beta.23
1.0.0-beta.22
1.0.0-beta.20
1.0.0-beta.19
1.0.0-beta.18
1.0.0-beta.17
1.0.0-beta.16
1.0.0-beta.15
1.0.0-beta.14
1.0.0-beta.13
1.0.0-beta.12
1.0.0-beta.11
1.0.0-beta.10
1.0.0-beta.9
1.0.0-beta.8
1.0.0-beta.7
1.0.0-beta.6
1.0.0-beta.5
1.0.0-beta.4
1.0.0-beta.3
1.0.0-beta.2
1.0.0-beta.1
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.5.2
0.4.4
Postgres sync engine. Sync little subsets of your Postgres data into local apps and services.
Current section
Files
Jump to
Current section
Files
lib/electric/postgres/replication_connection.ex
# Derived from Postgrex.ReplicationConnection v0.22.0
# https://github.com/elixir-ecto/postgrex
#
# Copyright 2013 Eric Meadows-Jönsson and contributors
# Licensed under the Apache License, Version 2.0
# (https://www.apache.org/licenses/LICENSE-2.0)
#
# Modifications by Electric DB Ltd:
# - Added socket pause/resume for backpressure (paused, buffered_copies,
# buffered_sock_msg, noreply_and_pause/noreply_and_resume return types).
#
# This module depends on Postgrex.Protocol internals (handle_copy_recv,
# handle_copy_send, activate, buffer state). Review compatibility when
# upgrading Postgrex beyond 0.22.x.
defmodule Electric.Postgres.ReplicationConnection do
@moduledoc """
Vendored and extended version of `Postgrex.ReplicationConnection`.
Adds socket pause/resume support so that the callback module can apply
backpressure (stop reading WAL data) while remaining responsive to
non-socket messages such as keepalive timers.
## Extensions over upstream
Two new return types are available from `handle_data/2` and `handle_info/2`:
* `{:noreply_and_pause, ack, state}` — send any ack messages to PostgreSQL,
then pause socket reads. The gen_statem will still process `handle_info`
and `handle_call` messages (e.g. keepalive timers), but no further
`handle_data` callbacks will fire until the socket is resumed.
* `{:noreply_and_resume, ack, state}` — send ack messages, resume socket
reads, and process any buffered data that arrived while paused.
When paused, TCP/SSL data that arrives on the socket is buffered (at most
one Erlang message, since the socket uses `{active, :once}`). This provides
natural TCP-level backpressure: once the kernel receive buffer fills,
PostgreSQL's walsender blocks on write.
The callback module is expected to send periodic `StandbyStatusUpdate`
messages via `{:noreply, [encoded_msg], state}` from a `handle_info`
timer callback to prevent PostgreSQL's `wal_sender_timeout` from firing.
"""
require Logger
import Bitwise
alias Postgrex.Protocol
@behaviour :gen_statem
@doc false
defstruct protocol: nil,
state: nil,
auto_reconnect: false,
reconnect_backoff: 500,
streaming: nil,
# Pause/resume extensions
paused: false,
buffered_copies: [],
buffered_sock_msg: nil
## PUBLIC API ##
@type server :: :gen_statem.server_ref()
@type state :: term
@type ack :: iodata
@type query :: iodata
@type reason :: String.t()
@type stream_opts :: [max_messages: pos_integer]
@query_timeout :infinity
@type query_opts :: [timeout: timeout]
@max_lsn_component_size 8
@max_uint64 18_446_744_073_709_551_615
@max_messages 500
@callback init(term) :: {:ok, state}
@callback handle_connect(state) ::
{:noreply, state}
| {:noreply, ack, state}
| {:query, query, state}
| {:query, query, query_opts, state}
| {:stream, query, stream_opts, state}
| {:disconnect, reason}
@callback handle_disconnect(state) :: {:noreply, state}
@callback handle_data(binary | :done, state) ::
{:noreply, state}
| {:noreply, ack, state}
| {:noreply_and_pause, ack, state}
| {:query, query, state}
| {:query, query, query_opts, state}
| {:stream, query, stream_opts, state}
| {:disconnect, reason}
@callback handle_info(term, state) ::
{:noreply, state}
| {:noreply, ack, state}
| {:noreply_and_resume, ack, state}
| {:query, query, state}
| {:query, query, query_opts, state}
| {:stream, query, stream_opts, state}
| {:disconnect, reason}
@callback handle_call(term, :gen_statem.from(), state) ::
{:noreply, state}
| {:noreply, ack, state}
| {:query, query, state}
| {:query, query, query_opts, state}
| {:stream, query, stream_opts, state}
| {:disconnect, reason}
@callback handle_result([Postgrex.Result.t()] | Postgrex.Error.t(), state) ::
{:noreply, state}
| {:noreply, ack, state}
| {:query, query, state}
| {:query, query, query_opts, state}
| {:stream, query, stream_opts, state}
| {:disconnect, reason}
@optional_callbacks handle_call: 3,
handle_connect: 1,
handle_data: 2,
handle_disconnect: 1,
handle_info: 2,
handle_result: 2
defdelegate reply(client, reply), to: :gen_statem
def call(server, message, timeout \\ 5000) do
with {__MODULE__, reason} <- :gen_statem.call(server, message, timeout) do
exit({reason, {__MODULE__, :call, [server, message, timeout]}})
end
end
@doc false
defmacro __using__(opts) do
quote location: :keep, bind_quoted: [opts: opts] do
@behaviour Electric.Postgres.ReplicationConnection
unless Module.has_attribute?(__MODULE__, :doc) do
@doc """
Returns a specification to start this module under a supervisor.
See `Supervisor`.
"""
end
def child_spec(init_arg) do
default = %{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]}
}
Supervisor.child_spec(default, unquote(Macro.escape(opts)))
end
defoverridable child_spec: 1
end
end
@spec start_link(module(), term(), Keyword.t()) ::
{:ok, pid} | {:error, Postgrex.Error.t() | term}
def start_link(module, arg, 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, arg, 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
@spec encode_lsn(integer) :: {:ok, String.t()} | :error
def encode_lsn(lsn) when is_integer(lsn) do
if 0 <= lsn and lsn <= @max_uint64 do
<<file_id::32, offset::32>> = <<lsn::64>>
{:ok, Integer.to_string(file_id, 16) <> "/" <> Integer.to_string(offset, 16)}
else
:error
end
end
@spec decode_lsn(String.t()) :: {:ok, integer} | :error
def decode_lsn(lsn) when is_binary(lsn) do
with [file_id, offset] <- :binary.split(lsn, "/"),
true <- byte_size(file_id) <= @max_lsn_component_size,
true <- byte_size(offset) <= @max_lsn_component_size,
{file_id, ""} when file_id >= 0 <- Integer.parse(file_id, 16),
{offset, ""} when offset >= 0 <- Integer.parse(offset, 16) do
{:ok, file_id <<< 32 ||| offset}
else
_ -> :error
end
end
# Guard for matching socket messages from either :gen_tcp or :ssl.
defguardp is_socket_msg(msg)
when is_tuple(msg) and
elem(msg, 0) in [:tcp, :tcp_closed, :tcp_error, :ssl, :ssl_closed, :ssl_error]
## CALLBACKS ##
@state :no_state
@doc false
@impl :gen_statem
def callback_mode, do: :handle_event_function
@doc false
@impl :gen_statem
def init({mod, arg, opts}) do
case mod.init(arg) do
{:ok, mod_state} ->
opts =
Keyword.update(
opts,
:parameters,
[replication: "database"],
&Keyword.put_new(&1, :replication, "database")
)
{auto_reconnect, opts} = Keyword.pop(opts, :auto_reconnect, false)
{reconnect_backoff, opts} = Keyword.pop(opts, :reconnect_backoff, 500)
state = %__MODULE__{
auto_reconnect: auto_reconnect,
reconnect_backoff: reconnect_backoff,
state: {mod, mod_state}
}
put_opts(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, reason}
end
else
{:ok, @state, state, {:next_event, :internal, {:connect, :init}}}
end
end
end
@doc false
@impl :gen_statem
def handle_event(type, content, state, s)
def handle_event({:timeout, :backoff}, nil, @state, s) do
{:keep_state, s, {:next_event, :internal, {:connect, :backoff}}}
end
def handle_event(:internal, {:connect, :reconnect}, @state, %{protocol: protocol} = state)
when protocol != nil do
Protocol.disconnect(:reconnect, protocol)
{:keep_state, %{state | protocol: nil}, {:next_event, :internal, {:connect, :init}}}
end
def handle_event(:internal, {:connect, _info}, @state, %{state: {mod, mod_state}} = s) do
case Protocol.connect(opts()) do
{:ok, protocol} ->
maybe_handle(mod, :handle_connect, [mod_state], %{s | protocol: protocol})
{:error, reason} ->
Logger.error(
"#{inspect(pid_or_name())} (#{inspect(mod)}) failed to connect to Postgres: #{Exception.format(:error, reason)}"
)
if s.auto_reconnect do
{:keep_state, s, {{:timeout, :backoff}, s.reconnect_backoff, nil}}
else
{:stop, reason, s}
end
end
end
def handle_event({:call, from}, msg, @state, %{state: {mod, mod_state}} = s) do
handle(mod, :handle_call, [msg, from, mod_state], from, s)
end
# When paused, buffer socket messages instead of processing them.
def handle_event(:info, msg, @state, %{paused: true, buffered_sock_msg: nil} = s)
when is_socket_msg(msg) do
{:keep_state, %{s | buffered_sock_msg: msg}}
end
# Second socket message while paused — shouldn't happen with {active, :once}
# but handle defensively in case of future OTP behavior changes.
def handle_event(:info, msg, @state, %{paused: true} = s)
when is_socket_msg(msg) do
Logger.warning(
"Unexpected second socket message while paused (#{elem(msg, 0)}). " <>
"This should not happen with {active, :once}."
)
{:keep_state, s}
end
# Normal (unpaused) socket message processing.
def handle_event(:info, msg, @state, %{protocol: protocol, streaming: streaming} = s) do
case Protocol.handle_copy_recv(msg, streaming, protocol) do
{:ok, copies, protocol} ->
handle_data(copies, %{s | protocol: protocol})
:unknown ->
%{state: {mod, mod_state}} = s
maybe_handle(mod, :handle_info, [msg, mod_state], s)
{error, reason, protocol} ->
reconnect_or_stop(error, reason, protocol, s)
end
end
## Helpers
defp handle_data([], s), do: {:keep_state, s}
defp handle_data([:copy_done | copies], %{state: {mod, mod_state}} = s) do
with {:keep_state, s} <-
handle(mod, :handle_data, [:done, mod_state], nil, %{s | streaming: nil}) do
handle_data(copies, s)
end
end
defp handle_data([copy | copies], %{state: {mod, mod_state}} = s) do
case handle(mod, :handle_data, [copy, mod_state], nil, s) do
{:keep_state, s} ->
handle_data(copies, s)
{:keep_state_and_pause, s} ->
# Callback requested pause — store remaining copies and stop processing.
{:keep_state, %{s | paused: true, buffered_copies: copies}}
other ->
other
end
end
defp maybe_handle(mod, fun, args, s) do
if function_exported?(mod, fun, length(args)) do
handle(mod, fun, args, nil, s)
else
{:keep_state, s}
end
end
defp handle(mod, fun, args, from, %{streaming: streaming} = s) do
case apply(mod, fun, args) do
{:noreply, mod_state} ->
{:keep_state, %{s | state: {mod, mod_state}}}
{:noreply, replies, mod_state} ->
s = %{s | state: {mod, mod_state}}
case Protocol.handle_copy_send(replies, s.protocol) do
:ok -> {:keep_state, s}
{error, reason, protocol} -> reconnect_or_stop(error, reason, protocol, s)
end
{:noreply_and_pause, replies, mod_state} ->
s = %{s | state: {mod, mod_state}}
case Protocol.handle_copy_send(replies, s.protocol) do
:ok -> {:keep_state_and_pause, s}
{error, reason, protocol} -> reconnect_or_stop(error, reason, protocol, s)
end
{:noreply_and_resume, replies, mod_state} ->
s = %{s | state: {mod, mod_state}, paused: false}
case Protocol.handle_copy_send(replies, s.protocol) do
:ok ->
resume(s)
{error, reason, protocol} ->
reconnect_or_stop(error, reason, protocol, s)
end
{:stream, query, opts, mod_state} when streaming == nil ->
s = %{s | state: {mod, mod_state}}
max_messages = opts[:max_messages] || @max_messages
with {:ok, protocol} <- Protocol.handle_streaming(query, s.protocol),
{:ok, protocol} <- Protocol.checkin(protocol) do
{:keep_state, %{s | protocol: protocol, streaming: max_messages}}
else
{error_or_disconnect, reason, protocol} ->
reconnect_or_stop(error_or_disconnect, reason, protocol, s)
end
{:stream, _query, _opts, mod_state} ->
stream_in_progress(:stream, mod, mod_state, from, s)
{:query, query, mod_state} when streaming == nil ->
handle_query(query, mod, from, s, mod_state, timeout: @query_timeout)
{:query, query, opts, mod_state} when streaming == nil ->
handle_query(query, mod, from, s, mod_state, opts)
{:query, _query, mod_state} ->
stream_in_progress(:query, mod, mod_state, from, s)
{:query, _query, _opts, mod_state} ->
stream_in_progress(:query, mod, mod_state, from, s)
{:disconnect, reason} ->
reconnect_or_stop(:disconnect, reason, s.protocol, s)
end
end
# Process buffered copies and then any buffered socket message.
defp resume(%{buffered_copies: copies, buffered_sock_msg: sock_msg} = s) do
s = %{s | buffered_copies: [], buffered_sock_msg: nil}
case handle_data(copies, s) do
{:keep_state, s} when sock_msg != nil ->
# Re-inject the buffered socket message for normal processing.
send(self(), sock_msg)
{:keep_state, s}
{:keep_state_and_pause, s} ->
# Callback paused again while processing remaining copies.
# Re-attach the socket message for when we resume next time.
{:keep_state, %{s | buffered_sock_msg: sock_msg}}
other ->
other
end
end
defp handle_query(query, mod, from, s, mod_state, opts) do
case Protocol.handle_simple(query, opts, s.protocol) do
{:ok, results, protocol} when is_list(results) ->
handle(mod, :handle_result, [results, mod_state], from, %{s | protocol: protocol})
{:error, %Postgrex.Error{} = error, protocol} ->
handle(mod, :handle_result, [error, mod_state], from, %{s | protocol: protocol})
{:disconnect, reason, protocol} ->
reconnect_or_stop(:disconnect, reason, protocol, %{s | state: {mod, mod_state}})
end
end
defp stream_in_progress(command, mod, mod_state, from, s) do
Logger.warning("received #{command} while stream is already in progress")
from && reply(from, {__MODULE__, :stream_in_progress})
{:keep_state, %{s | state: {mod, mod_state}}}
end
defp reconnect_or_stop(error, reason, protocol, %{auto_reconnect: false} = s)
when error in [:error, :disconnect] do
%{state: {mod, mod_state}} = s
{:keep_state, s} =
maybe_handle(mod, :handle_disconnect, [mod_state], %{s | protocol: protocol})
{:stop, reason, s}
end
defp reconnect_or_stop(error, reason, _protocol, %{auto_reconnect: true} = s)
when error in [:error, :disconnect] do
%{state: {mod, mod_state}} = s
Logger.error(
"#{inspect(pid_or_name())} (#{inspect(mod)}) is reconnecting due to reason: #{Exception.format(:error, reason)}"
)
{:keep_state, s} = maybe_handle(mod, :handle_disconnect, [mod_state], s)
{:keep_state,
%{s | streaming: nil, paused: false, buffered_copies: [], buffered_sock_msg: nil},
{:next_event, :internal, {:connect, :reconnect}}}
end
defp pid_or_name do
case Process.info(self(), :registered_name) do
{:registered_name, atom} when is_atom(atom) -> atom
_ -> self()
end
end
defp opts(), do: Process.get(__MODULE__)
defp put_opts(opts), do: Process.put(__MODULE__, opts)
end