@@ -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