Packages
electric
1.2.0
1.7.8
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/connection/manager/pool.ex
defmodule Electric.Connection.Manager.Pool do
@moduledoc """
A connection pool for managing multiple connections to a PostgreSQL database.
"""
use GenServer, shutdown: :infinity
require Logger
alias Electric.DbConnectionError
@type pool_status :: :starting | :ready | :repopulating
@type connection_status :: :starting | :connected | :disconnected
@type t :: %__MODULE__{
stack_id: Electric.stack_id(),
role: :admin | :snapshot,
pool_ref: reference(),
pool_pid: pid(),
pool_size: non_neg_integer(),
connection_manager: GenServer.server(),
status: pool_status(),
connection_pids: %{pid() => connection_status()},
last_connection_error: DbConnectionError.t() | nil
}
defstruct [
:stack_id,
:role,
:host,
:pool_ref,
:pool_pid,
:pool_size,
:connection_manager,
pool_mod: Postgrex,
status: :starting,
connection_pids: %{},
last_connection_error: nil
]
# NimbleOptions schema (keep style consistent with other modules)
@name_schema_tuple {:tuple, [:atom, :atom, :any]}
@schema NimbleOptions.new!(
stack_id: [type: :string, required: true],
role: [type: {:in, [:admin, :snapshot]}, required: true],
# GenServer.server() — allow pid or a registered name
connection_manager: [type: {:or, [:pid, :atom, @name_schema_tuple]}, required: true],
# Pool implementation module (e.g. Postgrex)
pool_mod: [type: :atom, default: Postgrex],
# Forwarded to the pool implementation
pool_opts: [type: :keyword_list, required: true],
# Forwarded DB connection options (e.g. hostname, username, etc.)
conn_opts: [type: :keyword_list, required: true]
)
def name(stack_id, role)
when not is_map(stack_id) and not is_list(stack_id) and is_atom(role) do
Electric.ProcessRegistry.name(stack_id, __MODULE__, role)
end
def name(opts) when is_list(opts) or is_map(opts) do
name(Access.fetch!(opts, :stack_id), Access.fetch!(opts, :role))
end
def start_link(opts) do
with {:ok, opts} <- NimbleOptions.validate(opts, @schema) do
GenServer.start_link(__MODULE__, opts, name: name(opts))
end
end
@impl true
def init(opts) do
Process.flag(:trap_exit, true)
role = Access.fetch!(opts, :role)
Process.set_label({:connection_pool, opts[:stack_id], role})
Logger.metadata(stack_id: opts[:stack_id], pool_role: role)
Electric.Telemetry.Sentry.set_tags_context(stack_id: opts[:stack_id])
pool_mod = Access.fetch!(opts, :pool_mod)
pool_opts = Access.fetch!(opts, :pool_opts)
conn_opts = Access.fetch!(opts, :conn_opts)
host = "#{conn_opts[:hostname]}:#{conn_opts[:port] || 5432}"
pool_ref = make_ref()
pool_size = Keyword.get(pool_opts, :pool_size, 2)
pool_config =
[
# Disable automatic reconnection for pooled connections making them terminate on
# error. This lets us observe connection errors by configuring the current process
# as a connection listener in the pool.
# The value for `max_restarts` is based on the pool size so the pool supervisor
# doesn't shutdown unless all pooled connections fall into a restart loop within
# the 5 second grace interval.
name: Electric.Connection.Manager.pool_name(opts[:stack_id], role),
backoff_type: :stop,
max_restarts: pool_size * 3,
max_seconds: 5,
configure: {__MODULE__, :configure_pool_conn, [self(), opts[:stack_id]]},
connection_listeners: {[self()], pool_ref},
# Assume the manager connection might be pooled, so use unnamed prepared
# statements to avoid issues with the pooler
#
# See https://hexdocs.pm/postgrex/0.19.3/readme.html#pgbouncer
prepare: :unnamed
]
{:ok, pool_pid} =
pool_mod.start_link(pool_config ++ pool_opts ++ conn_opts)
state = %__MODULE__{
stack_id: Access.fetch!(opts, :stack_id),
role: role,
host: host,
pool_ref: pool_ref,
pool_pid: pool_pid,
pool_size: pool_size,
connection_manager: Access.fetch!(opts, :connection_manager)
}
{:ok, state}
end
@impl true
def handle_continue(:update_pool_status, state) do
pool_is_ready = num_connected(state) >= state.pool_size
case {state.status, pool_is_ready} do
{:starting, true} ->
Logger.info(
"Connection pool [#{state.role}: #{state.host}] is ready with #{state.pool_size} connections"
)
notify_connection_pool_ready(state)
{:noreply, %{state | status: :ready, last_connection_error: nil}}
{:repopulating, true} ->
Logger.debug(
"Connection pool [#{state.role}] fully repopulated with #{state.pool_size} connections"
)
{:noreply, %{state | status: :ready, last_connection_error: nil}}
{:ready, false} ->
Logger.debug(
"Connection pool [#{state.role}] no longer fully populated, waiting for more connections"
)
{:noreply, %{state | status: :repopulating}}
_ ->
{:noreply, state}
end
end
@impl true
def handle_info({:pool_conn_started, pid}, state) do
# The connection pool has started a new connection, so we need to remember it.
Logger.debug("Pooled connection [#{state.role}] #{inspect(pid)} started")
{
:noreply,
%{state | connection_pids: Map.put(state.connection_pids, pid, :starting)}
}
end
# The following two messages are sent by the DBConnection library because we've configured
# the connection manager process as connection listener for the DB connection pool.
def handle_info({:connected, pid, ref}, %{pool_ref: ref} = state) do
Logger.debug("Pooled connection [#{state.role}] #{inspect(pid)} connected")
{
:noreply,
%{state | connection_pids: Map.put(state.connection_pids, pid, :connected)},
{:continue, :update_pool_status}
}
end
def handle_info({:disconnected, pid, ref}, %{pool_ref: ref} = state) do
Logger.debug("Pooled connection [#{state.role}] #{inspect(pid)} disconnected")
{
:noreply,
%{state | connection_pids: Map.put(state.connection_pids, pid, :disconnected)},
{:continue, :update_pool_status}
}
end
# Special-case the explicit shutdown of the supervision tree.
def handle_info({:EXIT, _, :shutdown}, state), do: {:noreply, state}
# Special-case the :killed exit of the pool process, which would occur if the pool connection
# supervisor reaches its max restarts within the grace period, indicating that the pool could
# not set up the specified number of connections.
def handle_info({:EXIT, pid, :killed}, %{pool_pid: pid, status: status} = state)
when status in [:starting, :repopulating] do
error =
if is_nil(state.last_connection_error) do
%DbConnectionError{
message:
"Connection pool [#{state.role}] was unable to fill up with healthy connections.",
type: :connection_pool_failed_to_populate,
original_error: :killed,
retry_may_fix?: true
}
else
state.last_connection_error
end
{:stop, {:shutdown, error}, state}
end
def handle_info({:EXIT, pid, reason}, %{pool_pid: pid} = state) do
{:stop, reason, state}
end
def handle_info({:EXIT, pid, reason}, state) when is_map_key(state.connection_pids, pid) do
Logger.debug(
"Pooled connection [#{state.role}] #{inspect(pid)} exited with reason: #{inspect(reason)}"
)
# Keep track of the most recent pooled connection error seen so we can use it as the
# reason for the pool failing as a whole if it does not manage to recover.
state =
case reason do
:killed -> state
:shutdown -> state
{:shutdown, exit_reason} -> %{state | last_connection_error: exit_reason}
reason -> %{state | last_connection_error: reason}
end
if not is_nil(state.last_connection_error) do
Logger.warning(
"Pooled database connection [#{state.role}] encountered an error: #{inspect(state.last_connection_error, pretty: true)}"
)
end
{
:noreply,
%{state | connection_pids: Map.delete(state.connection_pids, pid)},
{:continue, :update_pool_status}
}
end
@impl true
def terminate(_reason, %{pool_pid: pool_pid}) do
# ensure pool is terminated along with pool manager
ref = Process.monitor(pool_pid)
Process.exit(pool_pid, :shutdown)
receive do
{:DOWN, ^ref, :process, ^pool_pid, _reason} -> :ok
after
5000 ->
Process.exit(pool_pid, :kill)
receive do
{:DOWN, ^ref, :process, ^pool_pid, _reason} -> :ok
end
end
end
# We call this before configuring pool connections in order to fully monitor them
# and log any issues before and after the connection is established.
def configure_pool_conn(opts, supervisor_pid, stack_id) do
send(supervisor_pid, {:pool_conn_started, self()})
metadata = [is_connection_process?: true, stack_id: stack_id]
Logger.metadata(metadata)
Electric.Telemetry.Sentry.set_tags_context(metadata)
# If supervisor process not alive when initializing connection, abort it
try do
Process.link(supervisor_pid)
catch
:error, :noproc -> exit({:shutdown, {:supervisor_not_alive, supervisor_pid}})
end
Electric.Utils.deobfuscate_password(opts)
end
@spec num_connected(t()) :: non_neg_integer()
defp num_connected(%__MODULE__{connection_pids: connection_pids}) do
connection_pids
|> Enum.count(fn {_pid, status} -> status == :connected end)
end
defp notify_connection_pool_ready(%__MODULE__{} = state) do
Electric.Connection.Manager.connection_pool_ready(
state.connection_manager,
state.role,
self()
)
end
end