Skip to content

Commit ca329d3

Browse files
authored
Fix lock semantics for the exclusive connection lock when running against a pooler (#2791)
We have recently observed some source in Electric Cloud struggle due to lock and replication slot contention issues which in theory shouldn't have happened at all. The cause of the issues was an imperfect validation logic applied to the creation of new sources. As a consequence, some sources get created with a pooled connection URL. More concretely, our validation can successfully weed out pooled connection strings from Supabase, but Neon's pool allows establishing replication connections by proxying them directly to the DB. As a result, Electric ends up acquiring an advisory lock on a pooled connection where the pooler runs in transaction mode, leading to a bunch of undesirable effects that prevent Electric's normal operation. --- This PR addresses the cause of the issues by making the `LockConnection` process open a replication connection to the DB or fail if that cannot be achieved. In this way, either the advisory lock will be taken on a long-living connection as expected, or the connection won't be able to open and the source will immediately transition into an error state, instead of assigning an advisory lock to a random pooled connection as it currently does.
1 parent 6cae8ba commit ca329d3

7 files changed

Lines changed: 134 additions & 48 deletions

File tree

.changeset/eight-pigs-swim.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+
Open a replication connection to acquire the exclusive connection lock, fixing the lock semantics for cases where Electric runs with a pooled connection string.

packages/sync-service/lib/electric/connection/manager.ex

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ defmodule Electric.Connection.Manager do
111111
:pg_system_identifier,
112112
# PostgreSQL timeline ID
113113
:pg_timeline_id,
114+
# PostgreSQL's current WAL flush position at lock connection startup
115+
:pg_wal_flush_lsn,
114116
# ID used for process labeling and sibling discovery
115117
:stack_id,
116118
# Registry used for stack events
@@ -212,8 +214,12 @@ defmodule Electric.Connection.Manager do
212214
GenServer.cast(manager, :replication_client_streamed_first_message)
213215
end
214216

215-
def pg_info_looked_up(manager, pg_info) do
216-
GenServer.cast(manager, {:pg_info_looked_up, pg_info})
217+
def pg_system_info_obtained(manager, system_info) do
218+
GenServer.cast(manager, {:pg_system_info_obtained, system_info})
219+
end
220+
221+
def pg_info_obtained(manager, pg_info) do
222+
GenServer.cast(manager, {:pg_info_obtained, pg_info})
217223
end
218224

219225
# Used for testing the responsiveness of the manager process
@@ -832,24 +838,35 @@ defmodule Electric.Connection.Manager do
832838
{:noreply, state}
833839
end
834840

835-
def handle_cast({:pg_info_looked_up, {server_version, system_identifier, timeline_id}}, state) do
841+
def handle_cast({:pg_system_info_obtained, info}, state) do
842+
{:noreply,
843+
%State{
844+
state
845+
| pg_system_identifier: info.system_identifier,
846+
pg_timeline_id: info.timeline_id,
847+
pg_wal_flush_lsn: info.current_wal_flush_lsn
848+
}}
849+
end
850+
851+
def handle_cast({:pg_info_obtained, %{server_version_num: server_version}}, state) do
852+
Logger.info(
853+
"Postgres server version = #{server_version}, " <>
854+
"system identifier = #{state.pg_system_identifier}, " <>
855+
"timeline_id = #{state.pg_timeline_id}"
856+
)
857+
836858
:telemetry.execute(
837859
[:electric, :postgres, :info_looked_up],
838860
%{
839861
pg_version: server_version,
840-
pg_system_identifier: system_identifier,
841-
pg_timeline_id: timeline_id
862+
pg_system_identifier: state.pg_system_identifier,
863+
pg_timeline_id: state.pg_timeline_id,
864+
pg_wal_flush_lsn: state.pg_wal_flush_lsn
842865
},
843866
%{stack_id: state.stack_id}
844867
)
845868

846-
{:noreply,
847-
%State{
848-
state
849-
| pg_version: server_version,
850-
pg_system_identifier: system_identifier,
851-
pg_timeline_id: timeline_id
852-
}}
869+
{:noreply, %State{state | pg_version: server_version}}
853870
end
854871

855872
@impl true

packages/sync-service/lib/electric/db_connection_error.ex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,15 @@ defmodule Electric.DbConnectionError do
132132
maybe_database_does_not_exist(error) || unknown_error(error)
133133
end
134134

135+
def from_error(%Postgrex.Error{postgres: %{code: :syntax_error, pg_code: "42601"}} = error) do
136+
%DbConnectionError{
137+
message: error.postgres.message,
138+
type: :syntax_error,
139+
original_error: error,
140+
retry_may_fix?: false
141+
}
142+
end
143+
135144
def from_error(error), do: unknown_error(error)
136145

137146
def format_original_error(%DbConnectionError{original_error: error}) do

packages/sync-service/lib/electric/postgres/lock_connection.ex

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,23 @@ defmodule Electric.Postgres.LockConnection do
3636
def start_link(opts) do
3737
{connection_opts, init_opts} = Keyword.pop(opts, :connection_opts)
3838

39+
# Start the lock connection in logical replication mode to side-step any connection pooler
40+
# that may be sitting between us and the Postgres server.
41+
#
42+
# We cannot get desired semantics of session-level advisory locks when connecting to
43+
# Postgres through a pooler that runs in transaction mode (such as PGBouncer running in
44+
# front of Neon). Starting a connection in replication mode ensures that it will be a
45+
# direct connection to the database, so it can take a session-level advisory lock whose
46+
# lifetime will be tied to the connection's lifetime.
47+
connection_opts =
48+
connection_opts
49+
|> Electric.Utils.deobfuscate_password()
50+
|> connection_opts_with_logical_replication()
51+
3952
Postgrex.SimpleConnection.start_link(
4053
__MODULE__,
4154
init_opts,
42-
[timeout: :infinity, auto_reconnect: false, sync_connect: false] ++
43-
Electric.Utils.deobfuscate_password(connection_opts)
55+
[timeout: :infinity, auto_reconnect: false, sync_connect: false] ++ connection_opts
4456
)
4557
end
4658

@@ -58,6 +70,8 @@ defmodule Electric.Postgres.LockConnection do
5870
Logger.metadata(metadata)
5971
Electric.Telemetry.Sentry.set_tags_context(metadata)
6072

73+
Logger.debug("Opening lock connection")
74+
6175
state = %State{
6276
connection_manager: opts.connection_manager,
6377
lock_name: opts.lock_name,
@@ -71,11 +85,26 @@ defmodule Electric.Postgres.LockConnection do
7185
@impl true
7286
def handle_connect(state) do
7387
notify_connection_opened(state)
74-
send(self(), :acquire_lock)
88+
89+
# Verify that the connection has been opened in replication mode.
90+
#
91+
# If there's a pooler running in front of the Postgres server, it may have simply ignored
92+
# the replication=database connection parameter, defeating the purpose of us requesting the
93+
# replication mode in the first place which is to get the desired session-level locking
94+
# semantics.
95+
#
96+
# Issuing a statement that would cause a syntax error on a regular connection is a surefire
97+
# way to ensure the connection is running in the correct mode.
98+
send(self(), :identify_system)
99+
75100
{:noreply, state}
76101
end
77102

78103
@impl true
104+
def handle_info(:identify_system, state) do
105+
{:query, "IDENTIFY_SYSTEM", state}
106+
end
107+
79108
def handle_info(:acquire_lock, state) do
80109
if state.lock_acquired do
81110
notify_lock_acquired(state)
@@ -91,6 +120,32 @@ defmodule Electric.Postgres.LockConnection do
91120
end
92121

93122
@impl true
123+
def handle_result([%Postgrex.Result{command: :identify} = result], state) do
124+
# [db] postgres:postgres=> IDENTIFY_SYSTEM;
125+
# systemid │ timeline │ xlogpos │ dbname
126+
# ─────────────────────┼──────────┼───────────┼──────────
127+
# 7506979529870965272 │ 1 │ 0/220AE10 │ postgres
128+
# (1 row)
129+
[[systemid, timeline, xlogpos, _dbname]] = result.rows
130+
131+
notify_system_identified(state, %{
132+
system_identifier: systemid,
133+
timeline_id: timeline,
134+
current_wal_flush_lsn: xlogpos
135+
})
136+
137+
# Now proceed to the actual lock acquisition.
138+
send(self(), :acquire_lock)
139+
140+
{:noreply, state}
141+
end
142+
143+
def handle_result(%Postgrex.Error{postgres: %{code: :syntax_error}} = error, _state) do
144+
# Postgrex.SimpleConnection does not support {:stop, ...} or {:shutdown, ...} return values
145+
# from callback functions, so we raise here and let the connection manager handle the error.
146+
raise error
147+
end
148+
94149
def handle_result([%Postgrex.Result{columns: ["pg_advisory_lock"]}], state) do
95150
Logger.info("Lock acquired from postgres with name #{state.lock_name}")
96151
notify_lock_acquired(state)
@@ -116,6 +171,10 @@ defmodule Electric.Postgres.LockConnection do
116171
Connection.Manager.lock_connection_started(manager)
117172
end
118173

174+
defp notify_system_identified(%State{connection_manager: manager}, info) do
175+
Connection.Manager.pg_system_info_obtained(manager, info)
176+
end
177+
119178
defp notify_lock_acquisition_error(error, %State{connection_manager: manager}) do
120179
Connection.Manager.exclusive_connection_lock_acquisition_failed(manager, error)
121180
end
@@ -143,4 +202,12 @@ defmodule Electric.Postgres.LockConnection do
143202
do: true
144203

145204
defp is_expected_error?(_), do: false
205+
206+
defp connection_opts_with_logical_replication(connection_opts) do
207+
update_in(
208+
connection_opts,
209+
[:parameters],
210+
fn params -> params |> List.wrap() |> Keyword.put(:replication, "database") end
211+
)
212+
end
146213
end

packages/sync-service/lib/electric/postgres/replication_client.ex

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,14 @@ defmodule Electric.Postgres.ReplicationClient do
184184
{current_step, next_step, extra_info, return_val} =
185185
ConnectionSetup.process_query_result(result_list_or_error, state)
186186

187+
if current_step == :query_pg_info,
188+
do: notify_pg_info_obtained(state, extra_info)
189+
187190
if current_step == :create_slot and extra_info == :created_new_slot,
188191
do: notify_created_new_slot(state)
189192

190-
if next_step == :ready_to_stream, do: notify_ready_to_stream(state)
193+
if next_step == :ready_to_stream,
194+
do: notify_ready_to_stream(state)
191195

192196
return_val
193197
end
@@ -375,25 +379,28 @@ defmodule Electric.Postgres.ReplicationClient do
375379

376380
defp update_applied_wal(state, wal) when is_number(wal), do: state
377381

378-
defp notify_connection_opened(%State{connection_manager: connection_manager} = state) do
379-
:ok = Electric.Connection.Manager.replication_client_started(connection_manager)
382+
defp notify_connection_opened(%State{connection_manager: manager} = state) do
383+
:ok = Electric.Connection.Manager.replication_client_started(manager)
380384
state
381385
end
382386

383-
defp notify_created_new_slot(%State{connection_manager: connection_manager} = state) do
384-
:ok = Electric.Connection.Manager.replication_client_created_new_slot(connection_manager)
387+
defp notify_pg_info_obtained(%State{connection_manager: manager} = state, pg_info) do
388+
:ok = Electric.Connection.Manager.pg_info_obtained(manager, pg_info)
385389
state
386390
end
387391

388-
defp notify_ready_to_stream(%State{connection_manager: connection_manager} = state) do
389-
:ok = Electric.Connection.Manager.replication_client_ready_to_stream(connection_manager)
392+
defp notify_created_new_slot(%State{connection_manager: manager} = state) do
393+
:ok = Electric.Connection.Manager.replication_client_created_new_slot(manager)
390394
state
391395
end
392396

393-
defp notify_seen_first_message(%State{connection_manager: connection_manager} = state) do
394-
:ok =
395-
Electric.Connection.Manager.replication_client_streamed_first_message(connection_manager)
397+
defp notify_ready_to_stream(%State{connection_manager: manager} = state) do
398+
:ok = Electric.Connection.Manager.replication_client_ready_to_stream(manager)
399+
state
400+
end
396401

402+
defp notify_seen_first_message(%State{connection_manager: manager} = state) do
403+
:ok = Electric.Connection.Manager.replication_client_streamed_first_message(manager)
397404
state
398405
end
399406
end

packages/sync-service/lib/electric/postgres/replication_client/connection_setup.ex

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,32 +55,13 @@ defmodule Electric.Postgres.ReplicationClient.ConnectionSetup do
5555

5656
defp pg_info_query(state) do
5757
Logger.debug("ReplicationClient step: pg_info_query")
58-
59-
query = """
60-
SELECT
61-
current_setting('server_version_num') server_version_num,
62-
(pg_control_system()).system_identifier,
63-
(pg_control_checkpoint()).timeline_id
64-
"""
65-
58+
query = "SELECT current_setting('server_version_num') server_version_num"
6659
{:query, query, state}
6760
end
6861

6962
defp pg_info_result([%Postgrex.Result{} = result], state) do
70-
%{rows: [[version_str, system_identifier, timeline_id]]} = result
71-
72-
Logger.info(
73-
"Postgres server version = #{version_str}, " <>
74-
"system identifier = #{system_identifier}, " <>
75-
"timeline_id = #{timeline_id}"
76-
)
77-
78-
Electric.Connection.Manager.pg_info_looked_up(
79-
state.connection_manager,
80-
{String.to_integer(version_str), system_identifier, timeline_id}
81-
)
82-
83-
state
63+
%{rows: [[version_str]]} = result
64+
{%{server_version_num: String.to_integer(version_str)}, state}
8465
end
8566

8667
defp create_publication_query(state) do

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ defmodule Electric.Postgres.ReplicationClientTest do
3737

3838
defp process_message({:"$gen_cast", :replication_client_started}), do: nil
3939
defp process_message({:"$gen_cast", :replication_client_created_new_slot}), do: nil
40-
defp process_message({:"$gen_cast", {:pg_info_looked_up, _}}), do: nil
40+
defp process_message({:"$gen_cast", {:pg_info_obtained, _}}), do: nil
4141

4242
defp process_message({:"$gen_cast", :replication_client_streamed_first_message}),
4343
do: {self(), :streaming_started}

0 commit comments

Comments
 (0)