Stop the offering page loading a whole country every ten seconds

17836dc · Gabriel Morell · 2026-09-07 02:58

11 files +59 -1
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+18 −0
@@ -1057,6 +1057,24 @@ defmodule RoomSanctum.Storage do
1057 1057 |> Repo.all()
1058 1058 end
1059 1059
1060 + @doc """
1061 + The first `limit` stops of a source, for callers that cannot use all of them.
1062 +
1063 + A map is the case this exists for. Germany's feed has stops by the hundred
1064 + thousand, and drawing them is neither possible nor useful -- but *fetching*
1065 + them is what actually broke: the offering page reloaded every stop on a ten
1066 + second timer, which at that size took longer than the fifteen second query
1067 + timeout and killed the LiveView on a loop.
1068 +
1069 + Ordered, so the same stops come back each time rather than whatever the
1070 + planner felt like -- a map whose markers reshuffle every tick is worse than
1071 + one showing a stable subset.
1072 + """
1073 + def list_stops(source_id, limit) when is_integer(limit) do
1074 + from(p in Stop, where: p.source_id == ^source_id, order_by: p.stop_id, limit: ^limit)
1075 + |> Repo.all()
1076 + end
1077 +
1060 1078 def list_stops(source_id, search_term) do
1061 1079 from(p in Stop,
1062 1080 where:
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.ex
+9 −0
@@ -30,6 +30,10 @@ defmodule RoomSanctumWeb.SourceLive.Show do
30 30 |> assign(:vehicle_positions, [])
31 31 |> assign(:free_bikes, [])
32 32 |> assign(:stations, [])
33 + # Set here as well as on the tick: the dead render happens before the
34 + # first tick, and a template reading an assign that is not there yet is
35 + # a KeyError on the very first paint.
36 + |> assign(:station_count, 0)
33 37 # Both are otherwise only set by the :update_sec tick 200ms in, so any
34 38 # view touching them could be rendered before they exist.
35 39 |> assign(:station_statuses, [])
@@ -76,6 +80,10 @@ defmodule RoomSanctumWeb.SourceLive.Show do
76 80
77 81 stations = Stations.for_source(socket.assigns.source)
78 82
83 + # What the map is not showing. Counted rather than inferred from the list,
84 + # which cannot tell a source with exactly the cap from one with far more.
85 + station_count = Stations.count_for_source(socket.assigns.source)
86 +
79 87 # Add station status for GBFS sources
80 88 station_statuses = Stations.statuses_for_source(socket.assigns.source)
81 89
@@ -104,6 +112,7 @@ defmodule RoomSanctumWeb.SourceLive.Show do
104 112 |> assign(:available_tints, available_tints)
105 113 |> assign(:free_bikes, free_bikes)
106 114 |> assign(:stations, stations)
115 + |> assign(:station_count, station_count)
107 116 |> assign(:station_statuses, station_statuses)
108 117 |> assign(:route_types, route_types)
109 118 |> assign(:aircraft, aircraft)
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/show.html.heex
+6 −0
@@ -491,6 +491,12 @@ gl_available_projects={@gitlab_available_projects}
491 491 add_query_event="map-add-query"
492 492 queried_station_ids={Enum.map(@queries, &station_id_of/1)}
493 493 />
494 + <%!-- A national feed has stops by the hundred thousand.
495 + The map draws a bounded subset; saying so is better
496 + than a map that looks complete and is not. --%>
497 + <p :if={@station_count > length(@stations)} class="text-xs opacity-60 mt-1">
498 + Showing <%= length(@stations) %> of <%= @station_count %> stops.
499 + </p>
494 500 </div>
495 501
496 502 <% @view_mode == :paint -> %>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_source_live/stations.ex
+26 −1
@@ -25,6 +25,31 @@ defmodule RoomSanctumWeb.SourceLive.Stations do
25 25
26 26 @mappable [:gtfs, :gbfs, :aqi]
27 27
28 + # How many stops one source may contribute to a map.
29 + #
30 + # A national feed has hundreds of thousands, and no map draws that -- but the
31 + # cost that mattered was fetching them: the offering page reloads its
32 + # stations on a ten second timer, and for Germany that query outran the
33 + # fifteen second timeout and took the LiveView down with it, every tick.
34 + #
35 + # Two thousand is well past what is legible on a map and small enough that
36 + # the query is never the slow part. `count_for_source/1` says how many there
37 + # really are, so a capped map can admit to it rather than quietly lying about
38 + # the size of the feed.
39 + @map_cap 2000
40 +
41 + @doc "The cap `for_source/1` applies to a single source's stops."
42 + def map_cap, do: @map_cap
43 +
44 + @doc """
45 + How many places a source actually has, capped or not.
46 +
47 + Counted in the database rather than by measuring what came back, which
48 + cannot tell a source with exactly the cap from one with far more.
49 + """
50 + def count_for_source(%{type: :gtfs, id: id}), do: Storage.count_stops(id)
51 + def count_for_source(source), do: source |> for_source() |> length()
52 +
28 53 @doc "Whether a source has geography of its own to draw."
29 54 def mappable?(%{type: type}), do: type in @mappable
30 55 def mappable?(_), do: false
@@ -40,7 +65,7 @@ defmodule RoomSanctumWeb.SourceLive.Stations do
40 65 def for_source(%{type: :gbfs, id: id}), do: Storage.list_gbfs_station_information(id)
41 66
42 67 def for_source(%{type: :gtfs, id: id}),
43 − do: id |> Storage.list_stops() |> Enum.map(&stop_as_station/1)
68 + do: id |> Storage.list_stops(@map_cap) |> Enum.map(&stop_as_station/1)
44 69
45 70 def for_source(%{type: :aqi, id: id}),
46 71 do: id |> Storage.list_aqi_stations() |> Enum.map(&site_as_station/1)

Parents: f7912e4