Packages
electric
1.0.5
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/shapes/consumer.ex
defmodule Electric.Shapes.Consumer do
use GenStage,
restart: :temporary,
significant: true
import Electric.Postgres.Xid, only: [compare: 2]
alias Electric.LogItems
alias Electric.Postgres.Inspector
alias Electric.Replication.Changes
alias Electric.Replication.Changes.Transaction
alias Electric.ShapeCache
alias Electric.ShapeCache.LogChunker
alias Electric.Shapes.Shape
alias Electric.Telemetry.OpenTelemetry
alias Electric.Utils
require Logger
@initial_log_state %{current_chunk_byte_size: 0, current_txn_bytes: 0}
@type log_state :: %{
current_chunk_byte_size: non_neg_integer(),
current_txn_bytes: non_neg_integer()
}
def name(%{
stack_id: stack_id,
shape_handle: shape_handle
}) do
name(stack_id, shape_handle)
end
def name(stack_id, shape_handle) when is_binary(shape_handle) do
Electric.ProcessRegistry.name(stack_id, __MODULE__, shape_handle)
end
def initial_state(consumer) do
GenStage.call(consumer, :initial_state, 30_000)
end
@doc false
# use in tests to avoid race conditions. registers `pid` to be notified
# when the `shape_handle` consumer has processed every transaction.
# Transactions that we skip because of xmin logic do not generate
# a notification
@spec monitor(String.t(), ShapeCache.shape_handle(), pid()) :: reference()
def monitor(stack_id, shape_handle, pid \\ self()) do
GenStage.call(name(stack_id, shape_handle), {:monitor, pid})
end
@spec whereis(String.t(), ShapeCache.shape_handle()) :: pid() | nil
def whereis(stack_id, shape_handle) do
GenServer.whereis(name(stack_id, shape_handle))
end
def start_link(config) when is_map(config) do
GenStage.start_link(__MODULE__, config,
name: name(config),
hibernate_after: Electric.Config.get_env(:shape_hibernate_after)
)
end
def init(config) do
Process.set_label({:consumer, config.shape_handle})
%{log_producer: producer, storage: storage, shape_status: {shape_status, shape_status_state}} =
config
metadata = [shape_handle: config.shape_handle, stack_id: config.stack_id]
Logger.metadata(metadata)
Electric.Telemetry.Sentry.set_tags_context(metadata)
:ok = ShapeCache.Storage.initialise(storage)
# Store the shape definition to ensure we can restore it
:ok = ShapeCache.Storage.set_shape_definition(config.shape, storage)
{:ok, latest_offset, pg_snapshot} = ShapeCache.Storage.get_current_position(storage)
:ok =
shape_status.initialise_shape(
shape_status_state,
config.shape_handle,
pg_snapshot[:xmin],
latest_offset
)
state =
Map.merge(config, %{
latest_offset: latest_offset,
pg_snapshot: pg_snapshot,
log_state: @initial_log_state,
inspector: config.inspector,
snapshot_started: false,
awaiting_snapshot_start: [],
buffer: [],
monitors: []
})
{:consumer, state, subscribe_to: [{producer, [max_demand: 1, shape: config.shape]}]}
end
def handle_call(:initial_state, _from, %{latest_offset: offset} = state) do
{:reply, {:ok, offset}, [], state}
end
def handle_call({:monitor, pid}, _from, %{monitors: monitors} = state) do
ref = make_ref()
{:reply, ref, [], %{state | monitors: [{pid, ref} | monitors]}}
end
def handle_call(:clean_and_stop, _from, state) do
state =
reply_to_snapshot_waiters({:error, "Shape terminated before snapshot completed"}, state)
# TODO: ensure cleanup occurs after snapshot is done/failed/interrupted to avoid
# any race conditions and leftover data
cleanup(state)
{:stop, :normal, :ok, state}
end
def handle_call(:await_snapshot_start, _from, %{snapshot_started: true} = state) do
{:reply, :started, [], state}
end
def handle_call(:await_snapshot_start, from, %{awaiting_snapshot_start: waiters} = state) do
Logger.debug("Starting a wait on the snapshot #{state.shape_handle} for #{inspect(from)}}")
{:noreply, [], %{state | awaiting_snapshot_start: [from | waiters]}}
end
def handle_cast(
{:pg_snapshot_known, shape_handle, {xmin, xmax, xip_list}},
%{shape_handle: shape_handle} = state
) do
Logger.debug(
"Snapshot known for shape_handle: #{shape_handle} xmin: #{xmin}, xmax: #{xmax}, xip_list: #{Enum.join(xip_list, ",")}"
)
state =
%{
xmin: xmin,
xmax: xmax,
xip_list: xip_list,
filter_txns?: true
}
|> set_pg_snapshot(state)
handle_txns(state.buffer, %{state | buffer: []})
end
def handle_cast({:snapshot_started, shape_handle}, %{shape_handle: shape_handle} = state) do
Logger.debug("Snapshot started shape_handle: #{shape_handle}")
state = set_snapshot_started(state)
{:noreply, [], state}
end
def handle_cast(
{:snapshot_failed, shape_handle, error, stacktrace},
%{shape_handle: shape_handle} = state
) do
if match?(%DBConnection.ConnectionError{reason: :queue_timeout}, error),
do:
Logger.warning(
"Snapshot creation failed for #{shape_handle} because of a connection pool queue timeout"
),
else:
Logger.error(
"Snapshot creation failed for #{shape_handle} because of:\n#{Exception.format(:error, error, stacktrace)}"
)
state = reply_to_snapshot_waiters({:error, error}, state)
cleanup(state)
{:stop, :normal, state}
end
def handle_cast({:snapshot_exists, shape_handle}, %{shape_handle: shape_handle} = state) do
state = set_pg_snapshot(state.pg_snapshot, state)
state = set_snapshot_started(state)
{:noreply, [], state}
end
def terminate(reason, state) do
state =
reply_to_snapshot_waiters({:error, "Shape terminated before snapshot was ready"}, state)
if is_error?(reason) do
cleanup(state)
end
state
end
defp is_error?(:normal), do: false
defp is_error?(:killed), do: false
defp is_error?(:shutdown), do: false
defp is_error?({:shutdown, _}), do: false
defp is_error?(_), do: true
# `Shapes.Dispatcher` only works with single-events, so we can safely assert
# that here
def handle_events([{event, trace_context}], _from, state) do
OpenTelemetry.set_current_context(trace_context)
handle_event(event, state)
end
defp handle_event(%Changes.Relation{id: id}, %{shape: %{root_table_id: id}} = state) do
%{shape: %{root_table: root_table}, inspector: inspector} = state
Logger.info(
"Schema for the table #{Utils.inspect_relation(root_table)} changed - terminating shape #{state.shape_handle}"
)
# We clean up the relation info from ETS as it has changed and we want
# to source the fresh info from postgres for the next shape creation
Inspector.clean(root_table, inspector)
state =
reply_to_snapshot_waiters(
{:error, "Shape relation changed before snapshot was ready"},
state
)
cleanup(state)
{:stop, :normal, state}
end
# Buffer incoming transactions until we know our pg_snapshot
defp handle_event(%Transaction{xid: xid} = txn, %{pg_snapshot: nil} = state) do
Logger.debug(fn ->
"Consumer for #{state.shape_handle} buffering 1 transaction with xid #{xid}"
end)
{:noreply, [], %{state | buffer: state.buffer ++ [txn]}}
end
defp handle_event(%Transaction{} = txn, %{pg_snapshot: %{xmin: xmin, xmax: xmax}} = state) do
OpenTelemetry.with_span(
"shape_write.consumer.handle_txns",
[snapshot_xmin: xmin, snapshot_xmax: xmax],
state.stack_id,
fn -> handle_txns([txn], state) end
)
end
defp handle_txns(txns, state) do
case Enum.reduce_while(txns, state, &handle_txn/2) do
{:truncate, state} ->
{:stop, {:shutdown, :truncate}, state}
state ->
{:noreply, [], state}
end
end
defp handle_txn(txn, %{pg_snapshot: %{filter_txns?: false}} = state) do
handle_txn_in_span(txn, state)
end
defp handle_txn(
%Transaction{xid: xid} = txn,
%{pg_snapshot: %{xmin: xmin, xmax: xmax, xip_list: xip_list}} = state
) do
# xmin is the lowest active transaction ID, there can be txids > xmin that have
# committed and so would already be included in the shape's data snapshot.
# For this reason we store the full pg_snapshot and compare the incoming xid not only
# against xmin but also against xip_list, the list of transactions active at the time of
# taking the original data snapshot.
#
# See Postgres docs for details on the pg_snapshot fields:
# https://www.postgresql.org/docs/current/functions-info.html#FUNCTIONS-PG-SNAPSHOT-PARTS
cond do
compare(xid, xmin) == :lt ->
# Transaction already included in the shape snapshot because it had committed before
# the snapshot transaction started.
{:cont, state}
compare(xid, xmax) == :lt and xid not in xip_list ->
# Transaction commited sometime between xmin and the start of the snapshot transaction.
{:cont, state}
compare(xid, xmin) == :eq or xid in xip_list ->
# Transaction was active at the time of taking the snapshot so its effects weren't
# visible to the snapshot transaction.
handle_txn_in_span(txn, state)
compare(xid, xmax) != :lt ->
# The first transaction received from the replication stream whose xid >= xmax.
#
# From now on the only kinds of transactions coming in from the replication stream will
# be either those active at the time of taking the snapshot or those commited after the
# snapshot transaction had started. Both kinds need to be appended to the shape log.
#
# At this point we can disable transaction filtering on the snapshot to avoid further
# xid comparisons.
state = stop_filtering_txns(state)
handle_txn_in_span(txn, state)
end
end
defp handle_txn_in_span(txn, state) do
ot_attrs =
[xid: txn.xid, total_num_changes: txn.num_changes] ++
shape_attrs(state.shape_handle, state.shape)
OpenTelemetry.with_span("shape_write.consumer.handle_txn", ot_attrs, state.stack_id, fn ->
do_handle_txn(txn, state)
end)
end
defp do_handle_txn(%Transaction{} = txn, state) do
%{
shape: shape,
shape_handle: shape_handle,
log_state: log_state,
chunk_bytes_threshold: chunk_bytes_threshold,
shape_status: {shape_status, shape_status_state},
registry: registry,
storage: storage
} = state
Logger.debug(fn -> "Txn received in Shapes.Consumer: #{inspect(txn)}" end)
%{xid: xid, changes: changes, lsn: _lsn, last_log_offset: last_log_offset} = txn
{relevant_changes, {num_changes, has_truncate?}} =
Enum.flat_map_reduce(changes, {0, false}, fn
%Changes.TruncatedRelation{}, _ ->
{:halt, {0, true}}
change, {ops, false} ->
case Shape.convert_change(shape, change) do
[] -> {[], {ops, false}}
[change] -> {[change], {ops + 1, false}}
end
end)
cond do
has_truncate? ->
# TODO: This is a very naive way to handle truncations: if ANY relevant truncates are
# present in the transaction, we're considering the whole transaction empty, and
# just rotate the shape handle. "Correct" way to handle truncates is to be designed.
Logger.warning(
"Truncate operation encountered while processing txn #{txn.xid} for #{shape_handle}"
)
cleanup(state)
notify_listeners(registry, :shape_rotation, shape_handle, last_log_offset)
{:halt, {:truncate, notify(txn, %{state | log_state: @initial_log_state})}}
num_changes > 0 ->
{log_entries, new_log_state} =
prepare_log_entries(relevant_changes, xid, shape, log_state, chunk_bytes_threshold)
timestamp = System.monotonic_time()
# TODO: what's a graceful way to handle failure to append to log?
# Right now we'll just fail everything
:ok = ShapeCache.Storage.append_to_log!(log_entries, storage)
OpenTelemetry.add_span_attributes(%{
num_bytes: new_log_state.current_txn_bytes,
actual_num_changes: num_changes
})
shape_status.set_latest_offset(shape_status_state, shape_handle, last_log_offset)
notify_listeners(registry, :new_changes, shape_handle, last_log_offset)
lag = calculate_replication_lag(txn)
OpenTelemetry.add_span_attributes(replication_lag: lag)
:telemetry.execute(
[:electric, :storage, :transaction_stored],
%{
duration: System.monotonic_time() - timestamp,
bytes: new_log_state.current_txn_bytes,
count: 1,
operations: num_changes,
replication_lag: lag
},
Map.new(shape_attrs(state.shape_handle, state.shape))
)
{:cont, notify(txn, %{state | log_state: new_log_state})}
true ->
Logger.debug(fn ->
"No relevant changes found for #{inspect(shape)} in txn #{txn.xid}"
end)
{:cont, state}
end
end
defp notify_listeners(registry, :new_changes, shape_handle, latest_log_offset) do
Registry.dispatch(registry, shape_handle, fn registered ->
Logger.debug(fn ->
"Notifying ~#{length(registered)} clients about new changes to #{shape_handle}"
end)
for {pid, ref} <- registered,
do: send(pid, {ref, :new_changes, latest_log_offset})
end)
end
defp notify_listeners(registry, :shape_rotation, shape_handle, _latest_log_offset) do
Registry.dispatch(registry, shape_handle, fn registered ->
Logger.debug(fn ->
"Notifying ~#{length(registered)} clients about new changes to #{shape_handle}"
end)
for {pid, ref} <- registered,
do: send(pid, {ref, :shape_rotation})
end)
end
defp set_pg_snapshot(pg_snapshot, %{pg_snapshot: nil} = state) do
ShapeCache.Storage.set_pg_snapshot(pg_snapshot, state.storage)
set_pg_snapshot(pg_snapshot, %{state | pg_snapshot: pg_snapshot})
end
defp set_pg_snapshot(
%{xmin: xmin},
%{pg_snapshot: %{xmin: xmin}, shape_handle: shape_handle} = state
) do
%{shape_status: {shape_status, shape_status_state}} = state
unless shape_status.set_snapshot_xmin(shape_status_state, shape_handle, xmin),
do:
Logger.warning(
"Got snapshot information for a #{shape_handle}, that shape id is no longer valid. Ignoring."
)
state
end
defp set_snapshot_started(%{snapshot_started: false} = state) do
ShapeCache.Storage.mark_snapshot_as_started(state.storage)
set_snapshot_started(%{state | snapshot_started: true})
end
defp set_snapshot_started(%{shape_handle: shape_handle} = state) do
%{shape_status: {shape_status, shape_status_state}} = state
:ok = shape_status.mark_snapshot_started(shape_status_state, shape_handle)
reply_to_snapshot_waiters(:started, state)
end
defp stop_filtering_txns(state) do
pg_snapshot = Map.put(state.pg_snapshot, :filter_txns?, false)
ShapeCache.Storage.set_pg_snapshot(pg_snapshot, state.storage)
%{state | pg_snapshot: pg_snapshot}
end
defp cleanup(state) do
%{
shape_status: {shape_status, shape_status_state},
publication_manager: {publication_manager, publication_manager_opts}
} = state
shape_status.remove_shape(shape_status_state, state.shape_handle)
publication_manager.remove_shape(state.shape, publication_manager_opts)
ShapeCache.Storage.cleanup!(state.storage)
end
defp reply_to_snapshot_waiters(_reply, %{awaiting_snapshot_start: []} = state) do
state
end
defp reply_to_snapshot_waiters(reply, %{awaiting_snapshot_start: waiters} = state) do
for client <- List.wrap(waiters), not is_nil(client), do: GenStage.reply(client, reply)
%{state | awaiting_snapshot_start: []}
end
defp notify(_txn, %{monitors: []} = state), do: state
defp notify(%{xid: xid}, %{monitors: monitors} = state) do
for {pid, ref} <- monitors, do: send(pid, {__MODULE__, ref, xid})
state
end
@spec prepare_log_entries(
Enumerable.t(Electric.Replication.Changes.data_change()),
non_neg_integer() | nil,
Shape.t(),
log_state(),
non_neg_integer()
) :: {Enumerable.t(ShapeCache.Storage.log_item()), log_state()}
defp prepare_log_entries(
changes,
xid,
shape,
log_state,
chunk_bytes_threshold
) do
log_state = %{
current_chunk_byte_size: log_state.current_chunk_byte_size,
current_txn_bytes: 0
}
{log_items, new_log_state} =
changes
|> Stream.flat_map(
&LogItems.from_change(&1, xid, Shape.pk(shape, &1.relation), shape.replica)
)
|> Utils.flat_map_reduce_mark_last(log_state, fn {offset, log_item},
last?,
%{
current_chunk_byte_size: chunk_size,
current_txn_bytes: txn_bytes
} = state ->
json_log_item =
if(last?, do: put_in(log_item, [:headers, :last], true), else: log_item)
|> Jason.encode!()
item_byte_size = byte_size(json_log_item)
state = %{state | current_txn_bytes: txn_bytes + item_byte_size}
line_tuple = {offset, log_item.key, log_item.headers.operation, json_log_item}
case LogChunker.fit_into_chunk(item_byte_size, chunk_size, chunk_bytes_threshold) do
{:ok, new_chunk_size} ->
{[line_tuple], %{state | current_chunk_byte_size: new_chunk_size}}
{:threshold_exceeded, new_chunk_size} ->
{
[line_tuple, {:chunk_boundary, offset}],
%{state | current_chunk_byte_size: new_chunk_size}
}
end
end)
{log_items, new_log_state}
end
defp shape_attrs(shape_handle, shape) do
[
"shape.handle": shape_handle,
"shape.root_table": shape.root_table,
"shape.where": shape.where
]
end
defp calculate_replication_lag(%Transaction{commit_timestamp: commit_timestamp}) do
# Compute time elapsed since commit
# since we are comparing PG's clock with our own
# there may be a slight skew so we make sure not to report negative lag.
# Since the lag is only useful when it becomes significant, a slight skew doesn't matter.
now = DateTime.utc_now()
Kernel.max(0, DateTime.diff(now, commit_timestamp, :millisecond))
end
end