Tell an empty answer apart from no answer

4758a10 · Gabriel Morell · 2026-09-02 19:11

10 files +55 -5
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+6 −1
@@ -1617,7 +1617,12 @@ defmodule RoomSanctum.Storage do
1617 1617 tz: ^tz
1618 1618 }
1619 1619 )
1620 − |> Repo.all()
1620 + # Ecto's default of 15s was cancelling arrival lookups that the caller was
1621 + # perfectly willing to wait for -- the vision allows a query 25s -- and a
1622 + # cancelled statement raises, which used to publish "nothing is coming" on
1623 + # the strength of a timeout. The layers now agree; the lookups being this
1624 + # slow at all is a separate problem.
1625 + |> Repo.all(timeout: 20_000)
1621 1626
1622 1627 # q =
1623 1628 # from st in StopTime,
modified apps/room_sanctum/lib/room_sanctum/workers/vision_worker.ex
+14 −4
@@ -276,27 +276,37 @@ defmodule RoomSanctum.Worker.Vision do
276 276 # was slow or broken, and is why waiting would be the wrong move.
277 277 defp query_cached(q) do
278 278 case RoomSanctum.QueryCache.fetch({q.id, q.source.type}, fn -> query_safely(q) end) do
279 − {:ok, result} -> {:ok, result}
279 + # query_safely already tags its own outcome, so this is {:ok, {:ok, _}}
280 + # or {:ok, :skip} -- the cache does not care which it is holding.
281 + {:ok, outcome} -> outcome
280 282 :busy -> :skip
281 283 end
282 284 end
283 285
286 + # `:skip` rather than an empty list, and the difference matters.
287 + #
288 + # An empty list is a real answer -- nothing calls at this stop in the next
289 + # hour -- and writing it is correct. A query that raised has no answer at
290 + # all, and returning [] for it published "nothing is coming" on the strength
291 + # of a database timeout. The panel blanked, which is the very thing keeping
292 + # the last value was meant to prevent; it just arrived by a different route
293 + # than a task being killed.
284 294 defp query_safely(q) do
285 − query_one(q)
295 + {:ok, query_one(q)}
286 296 rescue
287 297 e ->
288 298 Logger.warning(
289 299 "Query failed for #{inspect(q.source.type)} (#{q.source.id}): #{Exception.message(e)}"
290 300 )
291 301
292 − []
302 + :skip
293 303 catch
294 304 kind, reason ->
295 305 Logger.warning(
296 306 "Query #{kind} for #{inspect(q.source.type)} (#{q.source.id}): #{inspect(reason)}"
297 307 )
298 308
299 − []
309 + :skip
300 310 end
301 311
302 312 defp query_one(q) do
modified apps/room_sanctum/test/room_sanctum/vision_merge_test.exs
+35 −0
@@ -192,6 +192,41 @@ defmodule RoomSanctum.VisionMergeTest do
192 192 end
193 193 end
194 194
195 + describe "an empty answer versus no answer" do
196 + test "a genuinely empty result is written" do
197 + {ref, entry} = outstanding(@key)
198 +
199 + s0 = state(%{data: %{@key => [:yesterday]}, inflight: %{ref => entry}})
200 + {:noreply, s} = Vision.handle_info({ref, {:ok, []}}, s0)
201 +
202 + # Nothing calls at this stop in the next hour is a real answer.
203 + assert s.data[@key] == []
204 + end
205 +
206 + test "a query that raised keeps what it had" do
207 + {ref, entry} = outstanding(@key)
208 +
209 + s0 = state(%{data: %{@key => [:yesterday]}, inflight: %{ref => entry}})
210 + {:noreply, s} = Vision.handle_info({ref, :skip}, s0)
211 +
212 + # Returning [] here published "nothing is coming" on the strength of a
213 + # database timeout.
214 + assert s.data[@key] == [:yesterday]
215 + assert s.inflight == %{}
216 + end
217 +
218 + test "a skip does not move the timestamp either" do
219 + {ref, entry} = outstanding(@key)
220 + at = %{@key => ~U[2026-09-01 00:00:00Z]}
221 +
222 + s0 = state(%{data: %{@key => [:a]}, data_at: at, inflight: %{ref => entry}})
223 + {:noreply, s} = Vision.handle_info({ref, :skip}, s0)
224 +
225 + # The panel is as old as its data actually is.
226 + assert s.data_at[@key] == ~U[2026-09-01 00:00:00Z]
227 + end
228 + end
229 +
195 230 describe "state given out" do
196 231 test "carries when each answer arrived" do
197 232 at = %{@key => ~U[2026-09-01 12:00:00Z]}

Parents: 04f2bcb