neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
⭐
Log in to mark this repository.
Tell an empty answer apart from no answer
4758a10 · Gabriel Morell · 2026-09-02 19:11
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