Survive a node that is shutting down underneath a query

c59c3a0 · Gabriel Morell · 2026-09-01 20:58

8 files +95 -11
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+26 −0
@@ -88,6 +88,9 @@ defmodule RoomGtfs.Worker do
88 88 |> via_tuple()
89 89 |> GenServer.call({:query_realtime, trips, stop}, 5_000)
90 90 |> Enum.map(&slim_entity/1)
91 + rescue
92 + # As above: a stopped Registry raises rather than exiting.
93 + ArgumentError -> []
91 94 catch
92 95 :exit, _ -> []
93 96 end
@@ -132,6 +135,12 @@ defmodule RoomGtfs.Worker do
132 135 "gtfs-rt#{name}"
133 136 |> via_tuple()
134 137 |> GenServer.call({:query_alerts, stop, route_ids}, 10_000)
138 + rescue
139 + # A Registry that has stopped -- which is what a shutting-down node
140 + # looks like -- raises from the lookup before any call happens, where a
141 + # dead process would have exited. Catching only the exit left every
142 + # in-flight caller to crash on the way out of a deploy.
143 + ArgumentError -> []
135 144 catch
136 145 :exit, _ -> []
137 146 end
@@ -149,6 +158,12 @@ defmodule RoomGtfs.Worker do
149 158 "gtfs-rt#{name}"
150 159 |> via_tuple()
151 160 |> GenServer.call(:current_alerts, 10_000)
161 + rescue
162 + # A Registry that has stopped -- which is what a shutting-down node
163 + # looks like -- raises from the lookup before any call happens, where a
164 + # dead process would have exited. Catching only the exit left every
165 + # in-flight caller to crash on the way out of a deploy.
166 + ArgumentError -> []
152 167 catch
153 168 :exit, _ -> []
154 169 end
@@ -162,6 +177,9 @@ defmodule RoomGtfs.Worker do
162 177 :miss ->
163 178 try do
164 179 "gtfs-rt#{name}" |> via_tuple() |> GenServer.call(:query_vehicle_positions, 5_000)
180 + rescue
181 + # As above: a stopped Registry raises rather than exiting.
182 + ArgumentError -> []
165 183 catch
166 184 :exit, _ -> []
167 185 end
@@ -304,6 +322,8 @@ defmodule RoomGtfs.Worker do
304 322 _val ->
305 323 try do
306 324 get_current_vehicle_positions(id, trips, @occupancy_timeout_ms)
325 + rescue
326 + ArgumentError -> []
307 327 catch
308 328 :exit, _ -> []
309 329 end
@@ -670,6 +690,12 @@ defmodule RoomGtfs.Worker.RT do
670 690 "gtfs-rt#{name}"
671 691 |> via_tuple()
672 692 |> GenServer.call(:stats)
693 + rescue
694 + # A Registry that has stopped -- which is what a shutting-down node
695 + # looks like -- raises from the lookup before any call happens, where a
696 + # dead process would have exited. Catching only the exit left every
697 + # in-flight caller to crash on the way out of a deploy.
698 + ArgumentError -> %{rt_sa: :unavailable, rt_tu: :unavailable, rt_vp: :unavailable}
673 699 catch
674 700 :exit, _ -> %{rt_sa: :unavailable, rt_tu: :unavailable, rt_vp: :unavailable}
675 701 end
modified apps/room_gtfs/lib/room_gtfs/rt_index.ex
+24 −7
@@ -78,7 +78,7 @@ defmodule RoomGtfs.RTIndex do
78 78 |> Enum.map(&{{source_id, :vp, &1.trip_id}, &1})
79 79
80 80 replace(source_id, :vp, rows)
81 − :ets.insert(@table, {{source_id, :vp_all}, vehicles})
81 + if exists?(), do: :ets.insert(@table, {{source_id, :vp_all}, vehicles})
82 82 :ok
83 83 end
84 84
@@ -140,9 +140,9 @@ defmodule RoomGtfs.RTIndex do
140 140 end
141 141
142 142 def vehicles(source_id) do
143 − case :ets.lookup(@table, {source_id, :vp_all}) do
143 + case exists?() and :ets.lookup(@table, {source_id, :vp_all}) do
144 144 [{_k, vehicles}] -> {:ok, vehicles}
145 − [] -> :miss
145 + _otherwise -> :miss
146 146 end
147 147 end
148 148
@@ -150,16 +150,33 @@ defmodule RoomGtfs.RTIndex do
150 150 # Whether this source has ever stored a feed of this kind. Distinct from
151 151 # having no rows: a feed can legitimately arrive carrying nothing.
152 152 def indexed?(source_id, kind) do
153 − :ets.member(@table, {source_id, {:present, kind}})
153 + exists?() and :ets.member(@table, {source_id, {:present, kind}})
154 154 end
155 155
156 + @doc false
157 + # The table is not always there, and every access has to survive that.
158 + #
159 + # It is created by this app's supervisor and dies with it, so there are two
160 + # windows either side of a running node: before the supervisor has started,
161 + # and after it has stopped. The second is the one that bit -- during a
162 + # deploy, the old node's ETS table goes while LiveViews are still asking it
163 + # for arrivals, and `:ets.member/2` against a table that has gone raises
164 + # rather than answering. Every caller holding a query crashed on the way out.
165 + #
166 + # A missing table means nothing is indexed, which is a state the readers
167 + # already handle: they fall back to asking the worker.
168 + def exists?, do: :ets.whereis(@table) != :undefined
169 +
156 170 # The marker is a two-element key on purpose: the per-trip rows are scanned
157 171 # with a three-element pattern for suffix matching, and a marker shaped like
158 172 # one of them comes back from that scan as a trip whose id is an atom.
159 173 defp replace(source_id, kind, rows) do
160 − :ets.match_delete(@table, {{source_id, kind, :_}, :_})
161 − :ets.insert(@table, rows)
162 − :ets.insert(@table, {{source_id, {:present, kind}}, true})
174 + if exists?() do
175 + :ets.match_delete(@table, {{source_id, kind, :_}, :_})
176 + :ets.insert(@table, rows)
177 + :ets.insert(@table, {{source_id, {:present, kind}}, true})
178 + end
179 +
163 180 :ok
164 181 end
165 182
modified apps/room_gtfs/test/rt_index_test.exs
+45 −4
@@ -12,15 +12,29 @@ defmodule RoomGtfs.RTIndexTest do
12 12 alias RoomGtfs.RTIndex
13 13 alias TransitRealtime, as: T
14 14
15 + # The table's owner is linked to whoever started it, so in tests it does not
16 + # outlive the process that created it. Each test therefore makes sure the
17 + # table is there rather than assuming a previous one left it behind -- which
18 + # also makes the "table is gone" tests below safe to run in any order.
15 19 setup do
20 + ensure_table()
21 +
22 + id = System.unique_integer([:positive])
23 +
24 + on_exit(fn ->
25 + if :ets.whereis(RTIndex.table()) != :undefined do
26 + :ets.match_delete(RTIndex.table(), {{id, :_, :_}, :_})
27 + end
28 + end)
29 +
30 + %{id: id}
31 + end
32 +
33 + defp ensure_table do
16 34 if :ets.whereis(RTIndex.table()) == :undefined do
17 35 {:ok, _} = RTIndex.start_link()
18 36 Process.sleep(20)
19 37 end
20 −
21 − id = System.unique_integer([:positive])
22 − on_exit(fn -> :ets.match_delete(RTIndex.table(), {{id, :_, :_}, :_}) end)
23 − %{id: id}
24 38 end
25 39
26 40 defp entity(trip_id, stop_ids, attrs \\ []) do
@@ -124,6 +138,33 @@ defmodule RoomGtfs.RTIndexTest do
124 138 end
125 139 end
126 140
141 + describe "when the table is not there" do
142 + # It is created by the app's supervisor and dies with it, so there are two
143 + # windows either side of a running node. The second is the one that bit: on
144 + # a deploy the old node's table goes while LiveViews are still asking for
145 + # arrivals, and every caller holding a query crashed on the way out.
146 + setup do
147 + # The next test's setup puts it back.
148 + if :ets.whereis(RTIndex.table()) != :undefined do
149 + :ets.delete(RTIndex.table())
150 + end
151 +
152 + :ok
153 + end
154 +
155 + test "reads answer :miss rather than raising", %{id: id} do
156 + assert RTIndex.trip_updates(id, ["t1"], "s1") == :miss
157 + assert RTIndex.trip_updates_by_suffix(id, ["t1"], "s1") == :miss
158 + assert RTIndex.vehicles(id) == :miss
159 + assert RTIndex.vehicles(id, ["t1"]) == :miss
160 + end
161 +
162 + test "writes are dropped rather than raising", %{id: id} do
163 + assert RTIndex.put_trip_updates(id, [entity("t1", ["s1"])]) == :ok
164 + assert RTIndex.put_vehicles(id, [%{vehicle_id: "v1", trip_id: "t1"}]) == :ok
165 + end
166 + end
167 +
127 168 describe "vehicles" do
128 169 test "by trip, and all at once", %{id: id} do
129 170 vehicles = [

Parents: b85c005