Skip to content

Commit f92d4b3

Browse files
authored
feat: Fallback to include whole table in replication if where clauses unsupported (#2367)
Fixes #2360 This will make the running Electric fallback to replicating whole tables if it receives any shapes with unsupported where clauses (e.g. enums, varchar with `IN` checks, user-defined data types in general, and who knows what else). There is no "recovery" mechanism to return to row-filtering as the Postgres error does not allow for an easy way to check which where clause caused the issue - once we go to relation-only filtering we stay there, like we would if an active shape had no where clause or if we were in PG14. Ideally we would detect where clauses that are unsupported at the relation filter processing level, so we can fine tune that, but until then this fallback makes sure that Electric works even if an unsupported where clause is provided. As discussed in [this Discord thread](https://discord.com/channels/933657521581858818/1341967559758581921), we could also have a configuration flag and better errors to avoid this sort of radical fallback, but we opted for an "always works" approach here. IIRC some benchmarking had shown that our filtering is fast enough that the PG level filtering might not be as important anyway, although the limiting of transmitted data is definitely nice (despite several issues we have with not being able to limit columns replicated etc). This is related with #1778 , and I'm also referencing #1831 as we had encountered many limitations to row filtering which has led to this proposed change. We can definitely improve this by detecting unsupported where clauses, checking filter diffs to know what caused the issue and reverting back after, periodically attempting to revert back to row-filtering, and an array of different approaches, but this allows all where clauses to be accepted and Electric to adjust accordingly.
1 parent 7e860a6 commit f92d4b3

File tree

4 files changed

+165
-9
lines changed

4 files changed

+165
-9
lines changed

.changeset/lazy-cameras-clap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@core/sync-service": patch
3+
---
4+
5+
Fallback to replicating whole relations if publication row filtering cannot support given where clauses.

packages/sync-service/lib/electric/replication/publication_manager.ex

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule Electric.Replication.PublicationManager do
1616
:relation_filter_counters,
1717
:prepared_relation_filters,
1818
:committed_relation_filters,
19+
:row_filtering_enabled,
1920
:update_debounce_timeout,
2021
:scheduled_updated_ref,
2122
:retries,
@@ -30,6 +31,7 @@ defmodule Electric.Replication.PublicationManager do
3031
relation_filter_counters: %{Electric.relation() => map()},
3132
prepared_relation_filters: %{Electric.relation() => __MODULE__.RelationFilter.t()},
3233
committed_relation_filters: %{Electric.relation() => __MODULE__.RelationFilter.t()},
34+
row_filtering_enabled: boolean(),
3335
update_debounce_timeout: timeout(),
3436
scheduled_updated_ref: nil | reference(),
3537
waiters: list(GenServer.from()),
@@ -43,6 +45,9 @@ defmodule Electric.Replication.PublicationManager do
4345
defmodule RelationFilter do
4446
defstruct [:relation, :where_clauses, :selected_columns]
4547

48+
def relation_only(%__MODULE__{relation: relation} = _filter),
49+
do: %__MODULE__{relation: relation}
50+
4651
@type t :: %__MODULE__{
4752
relation: Electric.relation(),
4853
where_clauses: [Electric.Replication.Eval.Expr.t()] | nil,
@@ -149,6 +154,7 @@ defmodule Electric.Replication.PublicationManager do
149154
relation_filter_counters: %{},
150155
prepared_relation_filters: %{},
151156
committed_relation_filters: %{},
157+
row_filtering_enabled: true,
152158
scheduled_updated_ref: nil,
153159
retries: 0,
154160
waiters: [],
@@ -204,7 +210,7 @@ defmodule Electric.Replication.PublicationManager do
204210
state = %{state | scheduled_updated_ref: nil, retries: 0}
205211

206212
case update_publication(state) do
207-
:ok ->
213+
{:ok, state} ->
208214
state = reply_to_waiters(:ok, state)
209215
{:noreply, %{state | committed_relation_filters: relation_filters}}
210216

@@ -229,24 +235,58 @@ defmodule Electric.Replication.PublicationManager do
229235
defp schedule_update_publication(_timeout, %__MODULE__{scheduled_updated_ref: _} = state),
230236
do: state
231237

232-
@spec update_publication(state()) :: :ok | {:error, term()}
238+
@spec update_publication(state()) :: {:ok, state()} | {:error, term()}
233239
defp update_publication(
234240
%__MODULE__{
235241
committed_relation_filters: committed_filters,
236242
prepared_relation_filters: current_filters
237-
} = _state
243+
} = state
238244
)
239245
when current_filters == committed_filters,
240-
do: :ok
246+
do: {:ok, state}
247+
248+
defp update_publication(
249+
%__MODULE__{
250+
committed_relation_filters: committed_filters,
251+
prepared_relation_filters: current_filters,
252+
row_filtering_enabled: false,
253+
publication_name: publication_name,
254+
db_pool: db_pool,
255+
pg_version: pg_version,
256+
configure_tables_for_replication_fn: configure_tables_for_replication_fn
257+
} = state
258+
) do
259+
# If row filtering is disabled, we only care about changes in actual relations
260+
# included in the publication
261+
if Map.keys(current_filters) == Map.keys(committed_filters) do
262+
{:ok, state}
263+
else
264+
try do
265+
configure_tables_for_replication_fn.(
266+
db_pool,
267+
Map.new(current_filters, fn {rel, filter} ->
268+
{rel, RelationFilter.relation_only(filter)}
269+
end),
270+
pg_version,
271+
publication_name
272+
)
273+
274+
{:ok, state}
275+
rescue
276+
err -> {:error, err}
277+
end
278+
end
279+
end
241280

242281
defp update_publication(
243282
%__MODULE__{
244283
prepared_relation_filters: relation_filters,
284+
row_filtering_enabled: true,
245285
publication_name: publication_name,
246286
db_pool: db_pool,
247287
pg_version: pg_version,
248288
configure_tables_for_replication_fn: configure_tables_for_replication_fn
249-
} = _state
289+
} = state
250290
) do
251291
configure_tables_for_replication_fn.(
252292
db_pool,
@@ -255,9 +295,28 @@ defmodule Electric.Replication.PublicationManager do
255295
publication_name
256296
)
257297

258-
:ok
298+
{:ok, state}
259299
rescue
260-
err -> {:error, err}
300+
# if we are unable to do row filtering for whatever reason, fall back to doing only
301+
# relation-based filtering - this is a fallback for unsupported where clauses that we
302+
# do not detect when composing relation filters
303+
err ->
304+
case err do
305+
%Postgrex.Error{postgres: %{code: :feature_not_supported}} ->
306+
Logger.warning(
307+
"Row filtering is not supported, falling back to relation-based filtering"
308+
)
309+
310+
update_publication(%__MODULE__{
311+
state
312+
| # disable row filtering and reset committed filters
313+
row_filtering_enabled: false,
314+
committed_relation_filters: %{}
315+
})
316+
317+
_ ->
318+
{:error, err}
319+
end
261320
end
262321

263322
defp get_pg_version(%{pg_version: pg_version} = state) when not is_nil(pg_version), do: state

packages/sync-service/test/electric/postgres/configuration_test.exs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ defmodule Electric.Postgres.ConfigurationTest do
1919
"""
2020
CREATE TABLE items (
2121
id UUID PRIMARY KEY,
22-
value TEXT NOT NULL
22+
value TEXT NOT NULL,
23+
value_c VARCHAR(255)
2324
)
2425
""",
2526
[]
@@ -291,6 +292,48 @@ defmodule Electric.Postgres.ConfigurationTest do
291292
)
292293
end
293294

295+
test "fails with invalid where clause error when unsupported clause provided",
296+
%{pool: conn, publication_name: publication, pg_version: pg_version} do
297+
if pg_version >= @pg_15 do
298+
error =
299+
assert_raise Postgrex.Error, fn ->
300+
Configuration.configure_tables_for_replication!(
301+
conn,
302+
%{
303+
{"public", "items"} => %RelationFilter{
304+
relation: {"public", "items"},
305+
where_clauses: [%Eval.Expr{query: "(value_c in ('a','b'))"}]
306+
}
307+
},
308+
pg_version,
309+
publication
310+
)
311+
end
312+
313+
assert %Postgrex.Error{
314+
postgres: %{
315+
code: :feature_not_supported,
316+
detail:
317+
"Only columns, constants, built-in operators, built-in data types, built-in collations, and immutable built-in functions are allowed."
318+
}
319+
} = error
320+
else
321+
# pg versions without row filtering should just accept this
322+
assert _ =
323+
Configuration.configure_tables_for_replication!(
324+
conn,
325+
%{
326+
{"public", "items"} => %RelationFilter{
327+
relation: {"public", "items"},
328+
where_clauses: [%Eval.Expr{query: "(value_c in ('a','b'))"}]
329+
}
330+
},
331+
pg_version,
332+
publication
333+
)
334+
end
335+
end
336+
294337
test "fails when a publication doesn't exist", %{pool: conn, pg_version: pg_version} do
295338
assert_raise Postgrex.Error, ~r/undefined_object/, fn ->
296339
Configuration.configure_tables_for_replication!(

packages/sync-service/test/electric/replication/publication_manager_test.exs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ defmodule Electric.Replication.PublicationManagerTest do
6464
configure_tables_for_replication_fn: configure_tables_fn
6565
})
6666

67-
%{opts: publication_manager_opts}
67+
%{opts: publication_manager_opts, ctx: ctx}
6868
end
6969

7070
describe "add_shape/2" do
@@ -220,6 +220,55 @@ defmodule Electric.Replication.PublicationManagerTest do
220220

221221
refute_receive {:filters, _}, 500
222222
end
223+
224+
test "should fallback to relation-only filtering if we cannot do row filtering", %{
225+
ctx: ctx,
226+
opts: opts
227+
} do
228+
GenServer.stop(opts[:server])
229+
230+
test_id = self()
231+
232+
configure_tables_fn = fn _, filters, _, _ ->
233+
if filters |> Map.values() |> Enum.any?(&(&1.where_clauses != nil)) do
234+
send(test_id, {:got_filters, :with_where_clauses})
235+
raise %Postgrex.Error{postgres: %{code: :feature_not_supported}}
236+
end
237+
238+
send(test_id, {:got_filters, :without_where_clauses})
239+
end
240+
241+
%{publication_manager: {_, publication_manager_opts}} =
242+
with_publication_manager(%{
243+
module: ctx.module,
244+
test: ctx.test,
245+
stack_id: ctx.stack_id,
246+
update_debounce_timeout: Access.get(ctx, :update_debounce_timeout, 0),
247+
publication_name: "pub_#{ctx.stack_id}",
248+
pool: :no_pool,
249+
pg_version: 150_001,
250+
configure_tables_for_replication_fn: configure_tables_fn
251+
})
252+
253+
shape1 = generate_shape({"public", "items"}, @where_clause_1)
254+
shape2 = generate_shape({"public", "items"}, @where_clause_2)
255+
shape3 = generate_shape({"public", "items_other"}, @where_clause_2)
256+
257+
# should fall back to relation-only filtering
258+
assert :ok == PublicationManager.add_shape(shape1, publication_manager_opts)
259+
assert_receive {:got_filters, :with_where_clauses}
260+
assert_receive {:got_filters, :without_where_clauses}
261+
refute_receive {:got_filters, _}, 50
262+
263+
# should remain in relation-only filtering mode after that, which
264+
# only updates the publication if the tracked relations change
265+
assert :ok == PublicationManager.add_shape(shape2, publication_manager_opts)
266+
refute_receive {:got_filters, _}, 50
267+
268+
assert :ok == PublicationManager.add_shape(shape3, publication_manager_opts)
269+
assert_receive {:got_filters, :without_where_clauses}
270+
refute_receive {:got_filters, _}, 50
271+
end
223272
end
224273

225274
describe "remove_shape/2" do

0 commit comments

Comments
 (0)