Skip to content

Commit bf522a5

Browse files
committed
fix: Reconcile on SubscriptionManager init
Instead of always deleting the subscriptions table when SubscriptionManager starts we: * warm restarts now re-adopt ETS and re-monitor subscribers * cold start cleans up the DB Orphan processes are not a problem because ReplicationPoller simply broadcasts if it can't find the pid related to the subscription row
1 parent 7632a21 commit bf522a5

2 files changed

Lines changed: 197 additions & 1 deletion

File tree

lib/extensions/postgres_cdc_rls/subscription_manager.ex

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,19 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do
9292
{:ok, conn} <- Database.connect_db(subscription_manager_settings),
9393
{:ok, conn_pub} <- Database.connect_db(subscription_manager_pub_settings),
9494
{:ok, oids} <- Subscriptions.fetch_publication_tables(conn, publication) do
95-
Subscriptions.delete_all_if_table_exists(conn)
95+
# The subscribers ETS tables are owned by the WorkerSupervisor, so they survive a
96+
# SubscriptionManager-only restart. An empty pids table means a cold start (fresh
97+
# WorkerSupervisor): clear any stale DB rows and ask still-connected channels to
98+
# re-subscribe. A non-empty table means a warm manager restart: the DB + ETS state is
99+
# still valid, so re-adopt it by rebuilding the monitors (the only thing lost with the
100+
# previous manager) instead of wiping everyone out.
101+
case :ets.info(subscribers_pids_table, :size) do
102+
0 ->
103+
Subscriptions.delete_all_if_table_exists(conn)
104+
105+
_ ->
106+
readopt_monitors(subscribers_pids_table)
107+
end
96108

97109
Rls.update_meta(id, self(), conn_pub)
98110

@@ -307,6 +319,29 @@ defmodule Extensions.PostgresCdcRls.SubscriptionManager do
307319

308320
## Internal functions
309321

322+
# Warm restart: re-adopt the subscribers that survived in ETS.
323+
#
324+
# The previous manager's monitors died with it, so the new one re-monitors every surviving pid
325+
# (refreshing the ref stored in ETS, which the :check_oids path uses to demonitor). A pid that
326+
# died during the downtime makes Process.monitor/1 deliver :DOWN immediately, so the existing
327+
# :DOWN handler cleans it up — self-healing.
328+
#
329+
# We deliberately do not reconcile the DB against ETS here: orphan DB rows are hygiene rather
330+
# than correctness (the poller falls back to a cluster-wide broadcast on :node_not_found instead
331+
# of dropping changes), and any DB-vs-ETS diff would scale with the tenant's total subscription
332+
# count on its own database right at restart. Orphans are cleared by the cold-start / OID-change
333+
# wipe instead.
334+
@spec readopt_monitors(:ets.tid()) :: :ok
335+
defp readopt_monitors(subscribers_pids_table) do
336+
subscribers_pids_table
337+
|> :ets.tab2list()
338+
|> Enum.each(fn {pid, id, old_ref, node} ->
339+
new_ref = Process.monitor(pid)
340+
:ets.delete_object(subscribers_pids_table, {pid, id, old_ref, node})
341+
:ets.insert(subscribers_pids_table, {pid, id, new_ref, node})
342+
end)
343+
end
344+
310345
@spec pop_not_alive_pids([pid()], :ets.tid(), :ets.tid(), binary()) :: [Ecto.UUID.t()]
311346
def pop_not_alive_pids(pids, subscribers_pids_table, subscribers_nodes_table, tenant_id) do
312347
Enum.reduce(pids, [], fn pid, acc ->

test/realtime/extensions/cdc_rls/subscription_manager_test.exs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,152 @@ defmodule Realtime.Extensions.CdcRls.SubscriptionManagerTest do
152152
end
153153
end
154154

155+
describe "warm restart (re-adopt)" do
156+
test "keeps DB rows and re-monitors surviving subscribers", %{pid: pid, args: args, publication: publication} do
157+
{:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
158+
{uuid, bin_uuid, pg_change_params} = pg_change_params()
159+
160+
subscriber = spawn(fn -> receive do: (:stop -> :ok) end)
161+
162+
assert {:ok, _} = Subscriptions.create(conn, publication, [pg_change_params], pid, subscriber)
163+
:sys.get_state(pid)
164+
165+
[{^subscriber, ^uuid, old_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber)
166+
167+
# Warm restart: the ETS tables are owned by the test (acting as WorkerSupervisor), so they
168+
# survive while only the manager restarts.
169+
new_pid = restart_manager(pid, args)
170+
{:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
171+
172+
# DB rows are untouched
173+
assert %{rows: [[count]]} =
174+
Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
175+
bin_uuid
176+
])
177+
178+
assert count > 0
179+
180+
# The subscriber is re-adopted with a fresh monitor
181+
assert [{^subscriber, ^uuid, new_ref, _node}] = :ets.lookup(args["subscribers_pids_table"], subscriber)
182+
assert new_ref != old_ref
183+
184+
# The fresh monitor actually works: killing the subscriber cleans it up
185+
send(subscriber, :stop)
186+
Process.monitor(subscriber)
187+
assert_receive {:DOWN, _, :process, ^subscriber, _}, 100
188+
:sys.get_state(new_pid)
189+
190+
assert :ets.lookup(args["subscribers_pids_table"], subscriber) == []
191+
assert :ets.lookup(args["subscribers_nodes_table"], bin_uuid) == []
192+
end
193+
194+
test "does not touch the DB: orphan rows are left untouched", %{pid: pid, args: args, publication: publication} do
195+
{:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
196+
197+
# A live subscription (present in both ETS and the DB)
198+
live = spawn_link(fn -> receive do: (:stop -> :ok) end)
199+
{_uuid_a, bin_a, params_a} = pg_change_params()
200+
assert {:ok, _} = Subscriptions.create(conn, publication, [params_a], pid, live)
201+
:sys.get_state(pid)
202+
203+
# A DB orphan: a real row whose ETS entries we drop (mimics a {:subscribed} dropped during
204+
# downtime). The warm path must leave it alone — orphan cleanup is not on the restart path.
205+
orphan = spawn_link(fn -> receive do: (:stop -> :ok) end)
206+
{_uuid_b, bin_b, params_b} = pg_change_params()
207+
assert {:ok, _} = Subscriptions.create(conn, publication, [params_b], pid, orphan)
208+
:sys.get_state(pid)
209+
:ets.delete(args["subscribers_pids_table"], orphan)
210+
:ets.delete(args["subscribers_nodes_table"], bin_b)
211+
212+
new_pid = restart_manager(pid, args)
213+
{:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
214+
215+
# Both the live subscription and the orphan rows still exist — no reconcile / wipe happened
216+
assert %{rows: [[count_a]]} =
217+
Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
218+
bin_a
219+
])
220+
221+
assert %{rows: [[count_b]]} =
222+
Postgrex.query!(conn2, "select count(*) from realtime.subscription where subscription_id = $1::uuid", [
223+
bin_b
224+
])
225+
226+
assert count_a > 0
227+
assert count_b > 0
228+
end
229+
230+
test "the new manager monitors exactly the surviving subscribers", %{
231+
pid: pid,
232+
args: args,
233+
publication: publication
234+
} do
235+
{:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
236+
237+
sub1 = spawn(fn -> receive do: (:stop -> :ok) end)
238+
sub2 = spawn_link(fn -> receive do: (:stop -> :ok) end)
239+
{_u1, _b1, params1} = pg_change_params()
240+
{_u2, _b2, params2} = pg_change_params()
241+
assert {:ok, _} = Subscriptions.create(conn, publication, [params1], pid, sub1)
242+
assert {:ok, _} = Subscriptions.create(conn, publication, [params2], pid, sub2)
243+
:sys.get_state(pid)
244+
245+
# The original manager monitors both subscribers
246+
assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(pid))
247+
248+
new_pid = restart_manager(pid, args)
249+
250+
# After the warm restart the new manager monitors both surviving subscribers, and the old
251+
# manager (now dead) no longer holds any monitors
252+
assert MapSet.subset?(MapSet.new([sub1, sub2]), monitored_pids(new_pid))
253+
refute Process.alive?(pid)
254+
255+
# The fresh monitors are wired to the new manager: when a subscriber dies, the new manager
256+
# drops both its monitor and its ETS entry
257+
send(sub1, :stop)
258+
Process.monitor(sub1)
259+
assert_receive {:DOWN, _, :process, ^sub1, _}, 100
260+
261+
:sys.get_state(new_pid)
262+
monitored = monitored_pids(new_pid)
263+
refute MapSet.member?(monitored, sub1)
264+
assert MapSet.member?(monitored, sub2)
265+
assert :ets.lookup(args["subscribers_pids_table"], sub1) == []
266+
end
267+
end
268+
269+
describe "cold start (empty ETS)" do
270+
test "deletes all subscriptions from the DB", %{pid: pid, args: args, publication: publication} do
271+
{:ok, ^pid, conn} = PostgresCdcRls.get_manager_conn(args["id"])
272+
273+
subscriber = spawn_link(fn -> receive do: (:stop -> :ok) end)
274+
{_uuid, _bin, params} = pg_change_params()
275+
assert {:ok, _} = Subscriptions.create(conn, publication, [params], pid, subscriber)
276+
:sys.get_state(pid)
277+
278+
assert %{rows: [[seeded]]} = Postgrex.query!(conn, "select count(*) from realtime.subscription", [])
279+
assert seeded > 0
280+
281+
# Simulate a cold start: a fresh WorkerSupervisor hands the manager brand new, empty ETS
282+
# tables. The DB rows from the previous run must be wiped.
283+
GenServer.stop(pid)
284+
empty_pids = :ets.new(__MODULE__, [:public, :bag])
285+
empty_nodes = :ets.new(__MODULE__, [:public, :set])
286+
287+
cold_args = %{
288+
args
289+
| "subscribers_pids_table" => empty_pids,
290+
"subscribers_nodes_table" => empty_nodes
291+
}
292+
293+
{:ok, new_pid} = SubscriptionManager.start_link(cold_args)
294+
:sys.get_state(new_pid)
295+
296+
{:ok, ^new_pid, conn2} = PostgresCdcRls.get_manager_conn(args["id"])
297+
assert %{rows: [[0]]} = Postgrex.query!(conn2, "select count(*) from realtime.subscription", [])
298+
end
299+
end
300+
155301
describe "check no users" do
156302
test "exit is sent to manager", %{pid: pid} do
157303
:sys.replace_state(pid, fn state -> %{state | no_users_ts: 0} end)
@@ -452,6 +598,21 @@ defmodule Realtime.Extensions.CdcRls.SubscriptionManagerTest do
452598
end
453599
end
454600

601+
# Simulates a SubscriptionManager-only restart: the ETS tables in `args` are owned by the test
602+
# process (acting as the WorkerSupervisor) and survive, so the new manager re-adopts them.
603+
defp restart_manager(pid, args) do
604+
GenServer.stop(pid)
605+
{:ok, new_pid} = SubscriptionManager.start_link(args)
606+
:sys.get_state(new_pid)
607+
new_pid
608+
end
609+
610+
# The set of processes `pid` is currently monitoring.
611+
defp monitored_pids(pid) do
612+
{:monitors, monitors} = Process.info(pid, :monitors)
613+
for {:process, monitored} <- monitors, into: MapSet.new(), do: monitored
614+
end
615+
455616
defp pg_change_params do
456617
uuid = UUID.uuid1()
457618

0 commit comments

Comments
 (0)