Skip to content

Commit 6020531

Browse files
committed
fix: reset retries and reuse oids
1 parent 833f241 commit 6020531

2 files changed

Lines changed: 69 additions & 19 deletions

File tree

lib/extensions/postgres_cdc_rls/replication_poller.ex

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do
66
77
## Lifecycle
88
9-
On start the poller connects to the tenant's database, calls
10-
`Replications.prepare_replication/2` to create the temporary slot, and fetches
11-
the publication's tables via `Subscriptions.fetch_publication_tables/2`. If the
12-
publication has tables, it kicks off the poll loop; if not, it stays idle and
13-
waits for tables to appear.
9+
On start the poller connects to the tenant's database and fetches the
10+
publication's tables via `Subscriptions.fetch_publication_tables/2`. Only if
11+
the publication has tables does it call `Replications.prepare_replication/2`
12+
to create the temporary slot and kick off the poll loop; if the publication is
13+
empty it stays idle without creating a slot (an unconsumed slot would retain
14+
WAL) and waits for tables to appear.
1415
1516
## Poll loop
1617
@@ -247,6 +248,10 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do
247248

248249
{n, 0} when n > 0 ->
249250
cancel_timer(state.poll_ref)
251+
# Cancel any pending :retry too: a retry left over from a prior
252+
# list_changes/5 error would otherwise fire after the slot is dropped and
253+
# re-run prepare_replication/1, recreating work (and possibly the slot).
254+
cancel_timer(state.retry_ref)
250255

251256
case Replications.drop_replication_slot(conn, state.slot_name) do
252257
{:error, reason} when reason != :slot_not_found ->
@@ -257,7 +262,16 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do
257262

258263
_ ->
259264
cancel_timer(state.check_oid_ref)
260-
{:noreply, %{state | oids: new_oids, poll_ref: nil, check_oid_ref: schedule_check_oids()}}
265+
266+
{:noreply,
267+
%{
268+
state
269+
| oids: new_oids,
270+
poll_ref: nil,
271+
retry_ref: nil,
272+
retry_count: 0,
273+
check_oid_ref: schedule_check_oids()
274+
}}
261275
end
262276

263277
_ ->
@@ -283,26 +297,37 @@ defmodule Extensions.PostgresCdcRls.ReplicationPoller do
283297
slot_name: slot_name,
284298
tenant_id: tenant_id,
285299
publication: publication,
300+
oids: oids,
286301
check_oid_ref: check_oid_ref
287302
} = state
288303
) do
289-
case Replications.prepare_replication(conn, slot_name) do
290-
{:ok, _} ->
291-
oids = Subscriptions.fetch_publication_tables(conn, publication)
292-
if map_size(oids) > 0, do: send(self(), :poll)
304+
# Reuse oids when a caller (e.g. :check_oids) already fetched them; otherwise
305+
# fetch once here so we never create a slot for an empty publication.
306+
oids = if map_size(oids) > 0, do: oids, else: Subscriptions.fetch_publication_tables(conn, publication)
293307

294-
cancel_timer(check_oid_ref)
295-
{:noreply, %{state | oids: oids, check_oid_ref: schedule_check_oids()}}
308+
if map_size(oids) > 0 do
309+
case Replications.prepare_replication(conn, slot_name) do
310+
{:ok, _} ->
311+
send(self(), :poll)
296312

297-
{:error, error} ->
298-
log_error("PoolingReplicationPreparationError", error)
313+
cancel_timer(check_oid_ref)
314+
{:noreply, %{state | oids: oids, check_oid_ref: schedule_check_oids()}}
299315

300-
Realtime.Telemetry.execute([:realtime, :replication, :poller, :prepare, :exception], %{}, %{
301-
tenant: tenant_id,
302-
reason: error
303-
})
316+
{:error, error} ->
317+
log_error("PoolingReplicationPreparationError", error)
304318

305-
retry_or_stop(state, error)
319+
Realtime.Telemetry.execute([:realtime, :replication, :poller, :prepare, :exception], %{}, %{
320+
tenant: tenant_id,
321+
reason: error
322+
})
323+
324+
retry_or_stop(state, error)
325+
end
326+
else
327+
# Empty publication: don't create a slot (it would retain WAL with nothing
328+
# to consume it). Wait for :check_oids to observe tables appearing.
329+
cancel_timer(check_oid_ref)
330+
{:noreply, %{state | oids: oids, check_oid_ref: schedule_check_oids()}}
306331
end
307332
end
308333

test/realtime/extensions/cdc_rls/replication_poller_test.exs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,31 @@ defmodule Realtime.Extensions.PostgresCdcRls.ReplicationPollerTest do
412412
refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
413413
end
414414

415+
test "cancels a pending retry when tables vanish so it can't recreate the slot", %{args: args} do
416+
tenant_id = args["id"]
417+
418+
expect(Replications, :drop_replication_slot, fn _conn, _slot -> {:ok, :dropped} end)
419+
420+
pid = start_link_supervised!({Poller, args})
421+
422+
# First poll happens with the default non-empty stub.
423+
assert_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 500
424+
425+
# Simulate a retry already scheduled from a prior list_changes/5 error.
426+
:sys.replace_state(pid, fn state ->
427+
%{state | retry_ref: Process.send_after(pid, :retry, 50), retry_count: 3}
428+
end)
429+
430+
expect(Subscriptions, :fetch_publication_tables, fn _, _ -> %{} end)
431+
reject(&Replications.list_changes/5)
432+
433+
send(pid, :check_oids)
434+
435+
# retry_ref is cancelled/cleared and retry_count reset; no :retry fires.
436+
assert %{retry_ref: nil, retry_count: 0} = :sys.get_state(pid)
437+
refute_receive {:telemetry, [:realtime, :replication, :poller, :query, :stop], _, %{tenant: ^tenant_id}}, 200
438+
end
439+
415440
test "resumes polling when tables appear via :check_oids", %{args: args} do
416441
tenant_id = args["id"]
417442

0 commit comments

Comments
 (0)