Packages
electric
0.7.3
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: :transient,
significant: true
alias Electric.ShapeCache.LogChunker
alias Electric.LogItems
alias Electric.Replication.Changes
alias Electric.Replication.Changes.Transaction
alias Electric.ShapeCache
alias Electric.Shapes.Shape
alias Electric.Telemetry.OpenTelemetry
alias Electric.Utils
require Logger
@initial_log_state %{current_chunk_byte_size: 0}
def name(%{electric_instance_id: electric_instance_id, shape_id: shape_id} = _config) do
name(electric_instance_id, shape_id)
end
def name(electric_instance_id, shape_id) when is_binary(shape_id) do
Electric.Application.process_name(electric_instance_id, __MODULE__, shape_id)
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_id` consumer has processed every transaction.
# Transactions that we skip because of xmin logic do not generate
# a notification
@spec monitor(atom(), ShapeCache.shape_id(), pid()) :: reference()
def monitor(electric_instance_id, shape_id, pid \\ self()) do
GenStage.call(name(electric_instance_id, shape_id), {:monitor, pid})
end
@spec whereis(atom(), ShapeCache.shape_id()) :: pid() | nil
def whereis(electric_instance_id, shape_id) do
electric_instance_id
|> name(shape_id)
|> GenServer.whereis()
end
def start_link(config) when is_map(config) do
GenStage.start_link(__MODULE__, config, name: name(config))
end
def init(config) do
%{log_producer: producer, storage: storage, shape_status: {shape_status, shape_status_state}} =
config
Logger.metadata(shape_id: config.shape_id)
Process.flag(:trap_exit, true)
: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, snapshot_xmin} = ShapeCache.Storage.get_current_position(storage)
:ok =
shape_status.initialise_shape(
shape_status_state,
config.shape_id,
snapshot_xmin,
latest_offset
)
state =
Map.merge(config, %{
latest_offset: latest_offset,
snapshot_xmin: snapshot_xmin,
log_state: @initial_log_state,
inspector: config.inspector,
snapshot_started: false,
awaiting_snapshot_start: [],
buffer: [],
monitors: []
})
{:consumer, state,
subscribe_to: [{producer, [max_demand: 1, selector: &selector(&1, config.shape)]}]}
end
defp selector(%Transaction{changes: changes}, shape) do
changes
|> Stream.flat_map(&Shape.convert_change(shape, &1))
|> Enum.any?()
end
defp selector(%Changes.Relation{} = relation_change, shape) do
Shape.is_affected_by_relation_change?(shape, relation_change)
end
def handle_call(:initial_state, _from, %{snapshot_xmin: xmin, latest_offset: offset} = state) do
{:reply, {:ok, xmin, 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
state = 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_id} for #{inspect(from)}}")
{:noreply, [], %{state | awaiting_snapshot_start: [from | waiters]}}
end
def handle_cast({:snapshot_xmin_known, shape_id, xmin}, %{shape_id: shape_id} = state) do
state = set_snapshot_xmin(xmin, state)
handle_txns(state.buffer, %{state | buffer: []})
end
def handle_cast({:snapshot_started, shape_id}, %{shape_id: shape_id} = state) do
state = set_snapshot_started(state)
{:noreply, [], state}
end
def handle_cast({:snapshot_failed, shape_id, error, stacktrace}, %{shape_id: shape_id} = state) do
if match?(%DBConnection.ConnectionError{reason: :queue_timeout}, error),
do:
Logger.warning(
"Snapshot creation failed for #{shape_id} because of a connection pool queue timeout"
),
else:
Logger.error(
"Snapshot creation failed for #{shape_id} because of:\n#{Exception.format(:error, error, stacktrace)}"
)
state = reply_to_snapshot_waiters({:error, error}, state)
state = cleanup(state)
{:stop, :normal, state}
end
def handle_cast({:snapshot_exists, shape_id}, %{shape_id: shape_id} = state) do
state = set_snapshot_xmin(state.snapshot_xmin, state)
state = set_snapshot_started(state)
{:noreply, [], state}
end
def terminate(_reason, state) do
reply_to_snapshot_waiters({:error, "Shape terminated before snapshot was ready"}, state)
end
# `Shapes.Dispatcher` only works with single-events, so we can safely assert
# that here
def handle_events([%Changes.Relation{}], _from, state) do
%{shape: %{root_table: root_table}, inspector: {inspector, inspector_opts}} = state
Logger.info(
"Schema for the table #{Utils.inspect_relation(root_table)} changed - terminating shape #{state.shape_id}"
)
# 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_opts)
state =
reply_to_snapshot_waiters(
{:error, "Shape relation changed before snapshot was ready"},
state
)
state = cleanup(state)
{:stop, :normal, state}
end
# Buffer incoming transactions until we know our xmin
def handle_events([%Transaction{xid: xid}] = txns, _from, %{snapshot_xmin: nil} = state) do
Logger.debug(fn ->
"Consumer for #{state.shape_id} buffering 1 transaction with xid #{xid}"
end)
{:noreply, [], %{state | buffer: state.buffer ++ txns}}
end
def handle_events([%Transaction{}] = txns, _from, state) do
OpenTelemetry.with_span(
"shape_write.consumer.handle_txns",
[snapshot_xmin: state.snapshot_xmin],
fn -> handle_txns(txns, 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(%Transaction{xid: xid}, %{snapshot_xmin: xmin} = state) when xid < xmin do
{:cont, state}
end
defp handle_txn(%Transaction{} = txn, state) do
ot_attrs =
[xid: txn.xid, num_changes: length(txn.changes)] ++ shape_attrs(state.shape_id, state.shape)
OpenTelemetry.with_span("shape_write.consumer.handle_txn", ot_attrs, fn ->
do_handle_txn(txn, state)
end)
end
defp do_handle_txn(%Transaction{} = txn, state) do
%{
shape: shape,
shape_id: shape_id,
log_state: log_state,
chunk_bytes_threshold: chunk_bytes_threshold,
shape_cache: {shape_cache, shape_cache_opts},
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 = Enum.flat_map(changes, &Shape.convert_change(shape, &1))
cond do
Enum.any?(relevant_changes, &is_struct(&1, Changes.TruncatedRelation)) ->
# 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 id. "Correct" way to handle truncates is to be designed.
Logger.warning(
"Truncate operation encountered while processing txn #{txn.xid} for #{shape_id}"
)
:ok = shape_cache.handle_truncate(shape_id, shape_cache_opts)
:ok = ShapeCache.Storage.cleanup!(storage)
{:halt, {:truncate, notify(txn, %{state | log_state: @initial_log_state})}}
relevant_changes != [] ->
{log_entries, new_log_state} =
prepare_log_entries(relevant_changes, xid, shape, log_state, chunk_bytes_threshold)
# 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)
shape_cache.update_shape_latest_offset(shape_id, last_log_offset, shape_cache_opts)
notify_listeners(registry, :new_changes, shape_id, last_log_offset)
{: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_id, latest_log_offset) do
Registry.dispatch(registry, shape_id, fn registered ->
Logger.debug(fn ->
"Notifying ~#{length(registered)} clients about new changes to #{shape_id}"
end)
for {pid, ref} <- registered,
do: send(pid, {ref, :new_changes, latest_log_offset})
end)
end
defp set_snapshot_xmin(xmin, %{snapshot_xmin: nil} = state) do
ShapeCache.Storage.set_snapshot_xmin(xmin, state.storage)
set_snapshot_xmin(xmin, %{state | snapshot_xmin: xmin})
end
defp set_snapshot_xmin(xmin, %{snapshot_xmin: xmin, shape_id: shape_id} = state) do
%{shape_status: {shape_status, shape_status_state}} = state
unless shape_status.set_snapshot_xmin(shape_status_state, shape_id, xmin),
do:
Logger.warning(
"Got snapshot information for a #{shape_id}, 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_id: shape_id} = state) do
%{shape_status: {shape_status, shape_status_state}} = state
:ok = shape_status.mark_snapshot_started(shape_status_state, shape_id)
reply_to_snapshot_waiters(:started, state)
end
defp cleanup(state) do
%{shape_status: {shape_status, shape_status_state}} = state
shape_status.remove_shape(shape_status_state, state.shape_id)
ShapeCache.Storage.cleanup!(state.storage)
state
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(),
ShapeCache.Storage.log_state(),
non_neg_integer()
) :: {Enumerable.t(ShapeCache.Storage.log_item()), ShapeCache.Storage.log_state()}
defp prepare_log_entries(
changes,
xid,
shape,
log_state,
chunk_bytes_threshold
) do
{log_items, new_log_state} =
changes
|> Stream.flat_map(&LogItems.from_change(&1, xid, Shape.pk(shape, &1.relation)))
|> Enum.flat_map_reduce(log_state, fn log_item,
%{current_chunk_byte_size: chunk_size} = state ->
json_log_item = Jason.encode!(log_item)
case LogChunker.add_to_chunk(json_log_item, chunk_size, chunk_bytes_threshold) do
{:ok, new_chunk_size} ->
{[{log_item.offset, json_log_item}],
%{state | current_chunk_byte_size: new_chunk_size}}
{:threshold_exceeded, new_chunk_size} ->
{
[{log_item.offset, json_log_item}, {:chunk_boundary, log_item.offset}],
%{state | current_chunk_byte_size: new_chunk_size}
}
end
end)
{log_items, new_log_state}
end
defp shape_attrs(shape_id, shape) do
["shape.id": shape_id, "shape.root_table": shape.root_table, "shape.where": shape.where]
end
end