Packages
electric
1.0.0-beta.19
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/postgres/configuration.ex
defmodule Electric.Postgres.Configuration do
@moduledoc """
Module for functions that configure Postgres in some way using
a provided connection.
"""
require Logger
alias Electric.Replication.PublicationManager.RelationFilter
alias Electric.Utils
@type filters() :: %{Electric.relation() => RelationFilter.t()}
@pg_15 150_000
@doc """
Ensure that all tables are configured for replication.
Table is considered configured for replication when it's `REPLICA IDENTITY` is set to `FULL`
and it's added to the specified publication.
Important: this function should not be ran in a transaction, because it starts multiple
internal transactions that are sometimes expected to fail.
Raises if it fails to configure all the tables in the expected way.
"""
@spec configure_tables_for_replication!(
Postgrex.conn(),
filters(),
String.t(),
float()
) ::
{:ok, [:ok]}
def configure_tables_for_replication!(pool, relation_filters, pg_version, publication_name) do
configure_tables_for_replication_internal!(
pool,
relation_filters,
pg_version,
publication_name
)
end
@doc """
Get Postgres server version
"""
@spec get_pg_version(Postgrex.conn()) :: {:ok, non_neg_integer()} | {:error, term()}
def get_pg_version(conn) do
case Postgrex.query(
conn,
"SELECT current_setting('server_version_num') server_version_num",
[]
) do
{:ok, result} when result.num_rows == 1 ->
[[version_str]] = result.rows
{:ok, String.to_integer(version_str)}
{:error, err} ->
{:error, err}
end
end
defp configure_tables_for_replication_internal!(
pool,
relation_filters,
pg_version,
publication_name
)
when pg_version < @pg_15 do
Postgrex.transaction(pool, fn conn ->
publication = Utils.quote_name(publication_name)
relation_filters = filter_for_existing_relations(conn, relation_filters)
prev_published_tables =
get_publication_tables(conn, publication_name)
|> Enum.map(&Utils.relation_to_sql/1)
|> MapSet.new()
new_published_tables =
relation_filters
|> Map.keys()
|> Enum.map(&Utils.relation_to_sql/1)
|> MapSet.new()
alter_ops =
Enum.concat(
MapSet.difference(new_published_tables, prev_published_tables)
|> Enum.map(&{&1, "ADD"}),
MapSet.difference(prev_published_tables, new_published_tables)
|> Enum.map(&{&1, "DROP"})
)
for {table, op} <- alter_ops do
Postgrex.query!(conn, "SAVEPOINT before_publication", [])
# PG 14 and below do not support filters on tables of publications
case Postgrex.query(conn, "ALTER PUBLICATION #{publication} #{op} TABLE #{table}", []) do
{:ok, _} ->
Postgrex.query!(conn, "RELEASE SAVEPOINT before_publication", [])
:ok
# Duplicate object error is raised if we're trying to add a table to the publication when it's already there.
{:error, %{postgres: %{code: :duplicate_object}}} ->
Postgrex.query!(conn, "ROLLBACK TO SAVEPOINT before_publication", [])
Postgrex.query!(conn, "RELEASE SAVEPOINT before_publication", [])
:ok
{:error, reason} ->
raise reason
end
end
set_replica_identity!(conn, relation_filters)
end)
end
defp configure_tables_for_replication_internal!(
pool,
relation_filters,
_pg_version,
publication_name
) do
Postgrex.transaction(pool, fn conn ->
# Ensure that all tables are present in the publication
relation_filters = filter_for_existing_relations(conn, relation_filters)
# Update the entire publication with the new filters
Postgrex.query!(
conn,
make_alter_publication_query(publication_name, relation_filters),
[]
)
# `ALTER TABLE` should be after the publication altering, because it takes out an exclusive lock over this table,
# but the publication altering takes out a shared lock on all mentioned tables, so a concurrent transaction will
# deadlock if the order is reversed.
set_replica_identity!(conn, relation_filters)
[:ok]
end)
end
defp set_replica_identity!(conn, relation_filters) do
for %RelationFilter{relation: relation} <- Map.values(relation_filters),
table = Utils.relation_to_sql(relation) do
%Postgrex.Result{rows: [[correct_identity?]]} =
Postgrex.query!(
conn,
"SELECT relreplident = 'f' FROM pg_class JOIN pg_namespace ON relnamespace = pg_namespace.oid WHERE nspname = $1 AND relname = $2;",
Tuple.to_list(relation)
)
if not correct_identity? do
Logger.info("Altering identity of #{table} to FULL")
Postgrex.query!(conn, "ALTER TABLE #{table} REPLICA IDENTITY FULL", [])
end
end
end
@spec get_publication_tables(Postgrex.conn(), String.t()) :: list(Electric.relation())
def get_publication_tables(conn, publication) do
# `pg_publication_tables` is too clever for us -- if you add a partitioned
# table to the publication `pg_publication_tables` lists all the partitions
# as members, not the actual partitioned table. `pg_publication_rel`
# doesn't do this, it returns a direct list of the tables that were added
# using `ALTER PUBLICATION` and doesn't expand a partitioned table into its
# partitions.
Postgrex.query!(
conn,
"""
SELECT pn.nspname, pc.relname
FROM pg_publication_rel ppr
JOIN pg_publication pp
ON ppr.prpubid = pp.oid
JOIN pg_class pc
ON pc.oid = ppr.prrelid
JOIN pg_namespace pn
ON pc.relnamespace = pn.oid
WHERE pp.pubname = $1
ORDER BY pn.nspname, pc.relname;
""",
[publication]
)
|> Map.fetch!(:rows)
|> Enum.map(&(Enum.take(&1, 2) |> List.to_tuple()))
end
# Makes an SQL query that alters the given publication whith the given tables and filters.
@spec make_alter_publication_query(String.t(), filters()) :: String.t()
defp make_alter_publication_query(publication_name, filters) do
case Map.values(filters) do
[] ->
"""
DO $$
DECLARE
tables TEXT;
BEGIN
SELECT string_agg(format('%I.%I', pn.nspname, pc.relname), ', ') INTO tables
FROM pg_publication_rel ppr
JOIN pg_publication pp
ON ppr.prpubid = pp.oid
JOIN pg_class pc
ON pc.oid = ppr.prrelid
JOIN pg_namespace pn
ON pc.relnamespace = pn.oid
WHERE pp.pubname = '#{publication_name}';
IF tables IS NOT NULL THEN
EXECUTE format('ALTER PUBLICATION #{Utils.quote_name(publication_name)} DROP TABLE %s', tables);
END IF;
END $$;
"""
filters ->
base_sql = "ALTER PUBLICATION #{Utils.quote_name(publication_name)} SET TABLE "
tables =
filters
|> Enum.map(&make_table_clause/1)
|> Enum.join(", ")
base_sql <> tables
end
end
@spec filter_for_existing_relations(Postgrex.conn(), filters()) :: filters()
defp filter_for_existing_relations(conn, filters) do
query = """
WITH input_relations AS (
SELECT
UNNEST($1::text[]) AS schemaname,
UNNEST($2::text[]) AS tablename
)
SELECT ir.schemaname, ir.tablename
FROM input_relations ir
JOIN pg_class pc ON pc.relname = ir.tablename
JOIN pg_namespace pn ON pn.oid = pc.relnamespace
WHERE pn.nspname = ir.schemaname AND pc.relkind IN ('r', 'p');
"""
relations = Map.keys(filters)
Postgrex.query!(conn, query, [
Enum.map(relations, &elem(&1, 0)),
Enum.map(relations, &elem(&1, 1))
])
|> Map.fetch!(:rows)
|> Enum.map(&List.to_tuple/1)
|> Enum.reduce(%{}, fn rel, new_filters ->
case Map.get(filters, rel) do
nil -> new_filters
filter -> Map.put(new_filters, rel, filter)
end
end)
end
@spec make_table_clause(RelationFilter.t()) :: String.t()
defp make_table_clause(%RelationFilter{
relation: relation,
where_clauses: where_clauses
# selected_columns: cols
}) do
table = Utils.relation_to_sql(relation)
# NOTE: cannot filter on columns with REPLICA IDENTITY FULL
# cols = if cols == nil, do: "", else: " (#{Enum.join(cols, ", ")})"
cols = ""
where =
if where_clauses == nil,
do: "",
else:
" WHERE " <>
"(#{where_clauses |> Enum.map(& &1.query) |> Enum.join(" OR ")})"
table <> cols <> where
end
end