Filter arrivals to today's service, and read realtime from a table

af6ed7e · Gabriel Morell · 2026-09-01 20:10

17 files +685 -28
Message
{commit_body(@commit)}

Files changed

modified apps/room_gtfs/lib/room_gtfs/application.ex
+4 −2
@@ -16,7 +16,9 @@ defmodule RoomGtfs.Application do
16 16 boot rather than lazily at the first decode.
17 17
18 18 It also owns `RoomGtfs.FeedCache`, so that several sources pointing at one
19 − realtime URL fetch it once between them. The per-source workers are not
19 + realtime URL fetch it once between them, and `RoomGtfs.RTIndex`, the table
20 + the realtime feeds are read out of -- owned here rather than by a worker so
21 + that a worker restarting does not take its source's data with it. The per-source workers are not
20 22 started here -- room_zeus does that, one per configured source.
21 23 """
22 24
@@ -29,7 +31,7 @@ defmodule RoomGtfs.Application do
29 31 # start callback, it is.
30 32 :ok = Protobuf.load_extensions()
31 33
32 − children = [RoomGtfs.FeedCache]
34 + children = [RoomGtfs.FeedCache, RoomGtfs.RTIndex]
33 35
34 36 Supervisor.start_link(children, strategy: :one_for_one, name: RoomGtfs.Supervisor)
35 37 end
modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+100 −21
@@ -55,11 +55,76 @@ defmodule RoomGtfs.Worker do
55 55 |> GenServer.cast(:update_realtime)
56 56 end
57 57
58 − def get_current_realtime(name, trips, stop) do
59 −# IO.inspect({name, trips, stop})
60 − "gtfs-rt#{name}"
61 − |> via_tuple
62 − |> GenServer.call({:query_realtime, trips, stop}, 30_000)
58 + @doc """
59 + The realtime trip updates for a stop, read rather than asked for.
60 +
61 + This used to be a `GenServer.call` into the source's RT worker -- the same
62 + process that fetches the feeds over HTTP -- so it queued behind those fetches
63 + and behind every other caller. A stop lookup could take tens of seconds, and
64 + the vision that asked for it gives up at thirty.
65 +
66 + The table is written on each poll and read here directly. The call remains
67 + for the window before a source has stored its first feed.
68 + """
69 + def get_current_realtime(name, trips, stop, config \\ nil) do
70 + # The caller usually holds the source already. Reading it again here would
71 + # put back one of the queries that taking the timezone as an argument
72 + # removed.
73 + config = config || source_config(name)
74 +
75 + indexed =
76 + case config do
77 + %{rt_trip_id_suffix: true} -> RoomGtfs.RTIndex.trip_updates_by_suffix(name, trips, stop)
78 + _otherwise -> RoomGtfs.RTIndex.trip_updates(name, trips, stop)
79 + end
80 +
81 + case indexed do
82 + {:ok, updates} ->
83 + updates
84 +
85 + :miss ->
86 + try do
87 + "gtfs-rt#{name}"
88 + |> via_tuple()
89 + |> GenServer.call({:query_realtime, trips, stop}, 5_000)
90 + |> Enum.map(&slim_entity/1)
91 + catch
92 + :exit, _ -> []
93 + end
94 + end
95 + end
96 +
97 + # The worker still replies with protobuf entities; the index speaks a smaller
98 + # shape, and callers should only have to know one of them.
99 + defp slim_entity(%{trip_update: tu}) do
100 + stu = tu.stop_time_update
101 +
102 + %{
103 + trip_id: tu.trip.trip_id,
104 + schedule_relationship: tu.trip.schedule_relationship,
105 + timestamp: tu.timestamp,
106 + stops: %{},
107 + stop:
108 + stu &&
109 + %{
110 + arrival: stu.arrival,
111 + departure: stu.departure,
112 + schedule_relationship: stu.schedule_relationship,
113 + stop_time_properties: stu.stop_time_properties,
114 + departure_occupancy_status: stu.departure_occupancy_status
115 + }
116 + }
117 + end
118 +
119 + # The suffix rule is a property of the source, and reading it here keeps the
120 + # lookup out of the worker entirely.
121 + defp source_config(name) do
122 + case Configuration.get_source!(:bare, name) do
123 + %{config: config} -> config
124 + _otherwise -> %{}
125 + end
126 + rescue
127 + _ -> %{}
63 128 end
64 129
65 130 def query_alerts(name, stop, route_ids) do
@@ -90,15 +155,27 @@ defmodule RoomGtfs.Worker do
90 155 end
91 156
92 157 def get_current_vehicle_positions(name) do
93 − "gtfs-rt#{name}"
94 − |> via_tuple
95 − |> GenServer.call(:query_vehicle_positions, 30_000)
158 + case RoomGtfs.RTIndex.vehicles(name) do
159 + {:ok, vehicles} ->
160 + vehicles
161 +
162 + :miss ->
163 + try do
164 + "gtfs-rt#{name}" |> via_tuple() |> GenServer.call(:query_vehicle_positions, 5_000)
165 + catch
166 + :exit, _ -> []
167 + end
168 + end
96 169 end
97 170
98 − def get_current_vehicle_positions(name, trips, timeout \\ 30_000) do
99 − "gtfs-rt#{name}"
100 − |> via_tuple
101 − |> GenServer.call({:query_vehicle_positions, trips}, timeout)
171 + def get_current_vehicle_positions(name, trips, timeout \\ 5_000) do
172 + case RoomGtfs.RTIndex.vehicles(name, trips) do
173 + {:ok, vehicles} ->
174 + vehicles
175 +
176 + :miss ->
177 + "gtfs-rt#{name}" |> via_tuple() |> GenServer.call({:query_vehicle_positions, trips}, timeout)
178 + end
102 179 end
103 180
104 181 @doc """
@@ -157,7 +234,7 @@ defmodule RoomGtfs.Worker do
157 234 _val ->
158 235 trips = res |> Enum.map(fn x -> x.trip_id end)
159 236
160 − case get_current_realtime(id, trips, query.stop) do
237 + case get_current_realtime(id, trips, query.stop, inst.config) do
161 238 [] ->
162 239 res
163 240
@@ -170,19 +247,19 @@ defmodule RoomGtfs.Worker do
170 247 res
171 248 |> Enum.map(fn x ->
172 249 case Enum.find(rtvals, fn v ->
173 − trip_id_match?(inst.config, x.trip_id, v.trip_update.trip.trip_id)
250 + trip_id_match?(inst.config, x.trip_id, v.trip_id)
174 251 end) do
175 252 nil ->
176 253 x
177 254
178 255 val ->
179 − stu = val.trip_update.stop_time_update
256 + stu = val.stop
180 257 time_event = stu && (stu.arrival || stu.departure)
181 258
182 259 x =
183 260 x
184 261 |> merge_occupancy(occupancy_for(inst.config, x.trip_id, stu, vehicles))
185 − |> merge_schedule_relationship(val.trip_update, stu)
262 + |> merge_schedule_relationship(val.schedule_relationship, stu)
186 263 |> merge_stop_properties(stu)
187 264
188 265 case time_event do
@@ -322,11 +399,9 @@ defmodule RoomGtfs.Worker do
322 399 #
323 400 # SCHEDULED is the proto default and means "as published", so it is left off
324 401 # rather than recorded -- only a departure from the schedule is news.
325 − def merge_schedule_relationship(arrival, trip_update, stu) do
326 − trip = trip_update && trip_update.trip
327 −
402 + def merge_schedule_relationship(arrival, trip_relationship, stu) do
328 403 arrival
329 − |> maybe_put(:trip_status, relationship(trip && trip.schedule_relationship))
404 + |> maybe_put(:trip_status, relationship(trip_relationship))
330 405 |> maybe_put(:stop_status, relationship(stu && stu.schedule_relationship))
331 406 end
332 407
@@ -1230,11 +1305,15 @@ defmodule RoomGtfs.Worker.RT do
1230 1305 # nothing they see.
1231 1306 defp store_feed(state, :sa, feed), do: Map.put(state, :rt_sa, feed)
1232 1307
1233 − defp store_feed(state, :tu, feed), do: Map.put(state, :rt_tu, feed)
1308 + defp store_feed(state, :tu, feed) do
1309 + RoomGtfs.RTIndex.put_trip_updates(state.id, feed.entity)
1310 + Map.put(state, :rt_tu, feed)
1311 + end
1234 1312
1235 1313 defp store_feed(state, :vp, feed) do
1236 1314 state = state |> Map.put(:rt_vp, feed) |> ensure_stops()
1237 1315 vehicles = vehicle_positions_from(feed, state.stops)
1316 + RoomGtfs.RTIndex.put_vehicles(state.id, vehicles)
1238 1317
1239 1318 # The source page watches its own channel; the query pages watch the shared
1240 1319 # one and sort out which vehicles are theirs.
added apps/room_gtfs/lib/room_gtfs/rt_index.ex
+184 −0
@@ -0,0 +1,184 @@
1 +defmodule RoomGtfs.RTIndex do
2 + @moduledoc """
3 + The realtime feeds, indexed by trip and readable without asking anyone.
4 +
5 + Arrivals used to be answered by two `GenServer.call`s into the source's RT
6 + worker -- one for trip updates, one for vehicle positions. That worker also
7 + fetches the feeds over HTTP, so the calls queued behind each other and behind
8 + the fetches, and a stop lookup could take tens of seconds. The vision that
9 + asked for it gives up after 30 and kills its own task, so a busy source meant
10 + a board that showed nothing at all.
11 +
12 + A realtime feed is immutable data replaced on a timer, which is the shape ETS
13 + is for. Readers take what they need straight out of the table; nothing
14 + serialises behind the worker's own work.
15 +
16 + Indexed by trip rather than stored whole, deliberately. A `:tu` feed is ~1600
17 + entities, and handing a reader all of them to find its sixteen would copy far
18 + more than the GenServer ever did -- the call at least filtered before
19 + replying. One row per trip, holding only the fields anything reads, means a
20 + lookup copies sixteen small maps.
21 +
22 + The table is public so each source's worker writes its own rows, and owned by
23 + this app's supervisor rather than by a worker, so a worker restarting does
24 + not take the data with it.
25 + """
26 +
27 + @table :gtfs_rt_index
28 +
29 + def table, do: @table
30 +
31 + @doc false
32 + def child_spec(_opts) do
33 + %{id: __MODULE__, start: {__MODULE__, :start_link, []}}
34 + end
35 +
36 + @doc false
37 + def start_link do
38 + Task.start_link(fn ->
39 + :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
40 + Process.hibernate(Process, :sleep, [:infinity])
41 + end)
42 + end
43 +
44 + @doc """
45 + Replace a source's trip updates with what just arrived.
46 +
47 + Written as one row per trip, carrying only what a stop lookup reads: the
48 + trip's own identity and relationship, and its stop times keyed by stop id.
49 + """
50 + def put_trip_updates(source_id, entities) do
51 + rows =
52 + entities
53 + |> Enum.filter(&(&1.trip_update && &1.trip_update.trip && &1.trip_update.trip.trip_id))
54 + |> Enum.map(fn %{trip_update: tu} ->
55 + {{source_id, :tu, tu.trip.trip_id},
56 + %{
57 + trip_id: tu.trip.trip_id,
58 + schedule_relationship: tu.trip.schedule_relationship,
59 + timestamp: tu.timestamp,
60 + stops: stops_by_id(tu.stop_time_update)
61 + }}
62 + end)
63 +
64 + replace(source_id, :tu, rows)
65 + end
66 +
67 + @doc """
68 + Replace a source's vehicle positions.
69 +
70 + Kept twice over: a row per trip, for "the vehicle on this trip", and one row
71 + holding the lot, for the map. The second is a large copy on read and is only
72 + taken by callers that genuinely want every vehicle.
73 + """
74 + def put_vehicles(source_id, vehicles) do
75 + rows =
76 + vehicles
77 + |> Enum.filter(& &1.trip_id)
78 + |> Enum.map(&{{source_id, :vp, &1.trip_id}, &1})
79 +
80 + replace(source_id, :vp, rows)
81 + :ets.insert(@table, {{source_id, :vp_all}, vehicles})
82 + :ok
83 + end
84 +
85 + @doc """
86 + The trip updates for a set of scheduled trip ids, at one stop.
87 +
88 + Answers `{:ok, list}` when the source has a feed indexed, and `:miss` when it
89 + has none -- so a caller can tell "nothing is running" from "ask the worker
90 + instead", which is what a source whose feed has never been stored needs.
91 + """
92 + def trip_updates(source_id, trip_ids, stop_id) do
93 + case indexed?(source_id, :tu) do
94 + false ->
95 + :miss
96 +
97 + true ->
98 + found =
99 + trip_ids
100 + |> Enum.flat_map(&:ets.lookup(@table, {source_id, :tu, &1}))
101 + |> Enum.map(fn {_k, tu} -> Map.put(tu, :stop, Map.get(tu.stops, stop_id)) end)
102 +
103 + {:ok, found}
104 + end
105 + end
106 +
107 + @doc """
108 + The trip updates for a set of scheduled trip ids where the feed names trips
109 + by a suffix of the scheduled id -- NYCT's shape, which no key lookup matches.
110 +
111 + A scan of one source's rows, which is still cheaper than a call into a busy
112 + worker, and only sources that declare the suffix take this path.
113 + """
114 + def trip_updates_by_suffix(source_id, trip_ids, stop_id) do
115 + case indexed?(source_id, :tu) do
116 + false ->
117 + :miss
118 +
119 + true ->
120 + found =
121 + @table
122 + |> :ets.match_object({{source_id, :tu, :_}, :_})
123 + |> Enum.filter(fn {{_, _, rt_id}, _} ->
124 + Enum.any?(trip_ids, &String.ends_with?(&1, rt_id))
125 + end)
126 + |> Enum.map(fn {_k, tu} -> Map.put(tu, :stop, Map.get(tu.stops, stop_id)) end)
127 +
128 + {:ok, found}
129 + end
130 + end
131 +
132 + @doc """
133 + The vehicles on a set of trips, or every vehicle the source is reporting.
134 + """
135 + def vehicles(source_id, trip_ids) do
136 + case indexed?(source_id, :vp) do
137 + false -> :miss
138 + true -> {:ok, Enum.flat_map(trip_ids, &(:ets.lookup(@table, {source_id, :vp, &1}) |> Enum.map(fn {_k, v} -> v end)))}
139 + end
140 + end
141 +
142 + def vehicles(source_id) do
143 + case :ets.lookup(@table, {source_id, :vp_all}) do
144 + [{_k, vehicles}] -> {:ok, vehicles}
145 + [] -> :miss
146 + end
147 + end
148 +
149 + @doc false
150 + # Whether this source has ever stored a feed of this kind. Distinct from
151 + # having no rows: a feed can legitimately arrive carrying nothing.
152 + def indexed?(source_id, kind) do
153 + :ets.member(@table, {source_id, {:present, kind}})
154 + end
155 +
156 + # The marker is a two-element key on purpose: the per-trip rows are scanned
157 + # with a three-element pattern for suffix matching, and a marker shaped like
158 + # one of them comes back from that scan as a trip whose id is an atom.
159 + 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})
163 + :ok
164 + end
165 +
166 + # A trip calls at a stop once in the ordinary case; a loop route can call
167 + # twice, and the earlier call is the one an arrivals board wants.
168 + defp stops_by_id(stop_time_updates) do
169 + stop_time_updates
170 + |> List.wrap()
171 + |> Enum.reject(&(&1.stop_id in [nil, ""]))
172 + |> Enum.reverse()
173 + |> Map.new(fn stu ->
174 + {stu.stop_id,
175 + %{
176 + arrival: stu.arrival,
177 + departure: stu.departure,
178 + schedule_relationship: stu.schedule_relationship,
179 + stop_time_properties: stu.stop_time_properties,
180 + departure_occupancy_status: stu.departure_occupancy_status
181 + }}
182 + end)
183 + end
184 +end
modified apps/room_gtfs/test/occupancy_test.exs
+1 −5
@@ -109,11 +109,7 @@ defmodule RoomGtfs.OccupancyTest do
109 109
110 110 describe "schedule relationship" do
111 111 defp merged(trip_rel, stop_rel) do
112 − Worker.merge_schedule_relationship(
113 − %{},
114 − %{trip: %{schedule_relationship: trip_rel}},
115 − %{schedule_relationship: stop_rel}
116 − )
112 + Worker.merge_schedule_relationship(%{}, trip_rel, %{schedule_relationship: stop_rel})
117 113 end
118 114
119 115 test "as published is not news" do
added apps/room_gtfs/test/rt_index_test.exs
+153 −0
@@ -0,0 +1,153 @@
1 +defmodule RoomGtfs.RTIndexTest do
2 + @moduledoc """
3 + Reading the realtime feeds out of a table instead of asking a worker for them.
4 +
5 + The worker that held these also fetches the feeds over HTTP, so every stop
6 + lookup queued behind those fetches and behind every other caller. Indexed by
7 + trip, because handing a reader the whole feed to find its sixteen trips would
8 + copy more than the call it replaced.
9 + """
10 + use ExUnit.Case, async: false
11 +
12 + alias RoomGtfs.RTIndex
13 + alias TransitRealtime, as: T
14 +
15 + setup do
16 + if :ets.whereis(RTIndex.table()) == :undefined do
17 + {:ok, _} = RTIndex.start_link()
18 + Process.sleep(20)
19 + end
20 +
21 + id = System.unique_integer([:positive])
22 + on_exit(fn -> :ets.match_delete(RTIndex.table(), {{id, :_, :_}, :_}) end)
23 + %{id: id}
24 + end
25 +
26 + defp entity(trip_id, stop_ids, attrs \\ []) do
27 + %T.FeedEntity{
28 + id: trip_id,
29 + trip_update: %T.TripUpdate{
30 + trip: struct(%T.TripDescriptor{trip_id: trip_id}, attrs),
31 + stop_time_update:
32 + Enum.map(stop_ids, fn s ->
33 + %T.TripUpdate.StopTimeUpdate{
34 + stop_id: s,
35 + arrival: %T.TripUpdate.StopTimeEvent{time: 123, delay: 60}
36 + }
37 + end)
38 + }
39 + }
40 + end
41 +
42 + describe "trip updates" do
43 + test "a source with nothing stored is a miss, not an empty answer", %{id: id} do
44 + # The distinction matters: a caller can fall back to the worker rather
45 + # than reporting that no trains are running.
46 + assert RTIndex.trip_updates(id, ["t1"], "s1") == :miss
47 + end
48 +
49 + test "a stored feed with no entities is an answer", %{id: id} do
50 + RTIndex.put_trip_updates(id, [])
51 +
52 + assert {:ok, []} = RTIndex.trip_updates(id, ["t1"], "s1")
53 + end
54 +
55 + test "only the trips asked for come back", %{id: id} do
56 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"]), entity("t2", ["s1"]), entity("t3", ["s1"])])
57 +
58 + assert {:ok, found} = RTIndex.trip_updates(id, ["t1", "t3"], "s1")
59 + assert Enum.map(found, & &1.trip_id) |> Enum.sort() == ["t1", "t3"]
60 + end
61 +
62 + test "the stop asked for is picked out of the trip's calls", %{id: id} do
63 + RTIndex.put_trip_updates(id, [entity("t1", ["s1", "s2", "s3"])])
64 +
65 + assert {:ok, [tu]} = RTIndex.trip_updates(id, ["t1"], "s2")
66 + assert tu.stop.arrival.time == 123
67 + end
68 +
69 + test "a trip that does not call at the stop still answers, with no stop", %{id: id} do
70 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"])])
71 +
72 + assert {:ok, [tu]} = RTIndex.trip_updates(id, ["t1"], "elsewhere")
73 + assert tu.stop == nil
74 + end
75 +
76 + test "the trip's own relationship rides along", %{id: id} do
77 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"], schedule_relationship: :CANCELED)])
78 +
79 + assert {:ok, [tu]} = RTIndex.trip_updates(id, ["t1"], "s1")
80 + assert tu.schedule_relationship == :CANCELED
81 + end
82 +
83 + test "a later feed replaces the earlier one rather than adding to it", %{id: id} do
84 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"]), entity("gone", ["s1"])])
85 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"])])
86 +
87 + assert {:ok, [_]} = RTIndex.trip_updates(id, ["t1", "gone"], "s1")
88 + end
89 +
90 + test "one source's feed is not another's", %{id: id} do
91 + other = System.unique_integer([:positive])
92 + RTIndex.put_trip_updates(id, [entity("t1", ["s1"])])
93 +
94 + assert RTIndex.trip_updates(other, ["t1"], "s1") == :miss
95 + end
96 +
97 + test "a stop update with no stop id is not indexed by one", %{id: id} do
98 + RTIndex.put_trip_updates(id, [entity("t1", [nil, ""])])
99 +
100 + assert {:ok, [tu]} = RTIndex.trip_updates(id, ["t1"], "s1")
101 + assert tu.stops == %{}
102 + end
103 + end
104 +
105 + describe "suffix matching" do
106 + test "a feed naming the tail of a scheduled trip id still matches", %{id: id} do
107 + # NYCT: realtime "098600_5..S03R", schedule "ASP26GEN-1038-Sunday-00_098600_5..S03R"
108 + RTIndex.put_trip_updates(id, [entity("098600_5..S03R", ["s1"])])
109 +
110 + assert {:ok, [tu]} =
111 + RTIndex.trip_updates_by_suffix(
112 + id,
113 + ["ASP26GEN-1038-Sunday-00_098600_5..S03R"],
114 + "s1"
115 + )
116 +
117 + assert tu.trip_id == "098600_5..S03R"
118 + end
119 +
120 + test "an unrelated trip does not match on a suffix", %{id: id} do
121 + RTIndex.put_trip_updates(id, [entity("something-else", ["s1"])])
122 +
123 + assert {:ok, []} = RTIndex.trip_updates_by_suffix(id, ["ASP26_098600"], "s1")
124 + end
125 + end
126 +
127 + describe "vehicles" do
128 + test "by trip, and all at once", %{id: id} do
129 + vehicles = [
130 + %{vehicle_id: "v1", trip_id: "t1", occupancy_status: :FULL},
131 + %{vehicle_id: "v2", trip_id: "t2", occupancy_status: :EMPTY}
132 + ]
133 +
134 + RTIndex.put_vehicles(id, vehicles)
135 +
136 + assert {:ok, [%{vehicle_id: "v1"}]} = RTIndex.vehicles(id, ["t1"])
137 + assert {:ok, both} = RTIndex.vehicles(id)
138 + assert length(both) == 2
139 + end
140 +
141 + test "a vehicle on no trip is still in the whole-feed answer", %{id: id} do
142 + RTIndex.put_vehicles(id, [%{vehicle_id: "v1", trip_id: nil}])
143 +
144 + assert {:ok, [_]} = RTIndex.vehicles(id)
145 + assert {:ok, []} = RTIndex.vehicles(id, ["t1"])
146 + end
147 +
148 + test "nothing stored is a miss", %{id: id} do
149 + assert RTIndex.vehicles(id) == :miss
150 + assert RTIndex.vehicles(id, ["t1"]) == :miss
151 + end
152 + end
153 +end
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+70 −0
@@ -1318,6 +1318,75 @@ defmodule RoomSanctum.Storage do
1318 1318 val
1319 1319 end
1320 1320
1321 + @doc false
1322 + # Keep only the trips whose service actually runs on `date`, and be careful
1323 + # about what "actually" can be known.
1324 + #
1325 + # A stop's timetable holds every service pattern the feed publishes --
1326 + # weekday, Saturday, Sunday, and on MBTA a dozen dated variants of each -- so
1327 + # without this the board is mostly departures that will not happen, and the
1328 + # limit is spent on them. One stop there mixes twelve patterns.
1329 + #
1330 + # Two things stop this from being a filter that empties boards, and both are
1331 + # load-bearing rather than defensive dressing. Measured on the dev database,
1332 + # two of eight sources have no usable service data at all: one has 33,163
1333 + # trips and not a single calendar row, another has calendars that expired
1334 + # days ago.
1335 + #
1336 + # * A service nothing says anything about is kept. Neither file mentioning
1337 + # it means we do not know when it runs, and hiding it asserts something
1338 + # we have not been told.
1339 + #
1340 + # * A source with nothing running today is not filtered at all. That is a
1341 + # feed whose service data has gone stale, and a board of possibly-wrong
1342 + # times beats a blank one.
1343 + defp running_on(queryable, source_id, date) do
1344 + weekday = weekday_column(Date.day_of_week(date))
1345 +
1346 + scheduled =
1347 + "gtfs_calendars"
1348 + |> where([c], c.source_id == ^source_id and c.start_date <= ^date and c.end_date >= ^date)
1349 + |> where([c], field(c, ^weekday) == 1)
1350 +
1351 + exception = fn type ->
1352 + "gtfs_calendar_dates"
1353 + |> where([cd], cd.source_id == ^source_id and cd.date == ^date and cd.exception_type == ^type)
1354 + end
1355 +
1356 + from([st, trip: t] in queryable,
1357 + where:
1358 + (exists(from(c in scheduled, where: c.service_id == parent_as(:trip).service_id, select: 1)) or
1359 + exists(
1360 + from(cd in exception.(1), where: cd.service_id == parent_as(:trip).service_id, select: 1)
1361 + ) or
1362 + not exists(
1363 + from(c in "gtfs_calendars",
1364 + where: c.source_id == ^source_id and c.service_id == parent_as(:trip).service_id,
1365 + select: 1
1366 + )
1367 + ) and
1368 + not exists(
1369 + from(cd in "gtfs_calendar_dates",
1370 + where: cd.source_id == ^source_id and cd.service_id == parent_as(:trip).service_id,
1371 + select: 1
1372 + )
1373 + ) or
1374 + not exists(from(c in scheduled, select: 1)) and
1375 + not exists(from(cd in exception.(1), select: 1))) and
1376 + not exists(
1377 + from(cd in exception.(2), where: cd.service_id == parent_as(:trip).service_id, select: 1)
1378 + )
1379 + )
1380 + end
1381 +
1382 + defp weekday_column(1), do: :monday
1383 + defp weekday_column(2), do: :tuesday
1384 + defp weekday_column(3), do: :wednesday
1385 + defp weekday_column(4), do: :thursday
1386 + defp weekday_column(5), do: :friday
1387 + defp weekday_column(6), do: :saturday
1388 + defp weekday_column(7), do: :sunday
1389 +
1321 1390 defp conditional_where(queryable, source_id, stop_id, timestamp_time_hour, timestamp_time) do
1322 1391 # case timestamp_time < timestamp_time_hour do
1323 1392 # true ->
@@ -1430,6 +1499,7 @@ defmodule RoomSanctum.Storage do
1430 1499 as: :trip,
1431 1500 on: t.trip_id == st.trip_id and t.source_id == st.source_id
1432 1501 )
1502 + |> running_on(source_id, DateTime.to_date(timestamp))
1433 1503 |> join(:left, [st, trip: t], r in Route,
1434 1504 as: :route,
1435 1505 on: t.route_id == r.route_id and r.source_id == st.source_id
added apps/room_sanctum/test/room_sanctum/arrival_service_day_test.exs
+173 −0
@@ -0,0 +1,173 @@
1 +defmodule RoomSanctum.ArrivalServiceDayTest do
2 + @moduledoc """
3 + The arrival query keeps only trips whose service runs today -- and knows when
4 + it is not entitled to an opinion.
5 +
6 + A stop's timetable holds every service pattern the feed publishes. On the
7 + MBTA bus feed in the dev database one stop mixed twelve, and 80% of an hour's
8 + arrivals were departures that will not happen. Filtering them is most of what
9 + makes a board correct.
10 +
11 + The two escapes matter as much as the filter. Two of eight sources there have
12 + no usable service data at all -- one with 33,163 trips and not a single
13 + calendar row -- and a filter without them would show those sources nothing.
14 + """
15 + use RoomSanctum.DataCase
16 +
17 + alias RoomSanctum.{Accounts, Configuration, Repo, Storage}
18 + alias RoomSanctum.Storage.GTFS.{Calendar, CalendarDate, StopTime, Trip}
19 +
20 + # 2026-09-01 is a Tuesday.
21 + @today ~D[2026-09-01]
22 + @at DateTime.new!(~D[2026-09-01], ~T[08:00:00], "Etc/UTC")
23 +
24 + setup do
25 + {:ok, user} =
26 + Accounts.register_user(%{
27 + email: "sd#{System.unique_integer([:positive])}@example.com",
28 + password: "hello world!hello world!"
29 + })
30 +
31 + {:ok, source} =
32 + Configuration.create_source(%{
33 + name: "T", notes: "", type: :gtfs, enabled: true, user_id: user.id,
34 + config: %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "Etc/UTC"}
35 + })
36 +
37 + %{source: source}
38 + end
39 +
40 + defp calendar(source, service_id, days, range \\ {~D[2026-08-31], ~D[2026-09-05]}) do
41 + {start_date, end_date} = range
42 +
43 + attrs =
44 + %{source_id: source.id, service_id: service_id, start_date: start_date, end_date: end_date}
45 + |> Map.merge(Map.new(~w(monday tuesday wednesday thursday friday saturday sunday)a, &{&1, 0}))
46 + |> Map.merge(Map.new(days, &{&1, 1}))
47 +
48 + Repo.insert!(Calendar.changeset(%Calendar{}, attrs))
49 + end
50 +
51 + defp exception(source, service_id, type) do
52 + Repo.insert!(
53 + CalendarDate.changeset(%CalendarDate{}, %{
54 + source_id: source.id, service_id: service_id, date: @today, exception_type: type
55 + })
56 + )
57 + end
58 +
59 + # One trip on a service, calling at "s1" at 08:30.
60 + defp trip_calling(source, service_id) do
61 + trip_id = "trip-#{service_id}-#{System.unique_integer([:positive])}"
62 +
63 + Repo.insert!(%Trip{
64 + source_id: source.id, trip_id: trip_id, route_id: "r1",
65 + service_id: service_id, direction_id: 0, trip_headsign: "Downtown"
66 + })
67 +
68 + Repo.insert!(%StopTime{
69 + source_id: source.id, trip_id: trip_id, stop_id: "s1", stop_sequence: 1,
70 + arrival_time: %Postgrex.Interval{secs: 8 * 3600 + 1800},
71 + departure_time: %Postgrex.Interval{secs: 8 * 3600 + 1800}
72 + })
73 +
74 + trip_id
75 + end
76 +
77 + defp arrivals(source) do
78 + Storage.get_upcoming_arrivals_for_stop(source.id, "s1", 50, @at, "Etc/UTC")
79 + |> Enum.map(& &1.trip_id)
80 + end
81 +
82 + test "a service running today is kept", %{source: source} do
83 + calendar(source, "weekday", [:tuesday])
84 + trip = trip_calling(source, "weekday")
85 +
86 + assert arrivals(source) == [trip]
87 + end
88 +
89 + test "a service for another day is dropped", %{source: source} do
90 + calendar(source, "weekday", [:tuesday])
91 + calendar(source, "saturday", [:saturday])
92 + keep = trip_calling(source, "weekday")
93 + _drop = trip_calling(source, "saturday")
94 +
95 + assert arrivals(source) == [keep]
96 + end
97 +
98 + test "a service whose date range has passed is dropped", %{source: source} do
99 + calendar(source, "weekday", [:tuesday])
100 + calendar(source, "expired", [:tuesday], {~D[2026-07-01], ~D[2026-08-28]})
101 + keep = trip_calling(source, "weekday")
102 + _drop = trip_calling(source, "expired")
103 +
104 + assert arrivals(source) == [keep]
105 + end
106 +
107 + test "a service added for today by exception is kept", %{source: source} do
108 + calendar(source, "weekday", [:tuesday])
109 + # Every weekday flag zero -- the MBTA shape, which runs only by exception.
110 + calendar(source, "by-exception", [], {@today, @today})
111 + exception(source, "by-exception", 1)
112 +
113 + keep = trip_calling(source, "weekday")
114 + also = trip_calling(source, "by-exception")
115 +
116 + assert Enum.sort(arrivals(source)) == Enum.sort([keep, also])
117 + end
118 +
119 + test "a service removed for today by exception is dropped", %{source: source} do
120 + calendar(source, "weekday", [:tuesday])
121 + calendar(source, "holiday", [:tuesday])
122 + exception(source, "holiday", 2)
123 +
124 + keep = trip_calling(source, "weekday")
125 + _drop = trip_calling(source, "holiday")
126 +
127 + assert arrivals(source) == [keep]
128 + end
129 +
130 + describe "the escapes" do
131 + test "a service nothing says anything about is kept", %{source: source} do
132 + calendar(source, "weekday", [:tuesday])
133 + keep = trip_calling(source, "weekday")
134 + # No calendar row, no exception: we have not been told when this runs, so
135 + # hiding it would assert something we do not know.
136 + unknown = trip_calling(source, "no-info")
137 +
138 + assert Enum.sort(arrivals(source)) == Enum.sort([keep, unknown])
139 + end
140 +
141 + test "a source with nothing running today is not filtered at all", %{source: source} do
142 + # Stale service data -- the state two of eight dev sources are actually in.
143 + calendar(source, "expired-a", [:tuesday], {~D[2026-07-01], ~D[2026-08-28]})
144 + calendar(source, "expired-b", [:saturday], {~D[2026-07-01], ~D[2026-08-28]})
145 +
146 + a = trip_calling(source, "expired-a")
147 + b = trip_calling(source, "expired-b")
148 +
149 + # A board of possibly-wrong times beats a blank one.
150 + assert Enum.sort(arrivals(source)) == Enum.sort([a, b])
151 + end
152 +
153 + test "one service running today is enough to switch filtering on", %{source: source} do
154 + calendar(source, "weekday", [:tuesday])
155 + calendar(source, "saturday", [:saturday])
156 +
157 + keep = trip_calling(source, "weekday")
158 + _drop = trip_calling(source, "saturday")
159 +
160 + assert arrivals(source) == [keep]
161 + end
162 +
163 + test "an exception alone is enough to switch filtering on", %{source: source} do
164 + calendar(source, "saturday", [:saturday])
165 + exception(source, "added", 1)
166 +
167 + added = trip_calling(source, "added")
168 + _drop = trip_calling(source, "saturday")
169 +
170 + assert arrivals(source) == [added]
171 + end
172 + end
173 +end

Parents: 27a12aa