Packages
electric
1.2.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/replication/shape_log_collector.ex
defmodule Electric.Replication.ShapeLogCollector do
@moduledoc """
When any txn comes from postgres, we need to store it into the
log for this shape if and only if it has txid >= xmin of the snapshot.
"""
use GenServer
require Electric.Postgres.Lsn
alias Electric.Postgres.ReplicationClient
alias Electric.Replication.ShapeLogCollector.FlushTracker
alias Electric.LsnTracker
alias Electric.Replication.ShapeLogCollector.AffectedColumns
alias Electric.Postgres.Lsn
alias Electric.Replication.PersistentReplicationState
alias Electric.Postgres.Inspector
alias Electric.Replication.Changes
alias Electric.Replication.Changes.{Relation, Transaction}
alias Electric.Shapes.Consumer.Materializer
alias Electric.Shapes.DependencyLayers
alias Electric.Shapes.Filter
alias Electric.Shapes.Partitions
alias Electric.Telemetry.OpenTelemetry
alias Electric.Telemetry.IntervalTimer
alias Electric.Shapes.ConsumerRegistry
import Electric.Utils, only: [map_while_ok: 2, map_if_ok: 2]
require Logger
@schema NimbleOptions.new!(
stack_id: [type: :string, required: true],
inspector: [type: :mod_arg, required: true],
persistent_kv: [type: :any, required: true],
consumer_registry_opts: [type: :any]
)
defguardp is_ready_to_process(state)
when is_map_key(state, :last_processed_lsn) and not is_nil(state.last_processed_lsn)
def start_link(opts) do
with {:ok, opts} <- NimbleOptions.validate(opts, @schema) do
GenServer.start_link(__MODULE__, Map.new(opts), name: name(opts[:stack_id]))
end
end
def name(stack_id) do
Electric.ProcessRegistry.name(stack_id, __MODULE__)
end
def set_last_processed_lsn(server_ref, last_processed_lsn) do
GenServer.call(server(server_ref), {:set_last_processed_lsn, last_processed_lsn})
end
# use `GenServer.call/2` here to make the event processing synchronous.
#
# This `call/3` has a timeout of `:infinity` because timeouts are
# handled at the storage layer, that is this function doesn't
# assume any aggregate max time for the shape consumers to actually commit
# the new txn to disk, instead the storage backend is responsible for
# determining how long a write should reasonably take and if that fails
# it should raise.
def store_transaction(%Transaction{} = txn, server) do
timer =
OpenTelemetry.extract_interval_timer()
|> IntervalTimer.start_interval("shape_log_collector.transaction_message")
trace_context = OpenTelemetry.get_current_context()
{response, timer} = GenServer.call(server, {:new_txn, txn, trace_context, timer}, :infinity)
OpenTelemetry.set_interval_timer(timer)
response
end
def handle_relation_msg(%Changes.Relation{} = rel, server) do
trace_context = OpenTelemetry.get_current_context()
:ok = GenServer.call(server, {:relation_msg, rel, trace_context}, :infinity)
end
# shapes that are being restored are already in the filters
# because they were restored from the ets at startup
def subscribe(_server_ref, _shape_handle, _shape, :restore) do
:ok
end
# new shapes -- created after boot -- do need to be added
def subscribe(server_ref, shape_handle, shape, :create) do
GenServer.call(server(server_ref), {:subscribe, shape_handle, shape})
end
def remove_shape(server_ref, shape_handle) do
# This has to be async otherwise the system will deadlock -
# - a consumer being cleanly shutdown may be waiting for a response from ShapeLogCollector
# while ShapeLogCollector is waiting for an ack from a transaction event, or
# - a consumer that has crashed will be waiting in a terminate callback
# for a reply from the unsubscribe while the ShapeLogCollector is again
# waiting for a txn ack.
GenServer.cast(server(server_ref), {:remove_shape, shape_handle})
end
def notify_flushed(server_ref, shape_handle, offset) do
GenServer.cast(server(server_ref), {:writer_flushed, shape_handle, offset})
end
def active_shapes(server_ref) do
GenServer.call(server(server_ref), :active_shapes)
end
def init(opts) do
activate_mocked_functions_from_test_process()
stack_id = opts.stack_id
Process.set_label({:shape_log_collector, stack_id})
Logger.metadata(stack_id: stack_id)
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
persistent_replication_data_opts = [
stack_id: stack_id,
persistent_kv: opts.persistent_kv
]
{:ok, tracker_state} =
persistent_replication_data_opts
|> PersistentReplicationState.get_tracked_relations()
|> AffectedColumns.init()
{:ok, registry_state} =
ConsumerRegistry.new(stack_id, Map.get(opts, :consumer_registry_opts, []))
state =
Map.merge(opts, %{
subscriptions: 0,
persistent_replication_data_opts: persistent_replication_data_opts,
tracked_relations: tracker_state,
partitions: Partitions.new(Keyword.new(opts)),
dependency_layers: DependencyLayers.new(),
pids_by_shape_handle: %{},
filter:
opts
|> Map.put(:refs_fun, &Materializer.get_all_as_refs(&1, stack_id))
|> Keyword.new()
|> Filter.new(),
flush_tracker:
FlushTracker.new(
notify_fn: fn lsn ->
case GenServer.whereis(ReplicationClient.name(stack_id)) do
nil -> :ok
pid -> send(pid, {:flush_boundary_updated, lsn})
end
end
),
registry_state: registry_state
})
{:ok, state, {:continue, :restore_shapes}}
end
def handle_continue(:restore_shapes, state) do
OpenTelemetry.with_span(
"shape_log_collector.restore_shapes",
[],
state.stack_id,
fn ->
{partitions, filter, layers, count} =
state.stack_id
|> Electric.ShapeCache.ShapeStatus.list_shapes()
|> Enum.reduce(
{state.partitions, state.filter, state.dependency_layers, 0},
fn {shape_handle, shape}, {partitions, filter, layers, count} ->
{:ok, partitions} = Partitions.add_shape(partitions, shape_handle, shape)
{
partitions,
Filter.add_shape(filter, shape_handle, shape),
DependencyLayers.add_dependency(layers, shape, shape_handle),
count + 1
}
end
)
Logger.info("Restored filters for #{count} shapes")
{:noreply,
%{
state
| partitions: partitions,
filter: filter,
dependency_layers: layers,
subscriptions: count
}}
end
)
end
def handle_call({:subscribe, shape_handle, shape}, {pid, _ref}, state) do
OpenTelemetry.with_span(
"shape_log_collector.subscribe",
[shape_handle: shape_handle],
state.stack_id,
fn ->
case Partitions.add_shape(state.partitions, shape_handle, shape) do
{:ok, partitions} ->
:ok = ConsumerRegistry.register_consumer(shape_handle, pid, state.registry_state)
state =
%{
state
| partitions: partitions,
filter: Filter.add_shape(state.filter, shape_handle, shape),
dependency_layers:
DependencyLayers.add_dependency(state.dependency_layers, shape, shape_handle)
}
|> Map.update!(:subscriptions, &(&1 + 1))
|> log_subscription_status()
{:reply, :ok, state}
{:error, :connection_not_available} ->
{:reply, {:error, :connection_not_available}, state}
end
end
)
end
def handle_call({:set_last_processed_lsn, lsn}, _from, state) do
LsnTracker.set_last_processed_lsn(lsn, state.stack_id)
Electric.StatusMonitor.mark_shape_log_collector_ready(state.stack_id, self())
{:reply, :ok, Map.put(state, :last_processed_lsn, lsn)}
end
def handle_call({:new_txn, _, _, timer}, _from, state) when not is_ready_to_process(state) do
{:reply, {{:error, :not_ready}, timer}, state}
end
def handle_call(
{:new_txn, %Transaction{xid: xid, lsn: lsn} = txn, trace_context, timer},
_from,
state
) do
OpenTelemetry.set_interval_timer(timer)
OpenTelemetry.start_interval("shape_log_collector.set_current_context")
OpenTelemetry.set_current_context(trace_context)
OpenTelemetry.start_interval("shape_log_collector.logging")
Logger.debug(
fn ->
"Received transaction #{xid} (#{txn.num_changes} changes) from Postgres at #{lsn}"
end,
received_transaction_xid: xid,
received_transaction_num_changes: txn.num_changes,
received_transaction_lsn: lsn
)
Logger.debug(fn -> "Txn received in ShapeLogCollector: #{inspect(txn)}" end)
{response, state} = handle_transaction(state, txn)
OpenTelemetry.start_interval("shape_log_collector.transaction_message_response")
{:reply, {response, OpenTelemetry.extract_interval_timer()}, state}
end
def handle_call({:relation_msg, _, _}, _from, state) when not is_ready_to_process(state) do
{:reply, {:error, :not_ready}, state}
end
def handle_call({:relation_msg, %Relation{} = rel, trace_context}, from, state) do
OpenTelemetry.set_current_context(trace_context)
Logger.info("Received relation #{inspect(rel.schema)}.#{inspect(rel.table)}")
Logger.debug(fn -> "Relation received in ShapeLogCollector: #{inspect(rel)}" end)
{response, state} = handle_relation(rel, from, state)
{:reply, response, state}
end
def handle_call(:active_shapes, _from, state) do
{:reply, Filter.active_shapes(state.filter), state}
end
def handle_cast({:writer_flushed, shape_id, offset}, state) do
{:noreply,
state
|> Map.update!(:flush_tracker, &FlushTracker.handle_flush_notification(&1, shape_id, offset))}
end
def handle_cast({:remove_shape, shape_handle}, state) do
state =
case remove_subscription(state, shape_handle) do
{:ok, state} -> state
{:error, _} -> state
end
{:noreply, state}
end
# If no-one is listening to the replication stream, then just return without
# emitting the transaction.
defp handle_transaction(%{subscriptions: 0} = state, txn) do
Logger.debug(fn -> "Dropping transaction #{txn.xid}: no active consumers" end)
OpenTelemetry.add_span_attributes("txn.is_dropped": true)
{:ok, %{state | flush_tracker: FlushTracker.handle_transaction(state.flush_tracker, txn, [])}}
end
# If we've already processed a transaction, then drop it without processing
defp handle_transaction(%{last_processed_lsn: last_processed_lsn} = state, txn)
when not Lsn.is_larger(txn.lsn, last_processed_lsn) do
Logger.debug(fn ->
"Dropping transaction #{txn.xid}: transaction LSN #{txn.lsn} smaller than last processed #{last_processed_lsn}"
end)
OpenTelemetry.add_span_attributes("txn.is_dropped": true)
{:ok, %{state | flush_tracker: FlushTracker.handle_transaction(state.flush_tracker, txn, [])}}
end
defp handle_transaction(state, txn) do
OpenTelemetry.add_span_attributes("txn.is_dropped": false)
OpenTelemetry.start_interval("shape_log_collector.fill_keys_in_txn")
case fill_keys_in_txn(txn, state) do
{:ok, txn} ->
OpenTelemetry.start_interval("partitions.handle_transaction")
{partitions, txn} = Partitions.handle_transaction(state.partitions, txn)
state =
state
|> Map.put(:partitions, partitions)
|> put_last_processed_lsn(txn.lsn)
|> publish(txn)
{:ok, state}
{:error, :connection_not_available} ->
{{:error, :connection_not_available}, state}
end
end
defp publish(state, event) do
OpenTelemetry.start_interval("shape_log_collector.affected_shapes")
affected_shapes = Filter.affected_shapes(state.filter, event)
affected_shape_count = MapSet.size(affected_shapes)
OpenTelemetry.add_span_attributes(
"shape_log_collector.affected_shape_count": affected_shape_count
)
OpenTelemetry.start_interval("shape_log_collector.publish")
context = OpenTelemetry.get_current_context()
for layer <- DependencyLayers.get_for_handles(state.dependency_layers, affected_shapes) do
# Each publish is synchronous, so layers will be processed in order
ConsumerRegistry.publish(layer, {:handle_event, event, context}, state.registry_state)
end
OpenTelemetry.start_interval("shape_log_collector.set_last_processed_lsn")
LsnTracker.set_last_processed_lsn(state.last_processed_lsn, state.stack_id)
flush_tracker =
if is_struct(event, Transaction) do
FlushTracker.handle_transaction(state.flush_tracker, event, affected_shapes)
else
state.flush_tracker
end
%{state | flush_tracker: flush_tracker}
end
defp handle_relation(rel, _from, state) do
OpenTelemetry.add_span_attributes("rel.is_dropped": false)
{updated_rel, tracker_state} =
AffectedColumns.transform_relation(rel, state.tracked_relations)
# PG doesn't send all the details in the relation message (in particular, nullability), but
# it will send a message even if the relation is unchanged. So if we see a relation message that's not
# changed, it might be after a reconnection, or it might be because something actually changed.
# In either case, we need to clean the inspector cache so we get the latest info.
if rel == updated_rel do
Inspector.clean(updated_rel.id, state.inspector)
end
:ok =
PersistentReplicationState.set_tracked_relations(
tracker_state,
state.persistent_replication_data_opts
)
case state do
%{subscriptions: 0} ->
Logger.debug(fn ->
"Dropping relation message for #{inspect(rel.schema)}.#{inspect(rel.table)}: no active consumers"
end)
{:ok, %{state | tracked_relations: tracker_state}}
_ ->
case Partitions.handle_relation(state.partitions, updated_rel) do
{:ok, partitions} ->
# relation changes will also start consumers if they're not running
state =
publish(
%{state | tracked_relations: tracker_state, partitions: partitions},
updated_rel
)
{:ok, state}
{:error, :connection_not_available} ->
{{:error, :connection_not_available}, state}
end
end
end
defp remove_subscription(%{subscriptions: count} = state, shape_handle) do
OpenTelemetry.with_span(
"shape_log_collector.remove_shape",
[shape_handle: shape_handle],
state.stack_id,
fn ->
if Filter.has_shape?(state.filter, shape_handle) do
Logger.debug("Deleting shape #{shape_handle}")
OpenTelemetry.start_interval("unsubscribe_shape.remove_subscription")
OpenTelemetry.start_interval("unsubscribe_shape.remove_from_filter")
filter = Filter.remove_shape(state.filter, shape_handle)
OpenTelemetry.start_interval("unsubscribe_shape.remove_from_partitions")
partitions = Partitions.remove_shape(state.partitions, shape_handle)
OpenTelemetry.start_interval("unsubscribe_shape.remove_pids_by_shape_handle")
pids_by_shape_handle = Map.delete(state.pids_by_shape_handle, shape_handle)
OpenTelemetry.start_interval("unsubscribe_shape.remove_from_flush_tracker")
flush_tracker = FlushTracker.handle_shape_removed(state.flush_tracker, shape_handle)
OpenTelemetry.start_interval("unsubscribe_shape.remove_from_dependency_layers")
dependency_layers =
DependencyLayers.remove_dependency(state.dependency_layers, shape_handle)
Electric.Shapes.ConsumerRegistry.remove_consumer(shape_handle, state.registry_state)
OpenTelemetry.stop_and_save_intervals(
total_attribute: "unsubscribe_shape.total_duration_µs"
)
{:ok,
%{
state
| subscriptions: count - 1,
filter: filter,
partitions: partitions,
pids_by_shape_handle: pids_by_shape_handle,
dependency_layers: dependency_layers,
flush_tracker: flush_tracker
}
|> log_subscription_status()}
else
# This may happen as we attempt to remove a shape multiple times
# depending on the source of the delete, on the understanding that
# removal is idempotent.
{:error, "shape #{shape_handle} not registered"}
end
end
)
end
defp log_subscription_status(%{subscriptions: active} = state) do
Logger.debug(fn ->
"#{active} consumers of replication stream"
end)
state
end
defp put_last_processed_lsn(%{last_processed_lsn: last_processed_lsn} = state, lsn)
when Lsn.is_larger(lsn, last_processed_lsn),
do: %{state | last_processed_lsn: lsn}
defp put_last_processed_lsn(state, _lsn), do: state
defp server(stack_id) when is_binary(stack_id), do: name(stack_id)
defp server({:via, _, _} = name), do: name
defp server(pid) when is_pid(pid), do: pid
if Mix.env() == :test do
def activate_mocked_functions_from_test_process do
Support.TestUtils.activate_mocked_functions_for_module(__MODULE__)
end
else
def activate_mocked_functions_from_test_process, do: :noop
end
defp fill_keys_in_txn(txn, state) do
with {:ok, pk_cols_of_relations} <- pk_cols_of_relations(txn, state) do
txn =
Map.update!(txn, :changes, fn changes ->
Enum.map(changes, &Changes.fill_key(&1, pk_cols_of_relations[&1.relation]))
end)
{:ok, txn}
end
end
defp pk_cols_of_relations(txn, state) do
txn.affected_relations
|> map_while_ok(fn relation ->
with {:ok, pk_cols} <- pk_cols_of_relation(relation, state) do
{:ok, {relation, pk_cols}}
end
end)
|> map_if_ok(&Map.new/1)
end
defp pk_cols_of_relation(relation, state) do
with {:ok, {oid, _}} <- Inspector.load_relation_oid(relation, state.inspector),
{:ok, info} <- Inspector.load_column_info(oid, state.inspector) do
{:ok, Inspector.get_pk_cols(info)}
end
end
end