Packages
electric
1.4.8
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/filter.ex
defmodule Electric.Shapes.Filter do
@moduledoc """
Responsible for knowing which shapes are affected by a change.
`affected_shapes(filter, change)` will return a set of IDs for the shapes that are affected by the change
considering all the shapes that have been added to the filter using `add_shape/3`.
The `Filter` module keeps track of what tables are referenced by the shapes and changes and delegates
the table specific logic to the `Filter.WhereCondition` module.
Data is stored in ETS tables (outside the process heap) to avoid GC pressure with large numbers of shapes.
"""
alias Electric.Replication.Changes
alias Electric.Replication.Changes.DeletedRecord
alias Electric.Replication.Changes.NewRecord
alias Electric.Replication.Changes.Relation
alias Electric.Replication.Changes.TruncatedRelation
alias Electric.Replication.Changes.UpdatedRecord
alias Electric.Shapes.Filter
alias Electric.Shapes.Filter.WhereCondition
alias Electric.Shapes.Shape
alias Electric.Telemetry.OpenTelemetry
require Logger
defstruct [
:shapes_table,
:tables_table,
:where_cond_table,
:eq_index_table,
:incl_index_table,
:refs_fun
]
@type t :: %Filter{}
@type shape_id :: any()
@spec new(keyword()) :: Filter.t()
def new(opts \\ []) do
%Filter{
shapes_table: :ets.new(:filter_shapes, [:set, :private]),
tables_table: :ets.new(:filter_tables, [:set, :private]),
where_cond_table: :ets.new(:filter_where, [:set, :private]),
eq_index_table: :ets.new(:filter_eq, [:set, :private]),
incl_index_table: :ets.new(:filter_incl, [:set, :private]),
refs_fun: Keyword.get(opts, :refs_fun, fn _shape -> %{} end)
}
end
@spec has_shape?(t(), shape_id()) :: boolean()
def has_shape?(%Filter{shapes_table: table}, shape_handle) do
:ets.member(table, shape_handle)
end
@spec active_shapes(t()) :: [shape_id()]
def active_shapes(%Filter{shapes_table: table}) do
:ets.select(table, [{{:"$1", :_}, [], [:"$1"]}])
end
@doc """
Add a shape for the filter to track.
The `shape_id` can be any term you like to identify the shape. Whatever you use will be returned
by `affected_shapes/2` when the shape is affected by a change.
"""
@spec add_shape(Filter.t(), shape_id(), Shape.t()) :: Filter.t()
def add_shape(%Filter{} = filter, shape_id, shape) do
if has_shape?(filter, shape_id), do: raise("duplicate shape #{shape_id}")
:ets.insert(filter.shapes_table, {shape_id, shape})
where_cond_id = get_or_create_table_condition(filter, shape.root_table)
WhereCondition.add_shape(filter, where_cond_id, shape_id, shape.where)
filter
end
defp get_or_create_table_condition(filter, table_name) do
case :ets.lookup(filter.tables_table, table_name) do
[] ->
where_cond_id = make_ref()
WhereCondition.init(filter, where_cond_id)
:ets.insert(filter.tables_table, {table_name, where_cond_id})
where_cond_id
[{_, where_cond_id}] ->
where_cond_id
end
end
@doc """
Remove a shape from the filter.
"""
@spec remove_shape(Filter.t(), shape_id()) :: Filter.t()
def remove_shape(%Filter{} = filter, shape_id) do
[{_, shape}] = :ets.lookup(filter.shapes_table, shape_id)
table_name = shape.root_table
[{_, where_cond_id}] = :ets.lookup(filter.tables_table, table_name)
case WhereCondition.remove_shape(filter, where_cond_id, shape_id, shape.where) do
:deleted -> :ets.delete(filter.tables_table, table_name)
:ok -> :ok
end
:ets.delete(filter.shapes_table, shape_id)
filter
end
@doc """
Returns the shape IDs for all shapes that have been added to the filter
that are affected by the given change.
"""
@spec affected_shapes(Filter.t(), Changes.change() | Relation.t()) ::
MapSet.t(shape_id())
def affected_shapes(%Filter{} = filter, change) do
OpenTelemetry.timed_fun("filter.affected_shapes.duration_µs", fn ->
try do
shapes_affected_by_change(filter, change)
catch
kind, error ->
Logger.error("""
Unexpected error in Filter.affected_shapes:
#{Exception.format(kind, error, __STACKTRACE__)}
""")
OpenTelemetry.record_exception(kind, error, __STACKTRACE__)
# We can't tell which shapes are affected, the safest thing to do is return all shapes
all_shape_ids(filter)
end
end)
end
defp shapes_affected_by_change(%Filter{} = filter, %Relation{} = relation) do
# Check all shapes is all tables because the table may have been renamed
for shape_id <- all_shape_ids(filter),
[{_, shape}] = :ets.lookup(filter.shapes_table, shape_id),
Shape.is_affected_by_relation_change?(shape, relation),
into: MapSet.new() do
shape_id
end
end
defp shapes_affected_by_change(%Filter{} = filter, %NewRecord{
relation: relation,
record: record
}) do
shapes_affected_by_record(filter, relation, record)
end
defp shapes_affected_by_change(%Filter{} = filter, %DeletedRecord{
relation: relation,
old_record: record
}) do
shapes_affected_by_record(filter, relation, record)
end
defp shapes_affected_by_change(%Filter{} = filter, %UpdatedRecord{relation: relation} = change) do
MapSet.union(
shapes_affected_by_record(filter, relation, change.record),
shapes_affected_by_record(filter, relation, change.old_record)
)
end
defp shapes_affected_by_change(%Filter{} = filter, %TruncatedRelation{relation: table_name}) do
shape_ids_for_table(filter, table_name)
end
defp shapes_affected_by_record(filter, table_name, record) do
case :ets.lookup(filter.tables_table, table_name) do
[] ->
MapSet.new()
[{_, where_cond_id}] ->
WhereCondition.affected_shapes(filter, where_cond_id, record)
end
end
defp all_shape_ids(%Filter{} = filter) do
:ets.foldl(
fn {_table_name, where_cond_id}, acc ->
MapSet.union(acc, WhereCondition.all_shape_ids(filter, where_cond_id))
end,
MapSet.new(),
filter.tables_table
)
end
defp shape_ids_for_table(%Filter{} = filter, table_name) do
case :ets.lookup(filter.tables_table, table_name) do
[] -> MapSet.new()
[{_, where_cond_id}] -> WhereCondition.all_shape_ids(filter, where_cond_id)
end
end
@doc """
Get a shape by its ID. Used internally for where clause evaluation.
"""
def get_shape(%Filter{shapes_table: table}, shape_id) do
case :ets.lookup(table, shape_id) do
[{_, shape}] -> shape
[] -> nil
end
end
end