Packages
electric
1.0.18
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/shape_cache.ex
defmodule Electric.ShapeCacheBehaviour do
@moduledoc """
Behaviour defining the ShapeCache functions to be used in mocks
"""
alias Electric.Shapes.Shape
alias Electric.Replication.LogOffset
@type shape_handle :: String.t()
@type shape_def :: Shape.t()
@callback get_shape(shape_def(), opts :: Access.t()) ::
{shape_handle(), current_snapshot_offset :: LogOffset.t()} | nil
@callback get_or_create_shape_handle(shape_def(), opts :: Access.t()) ::
{shape_handle(), current_snapshot_offset :: LogOffset.t()}
@callback list_shapes(keyword() | map()) :: [{shape_handle(), Shape.t()}] | :error
@callback await_snapshot_start(shape_handle(), opts :: Access.t()) ::
:started | {:error, term()}
@callback clean_shape(shape_handle(), Access.t()) :: :ok
@callback clean_all_shapes_for_relations(list(Electric.oid_relation()), opts :: Access.t()) ::
:ok
@callback clean_all_shapes(Access.t()) :: :ok
@callback has_shape?(shape_handle(), Access.t()) :: boolean()
end
defmodule Electric.ShapeCache do
use GenServer
alias Electric.Postgres.Lsn
alias Electric.Replication.LogOffset
alias Electric.Replication.ShapeLogCollector
alias Electric.ShapeCache.ShapeStatus
alias Electric.Shapes
alias Electric.Shapes.Shape
alias Electric.Telemetry.OpenTelemetry
require Logger
@behaviour Electric.ShapeCacheBehaviour
@type shape_handle :: Electric.ShapeCacheBehaviour.shape_handle()
@name_schema_tuple {:tuple, [:atom, :atom, :any]}
@genserver_name_schema {:or, [:atom, @name_schema_tuple]}
@schema NimbleOptions.new!(
name: [
type: @genserver_name_schema,
required: false
],
stack_id: [type: :string, required: true],
log_producer: [type: @genserver_name_schema, required: true],
consumer_supervisor: [type: @genserver_name_schema, required: true],
storage: [type: :mod_arg, required: true],
publication_manager: [type: :mod_arg, required: true],
chunk_bytes_threshold: [type: :non_neg_integer, required: true],
inspector: [type: :mod_arg, required: true],
shape_status: [type: :atom, default: Electric.ShapeCache.ShapeStatus],
registry: [type: {:or, [:atom, :pid]}, required: true],
db_pool: [type: {:or, [:atom, :pid, @name_schema_tuple]}],
run_with_conn_fn: [
type: {:fun, 2},
default: &Shapes.Consumer.Snapshotter.run_with_conn/2
],
create_snapshot_fn: [
type: {:fun, 7},
default: &Shapes.Consumer.Snapshotter.query_in_readonly_txn/7
],
purge_all_shapes?: [type: :boolean, required: false],
max_shapes: [type: {:or, [:non_neg_integer, nil]}, default: nil],
recover_shape_timeout: [
type: {:or, [:non_neg_integer, {:in, [:infinity]}]},
default: 5_000
]
)
# under load some of the storage functions, particularly the create calls,
# can take a long time to complete (I've seen 20s locally, just due to minor
# filesystem calls like `ls` taking multiple seconds). Most complete in a
# timely manner but rather than raise for the edge cases and generate
# unnecessary noise let's just cover those tail timings with our timeout.
@call_timeout 30_000
def name(stack_id) when not is_map(stack_id) and not is_list(stack_id) do
Electric.ProcessRegistry.name(stack_id, __MODULE__)
end
def name(opts) do
stack_id = Access.fetch!(opts, :stack_id)
name(stack_id)
end
def start_link(opts) do
with {:ok, opts} <- NimbleOptions.validate(opts, @schema) do
stack_id = Keyword.fetch!(opts, :stack_id)
name = Keyword.get(opts, :name, name(stack_id))
db_pool = Keyword.get(opts, :db_pool, Electric.Connection.Manager.pool_name(stack_id))
GenServer.start_link(__MODULE__, [name: name, db_pool: db_pool] ++ opts, name: name)
end
end
@impl Electric.ShapeCacheBehaviour
def get_shape(shape, opts \\ []) do
table = get_shape_meta_table(opts)
shape_status = Access.get(opts, :shape_status, ShapeStatus)
shape_status.get_existing_shape(table, shape)
end
@impl Electric.ShapeCacheBehaviour
def get_or_create_shape_handle(shape, opts \\ []) do
# Get or create the shape handle and fire a snapshot if necessary
if shape_state = get_shape(shape, opts) do
shape_state
else
server = Access.get(opts, :server, name(opts))
GenStage.call(server, {:create_or_wait_shape_handle, shape, opts[:otel_ctx]}, @call_timeout)
end
end
@impl Electric.ShapeCacheBehaviour
@spec list_shapes(Access.t()) :: [{shape_handle(), Shape.t()}] | :error
def list_shapes(opts) do
shape_status = Access.get(opts, :shape_status, ShapeStatus)
shape_status.list_shapes(%ShapeStatus{shape_meta_table: get_shape_meta_table(opts)})
rescue
ArgumentError -> :error
end
@impl Electric.ShapeCacheBehaviour
@spec clean_shape(shape_handle(), Access.t()) :: :ok
def clean_shape(shape_handle, opts) do
server = Access.get(opts, :server, name(opts))
GenStage.call(server, {:clean, shape_handle}, @call_timeout)
end
@impl Electric.ShapeCacheBehaviour
@spec clean_all_shapes(Access.t()) :: :ok
def clean_all_shapes(opts) do
server = Access.get(opts, :server, name(opts))
GenServer.call(server, :clean_all_shapes)
end
@impl Electric.ShapeCacheBehaviour
@spec clean_all_shapes_for_relations(list(Electric.oid_relation()), Access.t()) :: :ok
def clean_all_shapes_for_relations(relations, opts) do
server = Access.get(opts, :server, name(opts))
# We don't want for this call to be blocking because it will be called in `PublicationManager`
# if it notices a discrepancy in the schema
GenServer.cast(server, {:clean_all_shapes_for_relations, relations})
end
@impl Electric.ShapeCacheBehaviour
@spec await_snapshot_start(shape_handle(), Access.t()) :: :started | {:error, term()}
def await_snapshot_start(shape_handle, opts \\ []) when is_binary(shape_handle) do
table = get_shape_meta_table(opts)
shape_status = Access.get(opts, :shape_status, ShapeStatus)
stack_id = Access.fetch!(opts, :stack_id)
shape_status.update_last_read_time_to_now(table, shape_handle)
cond do
shape_status.snapshot_started?(table, shape_handle) ->
:started
!shape_status.get_existing_shape(table, shape_handle) ->
{:error, :unknown}
true ->
server = Electric.Shapes.Consumer.name(stack_id, shape_handle)
try do
GenServer.call(server, :await_snapshot_start, 15_000)
catch
:exit, {:timeout, {GenServer, :call, _}} ->
Logger.error("Failed to await snapshot start for shape #{shape_handle}: timeout")
{:error, %RuntimeError{message: "Timed out while waiting for snapshot to start"}}
:exit, {:noproc, _} ->
# The fact that we got the shape handle means we know the shape exists, and the process should
# exist too. We can get here if registry didn't propagate registration across partitions yet, so
# we'll just retry after waiting for a short time to avoid busy waiting.
Process.sleep(50)
await_snapshot_start(shape_handle, opts)
end
end
end
@impl Electric.ShapeCacheBehaviour
def has_shape?(shape_handle, opts \\ []) do
table = get_shape_meta_table(opts)
shape_status = Access.get(opts, :shape_status, ShapeStatus)
if shape_status.get_existing_shape(table, shape_handle) do
true
else
server = Access.get(opts, :server, name(opts))
GenStage.call(server, {:wait_shape_handle, shape_handle}, @call_timeout)
end
end
@impl GenServer
def init(opts) do
opts = Map.new(opts)
stack_id = opts.stack_id
meta_table = :ets.new(:"#{stack_id}:shape_meta_table", [:named_table, :public, :ordered_set])
Process.set_label({:shape_cache, stack_id})
Logger.metadata(stack_id: stack_id)
Electric.Telemetry.Sentry.set_tags_context(stack_id: stack_id)
{:ok, shape_status_state} =
opts.shape_status.initialise(
shape_meta_table: meta_table,
storage: opts.storage
)
state = %{
name: opts.name,
stack_id: opts.stack_id,
storage: opts.storage,
publication_manager: opts.publication_manager,
chunk_bytes_threshold: opts.chunk_bytes_threshold,
inspector: opts.inspector,
shape_meta_table: meta_table,
shape_status: opts.shape_status,
db_pool: opts.db_pool,
shape_status_state: shape_status_state,
run_with_conn_fn: opts.run_with_conn_fn,
create_snapshot_fn: opts.create_snapshot_fn,
log_producer: opts.log_producer,
registry: opts.registry,
consumer_supervisor: opts.consumer_supervisor,
subscription: nil,
max_shapes: opts.max_shapes
}
last_processed_lsn =
if opts[:purge_all_shapes?] do
purge_all_shapes(state)
Lsn.from_integer(0)
else
recover_shapes(state, opts.recover_shape_timeout)
end
# ensure publication filters are in line with existing shapes,
# and clean up cache if publication fails to update
{publication_manager, publication_manager_opts} = opts.publication_manager
try do
:ok = publication_manager.refresh_publication(publication_manager_opts)
rescue
error ->
purge_all_shapes(state)
reraise error, __STACKTRACE__
catch
:exit, reason ->
purge_all_shapes(state)
exit(reason)
end
# Let ShapeLogCollector that it can start processing after finishing this function so that
# we're subscribed to the producer before it starts forwarding its demand.
{:ok, state, {:continue, {:consumers_ready, last_processed_lsn}}}
end
@impl GenServer
def handle_continue({:consumers_ready, last_processed_lsn}, state) do
ShapeLogCollector.start_processing(state.log_producer, last_processed_lsn)
{:noreply, state}
end
@impl GenServer
def handle_info(:maybe_expire_shapes, state) do
maybe_expire_shapes(state)
{:noreply, state}
end
@impl GenServer
def handle_call(
{:create_or_wait_shape_handle, shape, otel_ctx},
_from,
%{shape_status: shape_status} = state
) do
{{shape_handle, latest_offset}, state} =
if shape_state = shape_status.get_existing_shape(state.shape_status_state, shape) do
{shape_state, state}
else
{:ok, shape_handle} = shape_status.add_shape(state.shape_status_state, shape)
{:ok, latest_offset} = start_shape(shape_handle, shape, state, otel_ctx)
send(self(), :maybe_expire_shapes)
{{shape_handle, latest_offset}, state}
end
Logger.debug("Returning shape id #{shape_handle} for shape #{inspect(shape)}")
{:reply, {shape_handle, latest_offset}, state}
end
def handle_call(
{:wait_shape_handle, shape_handle},
_from,
%{shape_status: shape_status} = state
) do
{:reply, !is_nil(shape_status.get_existing_shape(state.shape_status_state, shape_handle)),
state}
end
def handle_call({:clean, shape_handle}, _from, state) do
# ignore errors when cleaning up non-existant shape id
with :ok <- clean_up_shape(state, shape_handle) do
Logger.info("Cleaning up shape #{shape_handle}")
end
{:reply, :ok, state}
end
def handle_call(:clean_all_shapes, _from, state) do
Logger.warning("Purging all shapes.")
clean_up_all_shapes(state)
{:reply, :ok, state}
end
@impl GenServer
def handle_cast({:clean_all_shapes_for_relations, relations}, state) do
affected_shapes =
state.shape_status_state
|> state.shape_status.list_shape_handles_for_relations(relations)
if relations != [] do
Logger.info(fn ->
"Cleaning up all shapes for relations #{inspect(relations)}: #{length(affected_shapes)} shapes total"
end)
end
Enum.each(affected_shapes, fn shape_handle ->
clean_up_shape(state, shape_handle)
end)
{:noreply, state}
end
defp maybe_expire_shapes(%{max_shapes: max_shapes} = state) when max_shapes != nil do
shape_count = shape_count(state)
if shape_count > max_shapes do
number_to_expire = shape_count - max_shapes
state.shape_status_state
|> state.shape_status.least_recently_used(number_to_expire)
|> Enum.each(fn shape ->
OpenTelemetry.with_span(
"expiring_shape",
[
shape_handle: shape.shape_handle,
max_shapes: max_shapes,
shape_count: shape_count,
elapsed_minutes_since_use: shape.elapsed_minutes_since_use
],
fn ->
Logger.info(
"Expiring shape #{shape.shape_handle} as as the number of shapes " <>
"has exceeded the limit (#{state.max_shapes})"
)
clean_up_shape(state, shape.shape_handle)
end
)
end)
end
end
defp maybe_expire_shapes(_), do: :ok
defp shape_count(%{shape_status: shape_status, shape_status_state: shape_status_state}) do
shape_status_state
|> shape_status.list_shapes()
|> length()
end
defp clean_up_shape(state, shape_handle) do
# remove the shape immediately so new clients are redirected elsewhere
deregister_shape(shape_handle, state)
Electric.Shapes.DynamicConsumerSupervisor.stop_shape_consumer(
state.consumer_supervisor,
state.stack_id,
shape_handle
)
:ok
end
# reset shape storage before any consumer have been started
defp purge_all_shapes(state) do
Logger.warning("Purging all shapes.")
for {shape_handle, shape} <- shape_handles(state) do
purge_shape(state, shape_handle, shape)
end
state
end
defp purge_shape(state, shape_handle, shape) do
case Electric.Shapes.ConsumerSupervisor.stop_and_clean(state.stack_id, shape_handle) do
:noproc ->
# if the consumer isn't running then we can just delete things gratuitously
:ok = Electric.Shapes.Monitor.purge_shape(state.stack_id, shape_handle, shape)
:ok ->
# if it is running then the stop_and_clean process will cleanup properly
:ok
end
state
end
defp clean_up_all_shapes(state) do
for {shape_handle, _shape} <- shape_handles(state) do
clean_up_shape(state, shape_handle)
end
end
defp shape_handles(state) do
state.shape_status_state |> state.shape_status.list_shapes()
end
defp recover_shapes(state, timeout) do
state.shape_status_state
|> state.shape_status.list_shapes()
|> Enum.flat_map(fn {shape_handle, shape} ->
start_shape_with_timeout(shape_handle, shape, state, timeout)
end)
|> Lsn.max()
end
# the shape cache loads existing shapes within its init/1 callback
# which is useful because we know that when the start_link call on the
# stack completes the system is fully booted and all shape consumers are
# running.
#
# rather than set a global timeout for the `init/1` function we leave the
# `start_link/1` timeout as `:infinity` and instead have a timeout for every
# shape
defp start_shape_with_timeout(shape_handle, shape, state, timeout) do
task = Task.async(fn -> start_and_recover_shape(shape_handle, shape, state) end)
# since we catch errors in the task we don't need to handle the error state here
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, lsn} ->
lsn
nil ->
Logger.error(
"shape #{inspect(shape)} (#{inspect(shape_handle)}) failed to start within #{timeout}ms"
)
purge_shape(state, shape_handle, shape)
[]
end
end
defp start_and_recover_shape(shape_handle, shape, state) do
%{publication_manager: {publication_manager, publication_manager_opts}} = state
case start_shape(shape_handle, shape, state) do
{:ok, latest_offset} ->
publication_manager.recover_shape(shape_handle, shape, publication_manager_opts)
[LogOffset.extract_lsn(latest_offset)]
:error ->
[]
end
catch
# exception can only come from the receover_shape call
# if the shape consumer failed to start for some reason
# start_shape/4 will have returned an error
kind, reason when kind in [:exit, :error] ->
Logger.error(
"Failed to recover shape #{shape_handle}: #{Exception.format(kind, reason, __STACKTRACE__)}"
)
# clean up corrupted data to avoid persisting bad state
purge_shape(state, shape_handle, shape)
[]
end
defp start_shape(shape_handle, shape, state, otel_ctx \\ nil) do
case Electric.Shapes.DynamicConsumerSupervisor.start_shape_consumer(
state.consumer_supervisor,
stack_id: state.stack_id,
inspector: state.inspector,
shape_handle: shape_handle,
shape: shape,
shape_status: {state.shape_status, state.shape_status_state},
storage: state.storage,
publication_manager: state.publication_manager,
chunk_bytes_threshold: state.chunk_bytes_threshold,
log_producer: state.log_producer,
registry: state.registry,
db_pool: state.db_pool,
run_with_conn_fn: state.run_with_conn_fn,
create_snapshot_fn: state.create_snapshot_fn,
otel_ctx: otel_ctx
) do
{:ok, _supervisor_pid} ->
consumer = Shapes.Consumer.name(state.stack_id, shape_handle)
{:ok, _latest_offset} = Shapes.Consumer.initial_state(consumer)
{:error, _reason} = error ->
Logger.error("Failed to start shape #{shape_handle}: #{inspect(error)}")
# purge because we know the consumer isn't running
purge_shape(state, shape_handle, shape)
:error
end
end
defp deregister_shape(shape_handle, state) do
state.shape_status.remove_shape(state.shape_status_state, shape_handle)
end
def get_shape_meta_table(opts),
do: opts[:shape_meta_table] || :"#{opts[:stack_id]}:shape_meta_table"
end