Packages
electric
0.7.2
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.ex
defmodule Electric.ConnectionManager do
@moduledoc """
Custom initialisation and reconnection logic for database connections.
This module is esentially a supervisor for database connections. But unlike an OTP process
supervisor, it includes additional functionality:
- adjusting connection options based on the response from the database
- monitoring connections and initiating a reconnection procedure
- custom reconnection logic with exponential backoff
- starting the shape consumer supervisor tree once a replication connection
has been established
Your OTP application should start a singleton connection manager under its main supervision tree:
children = [
...,
{Electric.ConnectionManager,
connection_opts: [...],
replication_opts: [...],
pool_opts: [...],
log_collector: {LogCollector, [...]},
shape_cache: {ShapeCache, [...]}}
...
]
Supervisor.start_link(children, strategy: :one_for_one)
"""
defmodule State do
defstruct [
# Database connection opts to be passed to Postgrex modules.
:connection_opts,
# Replication options specific to `Electric.Postgres.ReplicationClient`.
:replication_opts,
# Database connection pool options.
:pool_opts,
# Options specific to `Electric.Timeline`.
:timeline_opts,
# Configuration for the log collector
:log_collector,
# Configuration for the shape cache that implements `Electric.ShapeCacheBehaviour`
:shape_cache,
# PID of the replication client.
:replication_client_pid,
# PID of the Postgres connection lock.
:lock_connection_pid,
# PID of the database connection pool (a `Postgrex` process).
:pool_pid,
# Backoff term used for reconnection with exponential back-off.
:backoff,
# Flag indicating whether the lock on the replication has been acquired.
:pg_lock_acquired,
# PostgreSQL server version
:pg_version,
:electric_instance_id
]
end
use GenServer
require Logger
@type status :: :waiting | :starting | :active
@type option ::
{:connection_opts, Keyword.t()}
| {:replication_opts, Keyword.t()}
| {:pool_opts, Keyword.t()}
| {:timeline_opts, Keyword.t()}
| {:log_collector, {module(), Keyword.t()}}
| {:shape_cache, {module(), Keyword.t()}}
@type options :: [option]
@name __MODULE__
@lock_status_logging_interval 10_000
@doc """
Returns the version of the PostgreSQL server.
"""
@spec get_pg_version(GenServer.server()) :: float()
def get_pg_version(server) do
GenServer.call(server, :get_pg_version)
end
@doc """
Returns the status of the connection manager.
"""
@spec get_status(GenServer.server()) :: status()
def get_status(server) do
GenServer.call(server, :get_status)
end
@spec start_link(options) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: @name)
end
@impl true
def init(opts) do
# Because child processes are started via `start_link()` functions and due to how Postgrex
# (mis)manages connection errors, we have to trap exists in the manager process to
# implement our custom error handling logic.
Process.flag(:trap_exit, true)
connection_opts =
opts
|> Keyword.fetch!(:connection_opts)
|> update_ssl_opts()
|> update_tcp_opts()
replication_opts =
opts
|> Keyword.fetch!(:replication_opts)
|> Keyword.put(:start_streaming?, false)
pool_opts = Keyword.fetch!(opts, :pool_opts)
timeline_opts = Keyword.fetch!(opts, :timeline_opts)
state =
%State{
connection_opts: connection_opts,
replication_opts: replication_opts,
pool_opts: pool_opts,
timeline_opts: timeline_opts,
log_collector: Keyword.fetch!(opts, :log_collector),
shape_cache: Keyword.fetch!(opts, :shape_cache),
pg_lock_acquired: false,
backoff: {:backoff.init(1000, 10_000), nil},
electric_instance_id: Keyword.fetch!(opts, :electric_instance_id)
}
# Try to acquire the connection lock on the replication slot
# before starting shape and replication processes, to ensure
# a single active sync service is connected to Postgres per slot.
{:ok, state, {:continue, :start_lock_connection}}
end
@impl true
def handle_call(:get_pg_version, _from, %{pg_version: pg_version} = state) do
# If we haven't queried the PG version by the time it is requested, that's a fatal error.
false = is_nil(pg_version)
{:reply, pg_version, state}
end
def handle_call(:get_status, _from, %{pg_lock_acquired: pg_lock_acquired} = state) do
status =
cond do
not pg_lock_acquired ->
:waiting
is_nil(state.replication_client_pid) || is_nil(state.pool_pid) ||
not Process.alive?(state.pool_pid) ->
:starting
true ->
:active
end
{:reply, status, state}
end
def handle_continue(:start_lock_connection, state) do
case Electric.Postgres.LockConnection.start_link(
connection_opts: state.connection_opts,
connection_manager: self(),
lock_name: Keyword.fetch!(state.replication_opts, :slot_name)
) do
{:ok, lock_connection_pid} ->
Process.send_after(self(), :log_lock_connection_status, @lock_status_logging_interval)
{:noreply, %{state | lock_connection_pid: lock_connection_pid}}
{:error, reason} ->
handle_connection_error(reason, state, "lock_connection")
end
end
@impl true
def handle_continue(:start_replication_client, state) do
case start_replication_client(state) do
{:ok, _pid} ->
# we wait for the working connection_opts to come back from the replication client
# see `handle_call({:connection_opts, pid, connection_opts}, _, _)`
{:noreply, state}
{:error, reason} ->
handle_connection_error(reason, state, "replication")
end
end
# if the replication client is brought down by an error in one of the shape
# consumers it will reconnect and re-send this message, so we just ignore
# attempts to start the connection pool when it's already running
def handle_continue(:start_connection_pool, %{pool_pid: pool_pid} = state)
when is_pid(pool_pid) do
if Process.alive?(pool_pid) do
{:noreply, state}
else
# unlikely since the pool is linked to this process... but why not
Logger.debug(fn -> "Restarting connection pool" end)
{:noreply, %{state | pool_pid: nil}, {:continue, :start_connection_pool}}
end
end
def handle_continue(:start_connection_pool, state) do
case start_connection_pool(state.connection_opts, state.pool_opts) do
{:ok, pid} ->
Electric.Timeline.check({get_pg_id(pid), get_pg_timeline(pid)}, state.timeline_opts)
# Now we have everything ready to start accepting and processing logical messages from
# Postgres.
Electric.Postgres.ReplicationClient.start_streaming(state.replication_client_pid)
state = %{state | pool_pid: pid}
{:noreply, state}
{:error, reason} ->
handle_connection_error(reason, state, "regular")
end
end
@impl true
def handle_info({:timeout, tref, step}, %{backoff: {backoff, tref}} = state) do
state = %{state | backoff: {backoff, nil}}
handle_continue(step, state)
end
# When either the replication client or the connection pool shuts down, let the OTP
# supervisor restart the connection manager to initiate a new connection procedure from a clean
# slate. That is, unless the error that caused the shutdown is unrecoverable and requires
# manual resolution in Postgres. In that case, we crash the whole server.
def handle_info({:EXIT, pid, reason}, state) do
halt_if_fatal_error!(reason)
tag =
cond do
pid == state.lock_connection_pid -> :lock_connection
pid == state.replication_client_pid -> :replication_connection
pid == state.pool_pid -> :database_pool
end
{:stop, {tag, reason}, state}
end
def handle_info({:DOWN, _ref, :process, pid, reason}, %{replication_client_pid: pid} = state) do
halt_if_fatal_error!(reason)
# The replication client will be restarted automatically by the
# Electric.Shapes.Supervisor so we can just carry on here.
{:noreply, %{state | replication_client_pid: nil}}
end
# Periodically log the status of the lock connection until it is acquired for
# easier debugging and diagnostics.
def handle_info(:log_lock_connection_status, state) do
if not state.pg_lock_acquired do
Logger.warning(fn -> "Waiting for postgres lock to be acquired..." end)
Process.send_after(self(), :log_lock_connection_status, @lock_status_logging_interval)
end
{:noreply, state}
end
@impl true
def handle_cast({:connection_opts, pid, connection_opts}, state) do
Process.monitor(pid)
state = %{state | replication_client_pid: pid, connection_opts: connection_opts}
case state do
%{pool_pid: nil} ->
{:noreply, state, {:continue, :start_connection_pool}}
%{pool_pid: pool_pid} when is_pid(pool_pid) ->
# The replication client has crashed and been restarted. Since we have
# a db pool already start the replication stream.
Electric.Postgres.ReplicationClient.start_streaming(pid)
{:noreply, state}
end
end
def handle_cast({:pg_version, pg_version}, state) do
{:noreply, %{state | pg_version: pg_version}}
end
def handle_cast(:lock_connection_acquired, %{pg_lock_acquired: false} = state) do
# As soon as we acquire the connection lock, we try to start the replication connection
# first because it requires additional privileges compared to regular "pooled" connections,
# so failure to open a replication connection should be reported ASAP.
{:noreply, %{state | pg_lock_acquired: true}, {:continue, :start_replication_client}}
end
defp start_replication_client(state) do
Electric.Shapes.Supervisor.start_link(
electric_instance_id: state.electric_instance_id,
replication_client: {
Electric.Postgres.ReplicationClient,
connection_opts: state.connection_opts,
replication_opts: state.replication_opts,
connection_manager: self()
},
shape_cache: state.shape_cache,
log_collector: state.log_collector
)
end
defp start_connection_pool(connection_opts, pool_opts) do
# Use default backoff strategy for connections to prevent pool from shutting down
# in the case of a connection error. Deleting a shape while its still generating
# its snapshot from the db can trigger this as the snapshot process and the storage
# process are both terminated when the shape is removed.
#
# See https://github.com/electric-sql/electric/issues/1554
Postgrex.start_link(
[backoff_type: :exp, max_restarts: 3, max_seconds: 5] ++ pool_opts ++ connection_opts
)
end
defp handle_connection_error(
{:shutdown, {:failed_to_start_child, Electric.Postgres.ReplicationClient, error}},
state,
mode
) do
handle_connection_error(error, state, mode)
end
defp handle_connection_error(error, state, mode) do
halt_if_fatal_error!(error)
message =
case error do
%DBConnection.ConnectionError{message: message} ->
message
%Postgrex.Error{message: message} when not is_nil(message) ->
message
%Postgrex.Error{postgres: %{message: message} = pg_error} ->
message <> pg_error_extra_info(pg_error)
end
Logger.warning("Database connection in #{mode} mode failed: #{message}")
step =
cond do
is_nil(state.lock_connection_pid) -> :start_lock_connection
is_nil(state.replication_client_pid) -> :start_replication_client
is_nil(state.pool_pid) -> :start_connection_pool
end
state = schedule_reconnection(step, state)
{:noreply, state}
end
defp pg_error_extra_info(pg_error) do
extra_info_items =
[
{"PG code:", Map.get(pg_error, :pg_code)},
{"PG routine:", Map.get(pg_error, :routine)}
]
|> Enum.reject(fn {_, val} -> is_nil(val) end)
|> Enum.map(fn {label, val} -> "#{label} #{val}" end)
if extra_info_items != [] do
" (" <> Enum.join(extra_info_items, ", ") <> ")"
else
""
end
end
@invalid_slot_detail "This slot has been invalidated because it exceeded the maximum reserved size."
defp halt_if_fatal_error!(
%Postgrex.Error{
postgres: %{
code: :object_not_in_prerequisite_state,
detail: @invalid_slot_detail,
pg_code: "55000",
routine: "StartLogicalReplication"
}
} = error
) do
System.stop(1)
exit(error)
end
defp halt_if_fatal_error!(_), do: nil
defp schedule_reconnection(step, %State{backoff: {backoff, _}} = state) do
{time, backoff} = :backoff.fail(backoff)
tref = :erlang.start_timer(time, self(), step)
Logger.warning("Reconnecting in #{inspect(time)}ms")
%State{state | backoff: {backoff, tref}}
end
defp update_ssl_opts(connection_opts) do
ssl_opts =
case connection_opts[:sslmode] do
:disable ->
false
_ ->
hostname = String.to_charlist(connection_opts[:hostname])
ssl_verify_opts()
|> Keyword.put(:server_name_indication, hostname)
end
Keyword.put(connection_opts, :ssl, ssl_opts)
end
# We explicitly set `verify` to `verify_none` because it's currently the only way to ensure
# encrypted connections work even when a faulty certificate chain is presented by the PG host.
# This behaviour matches that of `psql <DATABASE_URL>?sslmode=require`.
#
# Here's an example of connecting to DigitalOcean's Managed PostgreSQL to illustrate the point.
# Specifying `sslmode=require` does not result in any certificate validation, it only instructs
# `psql` to use SSL for the connection:
#
# $ psql 'postgresql://...?sslmode=require'
# psql (16.1, server 16.3)
# SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, compression: off)
# Type "help" for help.
#
# [db-postgresql-do-user-13160360-0] doadmin:defaultdb=> \q
#
# Now if we request certificate validation, we get a different result:
#
# $ psql 'postgresql://...?sslmode=verify-full'
# psql: error: connection to server at "***.db.ondigitalocean.com" (167.99.250.38), o
# port 25060 failed: root certificate file "/home/alco/.postgresql/root.crt" does not exist
# Either provide the file, use the system's trusted roots with sslrootcert=system, or change
# sslmode to disable server certificate verification.
#
# $ psql 'sslrootcert=system sslmode=verify-full host=***.db.ondigitalocean.com ...'
# psql: error: connection to server at "***.db.ondigitalocean.com" (167.99.250.38), port 25060
# failed: SSL error: certificate verify failed
#
# We can a better idea of what's wrong with the certificate with `openssl`'s help:
#
# $ openssl s_client -starttls postgres -showcerts -connect ***.db.ondigitalocean.com:25060 -CApath /etc/ssl/certs/
# [...]
# SSL handshake has read 3990 bytes and written 885 bytes
# Verification error: self-signed certificate in certificate chain
#
# So, until we find a way to deal with such PG hosts, we'll use `verify_none` to explicitly
# silence any warnings originating in Postgrex, since we're already forbidding the use of
# `sslmode=verify-ca` and `sslmode=verify-full` in the database URL parsing code.
defp ssl_verify_opts do
[verify: :verify_none]
end
defp update_tcp_opts(connection_opts) do
tcp_opts =
if connection_opts[:ipv6] do
[:inet6]
else
[]
end
Keyword.put(connection_opts, :socket_options, tcp_opts)
end
defp get_pg_id(conn) do
case Postgrex.query!(conn, "SELECT system_identifier FROM pg_control_system()", []) do
%Postgrex.Result{rows: [[system_identifier]]} -> system_identifier
end
end
defp get_pg_timeline(conn) do
case Postgrex.query!(conn, "SELECT timeline_id FROM pg_control_checkpoint()", []) do
%Postgrex.Result{rows: [[timeline_id]]} -> timeline_id
end
end
end