Packages
electric
1.2.3
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_client.ex
defmodule Electric.Postgres.ReplicationClient do
@moduledoc """
A client module for Postgres logical replication.
"""
use Postgrex.ReplicationConnection
require Electric.Postgres.ReplicationClient.Collector
alias Electric.Replication.Changes.Transaction
alias Electric.Postgres.LogicalReplication.Decoder
alias Electric.Postgres.Lsn
alias Electric.Postgres.ReplicationClient.Collector
alias Electric.Postgres.ReplicationClient.ConnectionSetup
alias Electric.Replication.Changes.Relation
alias Electric.Telemetry.OpenTelemetry
alias Electric.Telemetry.Sampler
require Logger
@type step ::
:disconnected
| :connected
| :identify_system
| :query_pg_info
| :acquire_lock
| :create_publication
| :check_if_publication_exists
| :drop_slot
| :create_slot
| :set_display_setting
| :ready_to_stream
| :start_streaming
| :streaming
defmodule State do
@enforce_keys [:transaction_received, :relation_received, :publication_name]
defstruct [
:stack_id,
:connection_manager,
:transaction_received,
:relation_received,
:publication_name,
:lock_acquired?,
:try_creating_publication?,
:recreate_slot?,
:start_streaming?,
:pg_version,
:slot_name,
:slot_temporary?,
:display_settings,
:txn_collector,
:publication_owner?,
:replication_idle_timeout,
step: :disconnected,
# Cache the end_lsn of the last processed Commit message to report it back to Postgres
# on demand via standby status update messages -
# https://www.postgresql.org/docs/current/protocol-replication.html#PROTOCOL-REPLICATION-STANDBY-STATUS-UPDATE
#
# Postgres defines separate "received and written to disk", "flushed to disk" and
# "applied" offsets but we only keep track of the "applied" offset which we define as the
# end LSN of the last transaction that we have successfully processed and persisted in the
# shape log storage.
received_wal: 0,
flushed_wal: 0,
last_seen_txn_lsn: Lsn.from_integer(0),
last_seen_txn_timestamp: nil,
flush_up_to_date?: true
]
@type t() :: %__MODULE__{
stack_id: String.t(),
connection_manager: pid(),
transaction_received: {module(), atom(), [term()]},
relation_received: {module(), atom(), [term()]},
publication_name: String.t(),
try_creating_publication?: boolean(),
recreate_slot?: boolean(),
start_streaming?: boolean(),
pg_version: non_neg_integer(),
slot_name: String.t(),
slot_temporary?: boolean(),
display_settings: [String.t()],
txn_collector: Collector.t(),
publication_owner?: boolean(),
replication_idle_timeout: non_neg_integer(),
step: Electric.Postgres.ReplicationClient.step(),
received_wal: non_neg_integer(),
flushed_wal: non_neg_integer(),
last_seen_txn_lsn: Lsn.t(),
last_seen_txn_timestamp: integer(),
flush_up_to_date?: boolean()
}
@opts_schema NimbleOptions.new!(
stack_id: [required: true, type: :string],
connection_manager: [required: true, type: :pid],
transaction_received: [required: true, type: :mfa],
relation_received: [required: true, type: :mfa],
publication_name: [required: true, type: :string],
try_creating_publication?: [required: true, type: :boolean],
start_streaming?: [type: :boolean, default: true],
slot_name: [required: true, type: :string],
slot_temporary?: [type: :boolean, default: false],
replication_idle_timeout: [type: :non_neg_integer, default: 0],
# Set a reasonable limit for the maximum size of a transaction that
# we can handle, above which we would exit as we run the risk of running
# out of memmory.
# TODO: stream out transactions and collect on disk to avoid this
max_txn_size: [type: {:or, [:non_neg_integer, nil]}, default: nil]
)
@spec new(Access.t()) :: t()
def new(opts) do
opts = NimbleOptions.validate!(opts, @opts_schema)
settings = [display_settings: Electric.Postgres.display_settings()]
opts = settings ++ opts
{max_txn_size, opts} = Keyword.pop!(opts, :max_txn_size)
struct!(
__MODULE__,
opts ++ [txn_collector: %Collector{max_tx_size: max_txn_size}]
)
end
end
# @type state :: State.t()
@repl_msg_x_log_data ?w
@repl_msg_primary_keepalive ?k
@repl_msg_standby_status_update ?r
@default_connect_timeout 30_000
@idle_check_interval Electric.Config.min_replication_idle_timeout()
@spec start_link(Keyword.t()) :: :gen_statem.start_ret()
def start_link(opts) do
config = Map.new(opts)
# Disable the reconnection logic in Postgex.ReplicationConnection to force it to exit with
# the connection error. Without this, we may observe undesirable restarts in tests between
# one test process exiting and the next one starting.
start_opts =
[
name: name(config.stack_id),
timeout: Access.get(opts, :timeout, @default_connect_timeout),
auto_reconnect: false,
sync_connect: false
] ++ Electric.Utils.deobfuscate_password(config.replication_opts[:connection_opts])
Postgrex.ReplicationConnection.start_link(
__MODULE__,
Keyword.delete(config.replication_opts, :connection_opts),
start_opts
)
end
def name(stack_id) do
Electric.ProcessRegistry.name(stack_id, __MODULE__)
end
# This is a send() and not a call() to prevent the caller (the Connection.Manager process) from
# getting blocked when the replication connection is blocked some replication slot condition
# that doesn't let it start streaming immediately.
def start_streaming(client) do
send(client, :start_streaming)
end
def stop(client, reason) do
Postgrex.ReplicationConnection.call(client, {:stop, reason})
end
# The `Postgrex.ReplicationConnection` behaviour does not follow the gen server conventions and
# establishes its own instead. Unless the `sync_connect: false` option is passed to `start_link()`, the
# connection process will try opening a replication connection to Postgres before returning
# from its `init()` callback.
#
# The callbacks `init()`, `handle_connect()` and `handle_result()` defined in this module
# would all be invoked inside the connection process' `init()` callback in that case. Once
# any of the callbacks return `{:stream, ...}`, the connection process finishes its
# initialization and switches into the logical streaming mode to start receiving logical
# messages from Postgres, invoking the `handle_data()` callback for each one.
#
# TODO(alco): this needs additional info about :noreply and :query return tuples.
@impl true
def init(replication_opts) do
state = State.new(replication_opts)
Process.set_label({:replication_client, state.stack_id})
Logger.metadata(stack_id: state.stack_id, is_connection_process?: true)
Electric.Telemetry.Sentry.set_tags_context(stack_id: state.stack_id)
{:ok, state}
end
# `Postgrex.ReplicationConnection` opens a new replication connection to Postgres and then
# gives us a chance to execute one or more queries before switching into the logical
# streaming mode. It doesn't give us the connection socket but instead takes the query returned
# by one of our `handle_connect/1`, `handle_result/2` or `handle_info/2` callbacks, executes
# it, invokes the `handle_result/2` callback on the result which may return another query to
# execute, executes that, and so it goes on and on, recursively, until a callback returns
# `{:noreply, ...}` or `{:streaming, ...}`.
#
# To execute a series of queries one after the other, we define an ad-hoc state
# machine that starts from the :connected state in `handle_connect/1`, then transitions to
# the next step and returns the appropriate query to `Postgrex.ReplicationConnection` for execution,
# This is all implemented in a separate module named `Electric.Postgres.ReplicationClient.ConnectionSetup`
# to separate the connection setup logic from logical streaming.
@impl true
def handle_connect(state) do
%{state | step: :connected}
|> notify_connection_opened()
|> ConnectionSetup.start()
end
@impl true
def handle_result(result_list_or_error, state) do
{current_step, next_step, extra_info, return_val} =
ConnectionSetup.process_query_result(result_list_or_error, state)
if current_step == :identify_system,
do: notify_system_identified(state, extra_info)
if current_step == :query_pg_info,
do: notify_pg_info_obtained(state, extra_info)
if current_step == :acquire_lock do
case extra_info do
:lock_acquired -> notify_lock_acquired(state)
{:lock_acquisition_failed, error} -> notify_lock_acquisition_error(state, error)
end
end
if current_step == :create_slot and extra_info == :created_new_slot,
do: notify_created_new_slot(state)
if next_step == :ready_to_stream,
do: notify_ready_to_stream(state)
return_val
end
@impl true
def handle_call({:stop, reason}, from, _state) do
Logger.notice(
"Replication client #{inspect(self())} is stopping after receiving stop request from #{inspect(elem(from, 0))} with reason #{inspect(reason)}"
)
{:disconnect, reason}
end
@impl true
def handle_info({:flush_boundary_updated, lsn}, state) do
state =
if Lsn.from_integer(lsn) == state.last_seen_txn_lsn do
%{
state
| flush_up_to_date?: true,
flushed_wal: state.received_wal,
received_wal: max(lsn, state.received_wal)
}
else
%{state | flushed_wal: max(lsn, state.flushed_wal), received_wal: state.received_wal}
end
{:noreply, [encode_standby_status_update(state)], state}
end
@impl true
def handle_info(:start_streaming, %State{step: :ready_to_stream} = state) do
ConnectionSetup.start_streaming(state)
end
def handle_info(:start_streaming, %State{step: step} = state) do
Logger.debug("Replication client requested to start streaming while step=#{step}")
{:noreply, state}
end
def handle_info(:check_if_idle, %State{last_seen_txn_timestamp: txn_ts} = state) do
time_diff = System.convert_time_unit(System.monotonic_time() - txn_ts, :native, :millisecond)
if time_diff >= state.replication_idle_timeout do
{:disconnect, {:shutdown, {:connection_idle, time_diff}}}
else
{:noreply, state}
end
end
# This callback is invoked when the connection process receives a shutdown signal.
def handle_info({:EXIT, _pid, :shutdown}, _state) do
Logger.debug("Replication client #{inspect(self())} received shutdown signal, stopping")
{:disconnect, :shutdown}
end
# Some other exit reason we're not expecting: disconnect and shut down.
def handle_info({:EXIT, _pid, reason}, _state) do
{:disconnect, reason}
end
# The implementation of Postgrex.ReplicationConnection doesn't give us a convenient way to
# check whether the START_REPLICATION_SLOT statement succeeded before switching the
# connection into streaming mode. Returning {:query, "START_REPLICATION_SLOT ...", state}
# works fine when the query result is an error: it is then passed to the handle_result()
# callback. But if streaming starts without issues, a function clause error is encountered
# inside Postgrex.ReplicationConnection because it expects the connection to already have
# been switched into streaming mode by returning {:stream, "START_REPLICATION_SLOT ...", [], state}.
#
# Hence this function clause of `handle_data()` that notifies the connection manager about
# successful streaming start as soon as it receives the first replication message from
# Postgres.
@impl true
@spec handle_data(binary(), State.t()) ::
{:noreply, State.t()} | {:noreply, list(binary()), State.t()}
def handle_data(data, %State{step: :start_streaming} = state) do
# Modify the state as if we've just seen a transaction so that in the future we have a
# starting point to check how long the stream has been idle for.
state = %{state | step: :streaming, last_seen_txn_timestamp: System.monotonic_time()}
if state.replication_idle_timeout > 0 do
:timer.send_interval(@idle_check_interval, :check_if_idle)
end
notify_seen_first_message(state)
handle_data(data, state)
end
def handle_data(<<@repl_msg_primary_keepalive, wal_end::64, _clock::64, reply>>, state) do
Logger.debug(fn ->
"Primary Keepalive: wal_end=#{wal_end} (#{Lsn.from_integer(wal_end)}) reply=#{reply}"
end)
case reply do
1 when Collector.is_collecting(state.txn_collector) ->
{:noreply, [encode_standby_status_update(state)], state}
# if we are not collecting any transactions, advance the replication slot
# with keepalives to avoid it getting filled with irrelevant changes, like
# heartbeats from the database provider
1 ->
state = update_stored_wals(state, wal_end)
{:noreply, [encode_standby_status_update(state)], state}
0 when Collector.is_collecting(state.txn_collector) ->
{:noreply, [], state}
0 ->
state = update_stored_wals(state, wal_end)
{:noreply, [], state}
end
end
def handle_data(
<<@repl_msg_x_log_data, _wal_start::64, _server_wal_end::64, _clock::64, data::binary>>,
%State{stack_id: stack_id} = state
) do
data
|> OpenTelemetry.start_interval("replication_client.decode_message")
|> Decoder.decode()
# # Useful for debugging:
# |> tap(fn %struct{} = msg ->
# message_type = struct |> to_string() |> String.split(".") |> List.last()
#
# Logger.debug(
# "XLogData: wal_start=#{wal_start} (#{Lsn.from_integer(wal_start)}), " <>
# "wal_end=#{wal_end} (#{Lsn.from_integer(wal_end)})\n" <>
# message_type <> " :: " <> inspect(Map.from_struct(msg))
# )
# end)
|> OpenTelemetry.start_interval("replication_client.collector.handle_message")
|> Collector.handle_message(state.txn_collector)
|> case do
{:error, reason, _} ->
{:disconnect, {:irrecoverable_slot, reason}}
%Collector{} = txn_collector ->
OpenTelemetry.start_interval("replication_client.await_more_data")
{:noreply, %{state | txn_collector: txn_collector, flush_up_to_date?: false}}
{%Relation{} = rel, %Collector{} = txn_collector} ->
OpenTelemetry.wipe_interval_timer()
{m, f, args} = state.relation_received
OpenTelemetry.with_span(
"pg_txn.replication_client.relation_received",
["rel.id": rel.id, "rel.schema": rel.schema, "rel.table": rel.table],
stack_id,
fn -> apply_with_retries({m, f, [rel | args]}, state) end
)
OpenTelemetry.start_interval("replication_client.await_more_data")
{:noreply, %{state | txn_collector: txn_collector}}
{%Transaction{} = txn, txn_meta, %Collector{} = txn_collector} ->
state = %{
state
| txn_collector: txn_collector,
last_seen_txn_lsn: txn.lsn,
last_seen_txn_timestamp: System.monotonic_time()
}
{m, f, args} = state.transaction_received
OpenTelemetry.start_interval("replication_client.telemetry_execute")
if Sampler.sample_metrics?() do
OpenTelemetry.execute(
[:electric, :postgres, :replication, :transaction_received],
%{
monotonic_time: System.monotonic_time(),
receive_lag: DateTime.diff(DateTime.utc_now(), txn.commit_timestamp, :millisecond),
bytes: txn_meta.byte_size,
count: 1,
operations: txn.num_changes
},
%{stack_id: state.stack_id}
)
end
OpenTelemetry.start_interval("replication_client.telemetry_span")
# this will block until all the consumers have processed the transaction because
# the log collector uses manual demand, and only replies to the `call` once it
# receives more demand.
# The timeout for any call here is important. Different storage
# backends will require different timeouts and the timeout will need to
# accomodate varying number of shape consumers.
#
# The current solution is to set timeout: :infinity for the call that
# sends the txn message to the consumers and waits for them all to
# write to storage, but crash individual consumers if the write takes
# too long. So it doesn't matter how many consumers we have but an
# individual storage write can timeout the entire batch.
OpenTelemetry.with_span(
"pg_txn.replication_client.transaction_received",
[
num_changes: txn.num_changes,
num_relations: MapSet.size(txn.affected_relations),
xid: txn.xid
],
stack_id,
fn ->
OpenTelemetry.start_interval("replication_client.telemetry_span")
apply_with_retries({m, f, [txn | args]}, state)
OpenTelemetry.start_interval("replication_client.update_received_wal")
# We currently process incoming replication messages sequentially, persisting each
# new transaction into the shape log store. So, when the applied function
# returns, we can safely advance the replication slot past the transaction's commit
# LSN.
state =
state
|> update_received_wal(Electric.Postgres.Lsn.to_integer(txn.lsn))
response = [encode_standby_status_update(state)]
OpenTelemetry.stop_and_save_intervals(
total_attribute: :"shape_log_collector.transaction.total_duration_µs"
)
OpenTelemetry.start_interval("replication_client.await_more_data")
{:noreply, response, state}
end
)
end
end
defp encode_standby_status_update(state) do
Logger.debug(fn ->
"Standby status update: received_wal=#{Lsn.from_integer(state.received_wal)}, flushed_wal=#{Lsn.from_integer(state.flushed_wal)}"
end)
<<
@repl_msg_standby_status_update,
state.received_wal + 1::64,
state.flushed_wal + 1::64,
state.flushed_wal + 1::64,
current_time()::64,
0
>>
end
# Retry applying the given MFA
# A retry may need to happen if the connection is available or the collector is not ready yet.
# In those instances we wait until the stack is ready and retry, and will go on retrying forever.
# We may also get a process down, and we retry here too but with a timeout since processes should
# be bought back up by the supervisor and if this carries on for longer than the timeout there may
# be a more serious issue.
@retry_time 10 * 60_000
@spin_prevention_delay 50
defp apply_with_retries(mfa, state, time_remaining \\ @retry_time) do
start_time = System.monotonic_time(:millisecond)
{m, f, args} = mfa
try do
case apply(m, f, args) do
:ok ->
:ok
{:error, error} when error in [:not_ready, :connection_not_available] ->
Process.sleep(@spin_prevention_delay)
Electric.StatusMonitor.wait_until_active(state.stack_id,
timeout: :infinity,
block_on_conn_sleeping: true
)
apply_with_retries(mfa, state, @retry_time)
end
catch
_, _ when time_remaining > 0 ->
receive do
# on receiving an exit while holding processing, we should respect the exit
{:EXIT, _from, reason} -> exit(reason)
after
@spin_prevention_delay ->
time_remaining = time_remaining - (System.monotonic_time(:millisecond) - start_time)
apply_with_retries(mfa, state, time_remaining)
end
end
end
@epoch DateTime.to_unix(~U[2000-01-01 00:00:00Z], :microsecond)
defp current_time(), do: System.os_time(:microsecond) - @epoch
defp update_stored_wals(
%{
received_wal: received_wal,
flushed_wal: flushed_wal,
flush_up_to_date?: flush_up_to_date?
} = state,
wal
) do
received_wal = max(received_wal, wal)
flushed_wal = if flush_up_to_date?, do: max(flushed_wal, wal), else: flushed_wal
%{state | received_wal: received_wal, flushed_wal: flushed_wal}
end
defp update_received_wal(state, wal) when is_number(wal) and wal >= state.received_wal,
do: %{state | received_wal: wal}
defp update_received_wal(state, wal) when is_number(wal), do: state
defp notify_connection_opened(%State{connection_manager: manager} = state) do
:ok = Electric.Connection.Manager.replication_client_started(manager)
state
end
defp notify_system_identified(%State{connection_manager: manager} = state, info) do
:ok = Electric.Connection.Manager.pg_system_identified(manager, info)
state
end
defp notify_pg_info_obtained(%State{connection_manager: manager} = state, pg_info) do
:ok = Electric.Connection.Manager.pg_info_obtained(manager, pg_info)
state
end
defp notify_lock_acquisition_error(%State{connection_manager: manager} = state, error) do
:ok = Electric.Connection.Manager.replication_client_lock_acquisition_failed(manager, error)
state
end
defp notify_lock_acquired(%State{connection_manager: manager} = state) do
:ok = Electric.Connection.Manager.replication_client_lock_acquired(manager)
state
end
defp notify_created_new_slot(%State{connection_manager: manager} = state) do
:ok = Electric.Connection.Manager.replication_client_created_new_slot(manager)
state
end
defp notify_ready_to_stream(%State{connection_manager: manager} = state) do
:ok = Electric.Connection.Manager.replication_client_ready_to_stream(manager)
state
end
defp notify_seen_first_message(%State{connection_manager: manager} = state) do
:ok = Electric.Connection.Manager.replication_client_streamed_first_message(manager)
state
end
end