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/shapes/partitions.ex
defmodule Electric.Shapes.Partitions do
@moduledoc ~S"""
Keeps track of shapes defined on partitioned tables and re-writes
transactions to send an equivalent change on the root partitioned table for
every change to a partition of that table.
"""
alias Electric.Postgres.Inspector
alias Electric.Replication.Changes.Relation
alias Electric.Replication.Changes.Transaction
alias Electric.Replication.Changes.TruncatedRelation
defstruct [:inspector, active: 0, partitions: %{}, partition_ownership: %{}]
@type partition_table :: Electric.relation()
@type root_table :: Electric.relation()
@type shape_id :: term()
@type t :: %__MODULE__{
inspector: Inspector.inspector(),
active: non_neg_integer(),
partitions: %{partition_table() => root_table()},
partition_ownership: %{Electric.relation() => MapSet.t(shape_id())}
}
@type options :: [{:inspector, Inspector.inspector()}]
@spec new(options()) :: t()
def new(opts) when is_list(opts) do
{:ok, inspector} = Keyword.fetch(opts, :inspector)
%__MODULE__{inspector: inspector}
end
@doc """
Update the partition information table with the given shape.
If the shape is defined on a partitioned table (not a partition of that
table) then this will expand the mapping function to add a change to the
partition root for every change to a partition of that root.
"""
@spec add_shape(t(), shape_id(), Electric.Shapes.Shape.t()) :: t()
def add_shape(%__MODULE__{} = state, shape_id, shape) do
case Inspector.load_relation_info(shape.root_table_id, state.inspector) do
{:ok, relation} ->
children = List.wrap(Map.get(relation, :children, []))
state =
state
|> Map.update!(:partitions, fn partitions ->
Enum.reduce(children, partitions, fn child, partitions ->
Map.put(partitions, child, [shape.root_table])
end)
end)
|> Map.update!(:partition_ownership, fn ownership ->
[shape.root_table | children]
|> Enum.reduce(ownership, fn relation, relation_ownership ->
Map.update(
relation_ownership,
relation,
MapSet.new([shape_id]),
&MapSet.put(&1, shape_id)
)
end)
end)
|> update_active()
{:ok, state}
:table_not_found ->
# tables that don't exist will be caught later in the stack (hard to
# run a snapshot against a non-existent table)
{:ok, state}
{:error, :connection_not_available} ->
{:error, :connection_not_available}
{:error, reason} ->
raise RuntimeError,
message:
"Unable to introspect table #{Electric.Utils.inspect_relation(shape.root_table)}: #{inspect(reason)}"
end
end
@doc """
Remove a shape that was previously added under the given id.
If that shape was defined on a partitioned table, this will clean up the
partition mapping table.
"""
@spec remove_shape(t(), shape_id()) :: t()
def remove_shape(%__MODULE__{} = state, shape_id) do
state
|> Map.update!(:partition_ownership, fn ownership ->
Map.new(ownership, fn {relation, shape_ids} ->
{relation, MapSet.delete(shape_ids, shape_id)}
end)
end)
|> clean_up_partitions()
end
defp clean_up_partitions(state) do
{empty, full} =
Enum.split_with(state.partition_ownership, fn {_relation, shape_ids} ->
Enum.empty?(shape_ids)
end)
remove_relations = Enum.map(empty, &elem(&1, 0))
%{state | partition_ownership: Map.new(full)}
|> Map.update!(:partitions, fn partitions ->
Enum.reduce(remove_relations, partitions, &Map.delete(&2, &1))
end)
|> update_active()
end
defp update_active(state) do
%{state | active: map_size(state.partitions)}
end
@doc """
Handle relation changes from the replication stream,
expanding changes to partitions into the partition root as appropriate.
"""
@spec handle_relation(t(), Relation.t()) :: {:ok, t()} | {:error, :connection_not_available}
def handle_relation(%__MODULE__{} = state, %Relation{} = relation) do
table = table(relation)
case Inspector.load_relation_info(relation.id, state.inspector) do
{:ok, %{parent: {_, _} = parent}} ->
# TODO: we should probabaly have a way to clean the inspector cache
# just based on the relation, there's a chance that this results in
# a query to pg just to then drop the info
with {:ok, {parent_id, _}} <-
Inspector.load_relation_oid(parent, state.inspector) do
Inspector.clean(parent_id, state.inspector)
end
{:ok, state |> Map.update!(:partitions, &Map.put(&1, table, [parent])) |> update_active()}
{:ok, _} ->
{:ok, state}
{:error, :connection_not_available} ->
{:error, :connection_not_available}
end
end
@doc """
Handle transactions from the replication stream, updating the partition mapping as appropriate.
"""
# no shapes on partitioned tables is probably the overwhelming majority of
# cases, so let's shortcut to avoid churn
@spec handle_transaction(t(), Transaction.t() | Relation.t()) ::
{t(), Transaction.t() | Relation.t()}
def handle_transaction(%__MODULE__{active: 0} = state, %Transaction{} = transaction) do
{state, transaction}
end
def handle_transaction(%__MODULE__{} = state, %Transaction{changes: changes} = transaction) do
{state, %{transaction | changes: expand_changes(changes, state)}}
end
defp expand_changes(changes, %__MODULE__{} = state) do
Enum.flat_map(changes, &expand_change(&1, state))
end
# Truncate handling:
# - Truncate partition root: truncation of the root plus all partitions
# if you truncate the root then your basically emptying it and all partitions
#
# - Truncate partition: truncate that partition plus the partition root
# truncating a partition empties it and also invalidates the contents of
# any shapes on the root. other partitions are untouched as, by definition,
# they don't overlap with the truncated partition.
defp expand_change(%TruncatedRelation{relation: relation} = change, state) do
[
change
| state |> truncation_dependencies(relation) |> Enum.map(&%{change | relation: &1})
]
end
defp expand_change(%{relation: relation} = change, state) do
[
change
| state.partitions
|> Map.get(relation, [])
|> Enum.map(&%{change | relation: &1})
]
end
defp table(%{schema: schema, table: table}), do: {schema, table}
defp truncation_dependencies(state, root_or_partition) do
state.partitions
|> Map.get(root_or_partition, [])
|> MapSet.new()
|> MapSet.union(
state
|> invert_partition_map()
|> Map.get(root_or_partition, [])
|> MapSet.new()
)
|> MapSet.delete(root_or_partition)
|> MapSet.to_list()
end
defp invert_partition_map(%__MODULE__{partitions: partitions}) do
Enum.reduce(partitions, %{}, fn {partition, roots}, inverted when is_list(roots) ->
Enum.reduce(roots, inverted, fn root, inverted ->
Map.update(inverted, root, [partition], &[partition | &1])
end)
end)
end
end