Skip to content

Commit eb8167a

Browse files
authored
feat: Implement separate, potentially pooled ELECTRIC_QUERY_DATABASE_URL (#2438)
Closes #1885 - Introduce separate env var `ELECTRIC_QUERY_DATABASE_URL` - Add another `connection_opts` field to `replication_opts`, so there are two of them - Fallback to using the same ones, but still keep them separate and maintain them separately - Main disadvantage of this approach is that the fallbacks for SSL and IPv6 might need to happen twice if a separate connection string is not being used - this can be improved by only populating the `replication_opts`'s `connection_opts` right before starting the replication client with the single `connection_opts` available if a separate one was not provided. - Make all tests (e.g. router tests) use a `pgBouncer` connection string - Had to ensure connections are terminated explicitly at the end of the test to avoid hanging connections from the pooler making the tests really slow. ## TODO - [x] Write integration test with `pgBouncer` as well that simply syncs a shape with a pooled connection URL, although the unit tests already cover that - [ ] Avoid duplicate SSL and IPv6 fallbacks when separate query connection string is not provided
1 parent d7a0c05 commit eb8167a

File tree

20 files changed

+365
-83
lines changed

20 files changed

+365
-83
lines changed

.changeset/flat-hornets-ring.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@core/sync-service": patch
3+
"@electric-sql/docs": patch
4+
---
5+
6+
Implement `ELECTRIC_QUERY_DATABASE_URL` optional env var to perform queries with separate, potentially pooled connection string.

.github/workflows/elixir_tests.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,19 @@ jobs:
3939
--health-retries 5
4040
ports:
4141
- 54321:5432
42+
43+
pgbouncer:
44+
image: bitnami/pgbouncer:latest
45+
env:
46+
PGBOUNCER_AUTH_TYPE: trust
47+
PGBOUNCER_DATABASE: "*"
48+
PGBOUNCER_POOL_MODE: transaction
49+
POSTGRESQL_HOST: postgres
50+
POSTGRESQL_DATABASE: electric
51+
POSTGRESQL_USERNAME: postgres
52+
POSTGRESQL_PASSWORD: password
53+
ports:
54+
- 64321:6432
4255
steps:
4356
- uses: actions/checkout@v4
4457

integration-tests/tests/_macros.luxinc

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@
33

44
[global pg_container_name=]
55
[global pg_host_port=54331]
6+
[global pg_pooler_host_port=64331]
67
[global database_url=postgresql://postgres:password@localhost:$pg_host_port/postgres?sslmode=disable]
8+
[global pooled_database_url=postgresql://postgres:password@localhost:$pg_pooler_host_port/postgres?sslmode=disable]
79

810
[macro setup_pg_with_name shell_name initdb_args config_opts]
911
[shell $shell_name]
1012
-$fail_pattern
13+
14+
# Create network for given contianer to be able to attach pooler
15+
!docker network ls | grep ${pg_container_name}-network || docker network create ${pg_container_name}-network
1116

1217
!docker run \
1318
--name $pg_container_name \
19+
--network ${pg_container_name}-network \
1420
-e POSTGRES_DB=electric \
1521
-e POSTGRES_USER=postgres \
1622
-e POSTGRES_PASSWORD=password \
@@ -31,6 +37,27 @@
3137
[invoke setup_pg_with_name "pg" $initdb_args $config_opts]
3238
[endmacro]
3339

40+
[macro setup_pg_with_pooler initdb_args config_opts]
41+
[invoke setup_pg_with_name "pg" $initdb_args $config_opts]
42+
[shell "pg_pooler"]
43+
-$fail_pattern
44+
45+
!docker run \
46+
--name "${pg_container_name}-pooler" \
47+
--network ${pg_container_name}-network \
48+
-e PGBOUNCER_AUTH_TYPE=trust \
49+
-e PGBOUNCER_DATABASE=* \
50+
-e PGBOUNCER_POOL_MODE=transaction \
51+
-e POSTGRESQL_HOST=$pg_container_name \
52+
-e POSTGRESQL_DATABASE=electric \
53+
-e POSTGRESQL_USERNAME=postgres \
54+
-e POSTGRESQL_PASSWORD=password \
55+
-p $pg_pooler_host_port:6432 \
56+
bitnami/pgbouncer:latest
57+
58+
??LOG process up: PgBouncer
59+
[endmacro]
60+
3461
[macro stop_pg]
3562
[shell pg_lifecycle]
3663
# This timeout is needed until https://github.com/electric-sql/electric/issues/1632 is fixed.
@@ -87,6 +114,10 @@
87114
[invoke setup_electric_with_env "DATABASE_URL=$database_url"]
88115
[endmacro]
89116

117+
[macro setup_electric_with_pooler]
118+
[invoke setup_electric_with_env "DATABASE_URL=$database_url ELECTRIC_QUERY_DATABASE_URL=$pooled_database_url"]
119+
[endmacro]
120+
90121
[macro setup_multi_tenant_electric]
91122
[invoke setup_electric_with_env ""]
92123
[endmacro]
@@ -125,11 +156,12 @@
125156

126157
[macro teardown_container container_name]
127158
-$fail_pattern
128-
!docker rm -f -v $container_name
159+
!docker rm -f -v $container_name 2>/dev/null || true
129160
?$PS1
130161
[endmacro]
131162

132163
[macro teardown]
164+
[invoke teardown_container "${pg_container_name}-pooler"]
133165
[invoke teardown_container $pg_container_name]
134166
!../scripts/clean_up.sh
135167
?$PS1
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
[doc Verify Electric handles data replication with pooled connection]
2+
3+
[include _macros.luxinc]
4+
5+
[global pg_container_name=poold-connections__pg]
6+
7+
###
8+
9+
## Start a new Postgres cluster
10+
[invoke setup_pg_with_pooler "" ""]
11+
12+
## Add some data
13+
[invoke start_psql]
14+
[shell psql]
15+
"""!
16+
CREATE TABLE items (
17+
id UUID PRIMARY KEY,
18+
val TEXT
19+
);
20+
"""
21+
??CREATE TABLE
22+
23+
"""!
24+
INSERT INTO
25+
items (id, val)
26+
SELECT
27+
gen_random_uuid(),
28+
'#' || generate_series || ' initial val'
29+
FROM
30+
generate_series(1, 10);
31+
"""
32+
??INSERT 0 10
33+
34+
## Start the sync service.
35+
[invoke setup_electric_with_pooler]
36+
37+
[shell electric]
38+
??[info] Starting replication from postgres
39+
40+
# Initialize a shape and collect the offset
41+
[shell client]
42+
# strip ANSI codes from response for easier matching
43+
!curl -v -X GET "http://localhost:3000/v1/shape?table=items&offset=-1"
44+
?electric-handle: ([\d-]+)
45+
[local handle=$1]
46+
?electric-offset: ([\w\d_]+)
47+
[local offset=$1]
48+
??"val":"#10 initial val"
49+
50+
## Add some more data
51+
[shell psql]
52+
"""!
53+
INSERT INTO
54+
items (id, val)
55+
SELECT
56+
gen_random_uuid(),
57+
'#' || generate_series || ' new val'
58+
FROM
59+
generate_series(1, 10);
60+
"""
61+
??INSERT 0 10
62+
63+
# Client should be able to continue same shape
64+
[shell client]
65+
[sleep 2]
66+
!curl -v -X GET "http://localhost:3000/v1/shape?table=items&handle=$handle&offset=$offset"
67+
??HTTP/1.1 200 OK
68+
??"val":"#10 new val"
69+
70+
[cleanup]
71+
[invoke teardown]

packages/sync-service/.env.test

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
ELECTRIC_LOG_LEVEL=info
22
DATABASE_URL=postgresql://postgres:password@localhost:54321/postgres?sslmode=disable
3+
ELECTRIC_QUERY_DATABASE_URL=postgresql://postgres:password@localhost:64321/postgres?sslmode=disable

packages/sync-service/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ reuse that configuration if you want:
6464
config :my_app, Repo, database_config
6565

6666
config :electric,
67-
connection_opts: Electric.Utils.obfuscate_password(database_config)
67+
replication_connection_opts: Electric.Utils.obfuscate_password(database_config)
6868

6969
Or if you're getting your db connection from an environment variable, then you
7070
can use
@@ -74,7 +74,7 @@ can use
7474
{:ok, database_config} = Electric.Config.parse_postgresql_uri(System.fetch_env!("DATABASE_URL"))
7575

7676
config :electric,
77-
connection_opts: Electric.Utils.obfuscate_password(database_config)
77+
replication_connection_opts: Electric.Utils.obfuscate_password(database_config)
7878

7979
The Electric app will startup along with the rest of your Elixir app.
8080

packages/sync-service/config/runtime.exs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,23 @@ config :opentelemetry,
123123
local_parent_not_sampled: :always_off
124124
}}
125125

126-
database_url_config = env!("DATABASE_URL", &Electric.Config.parse_postgresql_uri!/1)
126+
replication_database_url_config = env!("DATABASE_URL", &Electric.Config.parse_postgresql_uri!/1)
127127

128-
database_ipv6_config =
129-
env!("ELECTRIC_DATABASE_USE_IPV6", :boolean, false)
128+
query_database_url_config =
129+
env!(
130+
"ELECTRIC_QUERY_DATABASE_URL",
131+
&Electric.Config.parse_postgresql_uri!/1,
132+
replication_database_url_config
133+
)
130134

131-
connection_opts = database_url_config ++ [ipv6: database_ipv6_config]
135+
database_ipv6_config = env!("ELECTRIC_DATABASE_USE_IPV6", :boolean, false)
132136

133-
config :electric, connection_opts: Electric.Utils.obfuscate_password(connection_opts)
137+
replication_connection_opts = replication_database_url_config ++ [ipv6: database_ipv6_config]
138+
query_connection_opts = query_database_url_config ++ [ipv6: database_ipv6_config]
139+
140+
config :electric,
141+
replication_connection_opts: Electric.Utils.obfuscate_password(replication_connection_opts),
142+
query_connection_opts: Electric.Utils.obfuscate_password(query_connection_opts)
134143

135144
enable_integration_testing? = env!("ELECTRIC_ENABLE_INTEGRATION_TESTING", :boolean, nil)
136145
cache_max_age = env!("ELECTRIC_CACHE_MAX_AGE", :integer, nil)

packages/sync-service/dev/docker-compose.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ services:
3838
- docker-entrypoint.sh
3939
- -c
4040
- config_file=/etc/postgresql.conf
41+
pgbouncer:
42+
image: bitnami/pgbouncer:latest
43+
environment:
44+
PGBOUNCER_AUTH_TYPE: trust
45+
PGBOUNCER_DATABASE: "*"
46+
PGBOUNCER_POOL_MODE: transaction
47+
POSTGRESQL_HOST: postgres
48+
POSTGRESQL_DATABASE: electric
49+
POSTGRESQL_USERNAME: postgres
50+
POSTGRESQL_PASSWORD: password
51+
ports:
52+
- "64321:6432"
53+
depends_on:
54+
- postgres
4155
nginx:
4256
image: nginx:latest
4357
ports:

packages/sync-service/lib/electric.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@ defmodule Electric do
6868
6969
### Database
7070
71-
- `connection_opts` - **Required**
71+
- `replication_connection_opts` - **Required**
72+
#{NimbleOptions.docs(opts_schema, nest_level: 1)}.
73+
- `query_connection_opts` - Optional separate connection string that can use a pooler for non-replication queries (default: nil)
7274
#{NimbleOptions.docs(opts_schema, nest_level: 1)}.
7375
- `db_pool_size` - How many connections Electric opens as a pool for handling shape queries (default: `#{default.(:db_pool_size)}`)
7476
- `replication_stream_id` - Suffix for the logical replication publication and slot name (default: `#{default.(:replication_stream_id)}`)

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,10 +84,14 @@ defmodule Electric.Application do
8484

8585
slot_name = Keyword.get(opts, :slot_name, "electric_slot_#{replication_stream_id}")
8686

87+
replication_connection_opts = get_env!(opts, :replication_connection_opts)
88+
8789
Keyword.merge(
8890
core_config,
89-
connection_opts: get_env!(opts, :connection_opts),
91+
connection_opts:
92+
get_env_with_default(opts, :query_connection_opts, replication_connection_opts),
9093
replication_opts: [
94+
connection_opts: replication_connection_opts,
9195
publication_name: publication_name,
9296
slot_name: slot_name,
9397
slot_temporary?: get_env(opts, :replication_slot_temporary?)

0 commit comments

Comments
 (0)