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.
Stop the arrival lookup starving the vision that asked for it
27a12aa · Gabriel Morell · 2026-09-01 19:52
Message
{commit_body(@commit)}
Files changed
modified
apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+43
−17
@@ -95,10 +95,10 @@ defmodule RoomGtfs.Worker do
| 95 | 95 | |> GenServer.call(:query_vehicle_positions, 30_000) |
| 96 | 96 | end |
| 97 | 97 | |
| 98 | − def get_current_vehicle_positions(name, trips) do | |
| 98 | + def get_current_vehicle_positions(name, trips, timeout \\ 30_000) do | |
| 99 | 99 | "gtfs-rt#{name}" |
| 100 | 100 | |> via_tuple |
| 101 | − |> GenServer.call({:query_vehicle_positions, trips}, 30_000) | |
| 101 | + |> GenServer.call({:query_vehicle_positions, trips}, timeout) | |
| 102 | 102 | end |
| 103 | 103 | |
| 104 | 104 | @doc """ |
@@ -138,8 +138,14 @@ defmodule RoomGtfs.Worker do
| 138 | 138 | end |
| 139 | 139 | |
| 140 | 140 | def query_stop(id, query) do |
| 141 | − inst = Configuration.get_source!(id) | |
| 142 | − res = Storage.get_upcoming_arrivals_for_stop(id, query.stop) |> Storage.fix_arrival_times | |
| 141 | + # Bare: this wants the config blob, and nothing on this path reads the | |
| 142 | + # mailboxes and webhooks the preloading version fetches alongside it. | |
| 143 | + inst = Configuration.get_source!(:bare, id) | |
| 144 | + # The source is already in hand, so its timezone goes with the call rather | |
| 145 | + # than being looked up again inside it. | |
| 146 | + res = | |
| 147 | + Storage.get_upcoming_arrivals_for_stop(id, query.stop, 16, :now, inst.config.tz) | |
| 148 | + |> Storage.fix_arrival_times | |
| 143 | 149 | |
| 144 | 150 | # Not `url_rt_tu` alone: a source whose trip updates arrive in a combined |
| 145 | 151 | # feed has that field blank and its URL in url_rt_shared, and gating on the |
@@ -196,9 +202,23 @@ defmodule RoomGtfs.Worker do
| 196 | 202 | end |
| 197 | 203 | end |
| 198 | 204 | |
| 205 | + # How long to wait for vehicle positions before giving up on occupancy. | |
| 206 | + # | |
| 207 | + # Deliberately short. This is the second call query_stop makes to the same | |
| 208 | + # worker -- get_current_realtime is the first -- and that worker also fetches | |
| 209 | + # the feeds over HTTP, so the calls queue behind each other and behind the | |
| 210 | + # fetches. At the default 30s a busy worker made every arrival query take | |
| 211 | + # longer than the 30s tick of the vision that asked for it, so the vision | |
| 212 | + # killed its own task and got nothing at all, every cycle. | |
| 213 | + # | |
| 214 | + # Occupancy is a nice-to-have hanging off an answer that has to arrive. | |
| 215 | + # Waiting seconds for it, and losing the answer, is the wrong trade. | |
| 216 | + @occupancy_timeout_ms 2_000 | |
| 217 | + | |
| 199 | 218 | # The vehicle positions for the trips calling at a stop, or [] for a source |
| 200 | − # that publishes none. Occupancy is the only thing wanted from them here, and | |
| 201 | − # a source with no vehicle feed still gets whatever the trip updates carry. | |
| 219 | + # that publishes none, or [] if they do not arrive promptly. A source with no | |
| 220 | + # vehicle feed -- or a worker too busy to answer right now -- still gets | |
| 221 | + # whatever the trip updates carry. | |
| 202 | 222 | defp occupancy_vehicles(id, config, trips) do |
| 203 | 223 | case RoomGtfs.Worker.RT.rt_url_for(config, :vp) do |
| 204 | 224 | nil -> |
@@ -206,7 +226,7 @@ defmodule RoomGtfs.Worker do
| 206 | 226 | |
| 207 | 227 | _val -> |
| 208 | 228 | try do |
| 209 | − get_current_vehicle_positions(id, trips) | |
| 229 | + get_current_vehicle_positions(id, trips, @occupancy_timeout_ms) | |
| 210 | 230 | catch |
| 211 | 231 | :exit, _ -> [] |
| 212 | 232 | end |
@@ -1372,12 +1392,12 @@ defmodule RoomGtfs.Worker.Static do
| 1372 | 1392 | alias RoomSanctum.Storage.GTFS |
| 1373 | 1393 | |
| 1374 | 1394 | # The files that get loaded, and the only ones file_to_atom/1 and |
| 1375 | − # file_to_order/1 accept. Everything else in a feed -- calendar_dates.txt, | |
| 1376 | − # feed_info.txt, linked_datasets.txt -- is either handled separately or | |
| 1377 | − # ignored. | |
| 1395 | + # file_to_order/1 accept. Everything else in a feed -- feed_info.txt, | |
| 1396 | + # linked_datasets.txt -- is either handled separately or ignored. | |
| 1378 | 1397 | @import_files ~w( |
| 1379 | 1398 | agency.txt |
| 1380 | 1399 | calendar.txt |
| 1400 | + calendar_dates.txt | |
| 1381 | 1401 | directions.txt |
| 1382 | 1402 | routes.txt |
| 1383 | 1403 | shapes.txt |
@@ -1573,6 +1593,7 @@ end
| 1573 | 1593 | case type do |
| 1574 | 1594 | :agencies -> RoomSanctum.Storage.truncate_agency(id) |
| 1575 | 1595 | :calendars -> RoomSanctum.Storage.truncate_calendar(id) |
| 1596 | + :calendar_dates -> RoomSanctum.Storage.truncate_calendar_date(id) | |
| 1576 | 1597 | :directions -> RoomSanctum.Storage.truncate_direction(id) |
| 1577 | 1598 | :routes -> RoomSanctum.Storage.truncate_route(id) |
| 1578 | 1599 | :stops -> RoomSanctum.Storage.truncate_stop(id) |
@@ -1587,6 +1608,7 @@ end
| 1587 | 1608 | case type do |
| 1588 | 1609 | :agencies -> {:gtfs_agencies, [GTFS.Agency |> get_cols(cols_j_plus)], GTFS.Agency |> get_cols_pgtypes(cols_j_plus)} |
| 1589 | 1610 | :calendars -> {:gtfs_calendars, [GTFS.Calendar |> get_cols(cols_j_plus)], GTFS.Calendar |> get_cols_pgtypes(cols_j_plus)} |
| 1611 | + :calendar_dates -> {:gtfs_calendar_dates, [GTFS.CalendarDate |> get_cols(cols_j_plus)], GTFS.CalendarDate |> get_cols_pgtypes(cols_j_plus)} | |
| 1590 | 1612 | :directions -> {:gtfs_directions, [GTFS.Direction |> get_cols(cols_j_plus)], GTFS.Direction |> get_cols_pgtypes(cols_j_plus)} |
| 1591 | 1613 | :routes -> {:gtfs_routes, [GTFS.Route |> get_cols(cols_j_plus)], GTFS.Route |> get_cols_pgtypes(cols_j_plus)} |
| 1592 | 1614 | :stops -> {:gtfs_stops, [GTFS.Stop |> get_cols(cols_j_plus)], GTFS.Stop |> get_cols_pgtypes(cols_j_plus)} |
@@ -1880,6 +1902,9 @@ end
| 1880 | 1902 | "calendar.txt" -> |
| 1881 | 1903 | :calendars |
| 1882 | 1904 | |
| 1905 | + "calendar_dates.txt" -> | |
| 1906 | + :calendar_dates | |
| 1907 | + | |
| 1883 | 1908 | "directions.txt" -> |
| 1884 | 1909 | :directions |
| 1885 | 1910 |
@@ -1911,12 +1936,13 @@ end
| 1911 | 1936 | case filename do |
| 1912 | 1937 | "agency.txt" -> 3 |
| 1913 | 1938 | "calendar.txt" -> 4 |
| 1914 | − "directions.txt" -> 5 | |
| 1915 | − "routes.txt" -> 6 | |
| 1916 | − "shapes.txt" -> 7 | |
| 1917 | − "stops.txt" -> 8 | |
| 1918 | − "stop_times.txt" -> 9 | |
| 1919 | − "trips.txt" -> 10 | |
| 1939 | + "calendar_dates.txt" -> 5 | |
| 1940 | + "directions.txt" -> 6 | |
| 1941 | + "routes.txt" -> 7 | |
| 1942 | + "shapes.txt" -> 8 | |
| 1943 | + "stops.txt" -> 9 | |
| 1944 | + "stop_times.txt" -> 10 | |
| 1945 | + "trips.txt" -> 11 | |
| 1920 | 1946 | end |
| 1921 | 1947 | end |
| 1922 | 1948 |
@@ -2047,7 +2073,7 @@ end
| 2047 | 2073 | |> Enum.map(fn e -> |
| 2048 | 2074 | type = file_to_atom(e.file_name) |
| 2049 | 2075 | |
| 2050 | − bcast(id, type, file_to_order(e.file_name), 11) | |
| 2076 | + bcast(id, type, file_to_order(e.file_name), 12) | |
| 2051 | 2077 | |
| 2052 | 2078 | Unzip.file_stream!(unzip, e.file_name) |
| 2053 | 2079 | |> write_file(type, id, nil) |
modified
apps/room_sanctum/lib/room_sanctum/configuration.ex
+10
−0
@@ -45,6 +45,16 @@ defmodule RoomSanctum.Configuration do
| 45 | 45 | """ |
| 46 | 46 | def get_source!(id), do: Repo.get!(Source, id) |> Repo.preload([:mailboxes, :webhooks]) |
| 47 | 47 | |
| 48 | + @doc """ | |
| 49 | + The source row on its own, without the associations `get_source!/1` brings. | |
| 50 | + | |
| 51 | + Those preloads are right for the pages that list a source's mailboxes and | |
| 52 | + webhooks, and are two wasted queries everywhere else -- which includes the | |
| 53 | + arrival lookup, where the only thing wanted is the config blob and the cost | |
| 54 | + is paid on every call. | |
| 55 | + """ | |
| 56 | + def get_source!(:bare, id), do: Repo.get!(Source, id) | |
| 57 | + | |
| 48 | 58 | @doc """ |
| 49 | 59 | Subscribe to config changes for one record, so a worker can be told rather |
| 50 | 60 | than asking. |
modified
apps/room_sanctum/lib/room_sanctum/storage.ex
+88
−3
@@ -251,6 +251,72 @@ defmodule RoomSanctum.Storage do
| 251 | 251 | Calendar.changeset(calendar, attrs) |
| 252 | 252 | end |
| 253 | 253 | |
| 254 | + alias RoomSanctum.Storage.GTFS.CalendarDate | |
| 255 | + | |
| 256 | + @doc """ | |
| 257 | + The service exceptions for a source -- calendar_dates.txt. | |
| 258 | + | |
| 259 | + For several feeds this is not a footnote to calendar.txt but the greater part | |
| 260 | + of the service definition: MBTA's bus services carry seven zero weekday flags | |
| 261 | + and name every day they run here instead. | |
| 262 | + """ | |
| 263 | + def count_calendar_dates(source_id) do | |
| 264 | + Repo.one(from p in CalendarDate, where: p.source_id == ^source_id, select: count(p.id)) | |
| 265 | + end | |
| 266 | + | |
| 267 | + def truncate_calendar_date(source_id) do | |
| 268 | + from(p in CalendarDate, where: p.source_id == ^source_id) | |
| 269 | + |> Repo.delete_all(timeout: @gtfs_truncate_timeout) | |
| 270 | + end | |
| 271 | + | |
| 272 | + def change_calendar_date(%CalendarDate{} = calendar_date, attrs \\ %{}) do | |
| 273 | + CalendarDate.changeset(calendar_date, attrs) | |
| 274 | + end | |
| 275 | + | |
| 276 | + @doc """ | |
| 277 | + The service ids running on a date, as a MapSet. | |
| 278 | + | |
| 279 | + Both halves of the answer: the services whose calendar.txt row covers the | |
| 280 | + date and names that weekday, plus the ones added for it by exception, minus | |
| 281 | + the ones removed for it. A service can be defined entirely by exceptions -- | |
| 282 | + every weekday flag zero -- so neither half alone is the answer. | |
| 283 | + """ | |
| 284 | + def services_on(source_id, %Date{} = date) do | |
| 285 | + weekday = Date.day_of_week(date) | |
| 286 | + | |
| 287 | + scheduled = | |
| 288 | + from(c in Calendar, | |
| 289 | + where: | |
| 290 | + c.source_id == ^source_id and c.start_date <= ^date and c.end_date >= ^date and | |
| 291 | + field(c, ^weekday_field(weekday)) == 1, | |
| 292 | + select: c.service_id | |
| 293 | + ) | |
| 294 | + |> Repo.all() | |
| 295 | + | |
| 296 | + exceptions = | |
| 297 | + from(cd in CalendarDate, | |
| 298 | + where: cd.source_id == ^source_id and cd.date == ^date, | |
| 299 | + select: {cd.service_id, cd.exception_type} | |
| 300 | + ) | |
| 301 | + |> Repo.all() | |
| 302 | + | |
| 303 | + added = for {id, t} <- exceptions, t == 1, do: id | |
| 304 | + removed = for {id, t} <- exceptions, t == 2, do: id | |
| 305 | + | |
| 306 | + scheduled | |
| 307 | + |> MapSet.new() | |
| 308 | + |> MapSet.union(MapSet.new(added)) | |
| 309 | + |> MapSet.difference(MapSet.new(removed)) | |
| 310 | + end | |
| 311 | + | |
| 312 | + defp weekday_field(1), do: :monday | |
| 313 | + defp weekday_field(2), do: :tuesday | |
| 314 | + defp weekday_field(3), do: :wednesday | |
| 315 | + defp weekday_field(4), do: :thursday | |
| 316 | + defp weekday_field(5), do: :friday | |
| 317 | + defp weekday_field(6), do: :saturday | |
| 318 | + defp weekday_field(7), do: :sunday | |
| 319 | + | |
| 254 | 320 | alias RoomSanctum.Storage.GTFS.Direction |
| 255 | 321 | |
| 256 | 322 | @doc """ |
@@ -1308,10 +1374,29 @@ defmodule RoomSanctum.Storage do
| 1308 | 1374 | |
| 1309 | 1375 | end |
| 1310 | 1376 | |
| 1311 | − def get_upcoming_arrivals_for_stop(source_id, stop_id, limit \\ 16, timestamp \\ :now) do | |
| 1312 | − source = Cfg.get_source!(source_id) | |
| 1313 | − tz = source.config.tz | |
| 1377 | + @doc """ | |
| 1378 | + The next arrivals at a stop, in the stop's own timezone. | |
| 1379 | + | |
| 1380 | + `tz` is the source's, and the caller usually has it already -- the GTFS | |
| 1381 | + worker holds the source in its state and has just read it again to decide | |
| 1382 | + whether the feed has realtime. Looking it up here as well cost three more | |
| 1383 | + queries per call, because `get_source!/1` preloads mailboxes and webhooks | |
| 1384 | + onto a row we want one field from. Passing it in skips all of that; leaving | |
| 1385 | + it out still works, and still pays for it. | |
| 1386 | + """ | |
| 1387 | + def get_upcoming_arrivals_for_stop(source_id, stop_id, limit \\ 16, timestamp \\ :now, tz \\ nil) | |
| 1388 | + | |
| 1389 | + def get_upcoming_arrivals_for_stop(source_id, stop_id, limit, timestamp, nil) do | |
| 1390 | + get_upcoming_arrivals_for_stop( | |
| 1391 | + source_id, | |
| 1392 | + stop_id, | |
| 1393 | + limit, | |
| 1394 | + timestamp, | |
| 1395 | + Cfg.get_source!(source_id).config.tz | |
| 1396 | + ) | |
| 1397 | + end | |
| 1314 | 1398 | |
| 1399 | + def get_upcoming_arrivals_for_stop(source_id, stop_id, limit, timestamp, tz) do | |
| 1315 | 1400 | timestamp = |
| 1316 | 1401 | case timestamp do |
| 1317 | 1402 | :now -> DateTime.now!(tz) |
added
apps/room_sanctum/lib/room_sanctum/storage/gtfs/calendar_date.ex
+57
−0
@@ -0,0 +1,57 @@
| 1 | +defmodule RoomSanctum.Storage.GTFS.CalendarDate do | |
| 2 | + @moduledoc """ | |
| 3 | + One line of calendar_dates.txt: a service, a date, and whether that date is | |
| 4 | + added to the service or taken away from it. | |
| 5 | + | |
| 6 | + For some feeds this is not the exception it sounds like. MBTA's bus feed | |
| 7 | + carries services whose seven weekday flags in calendar.txt are all zero -- | |
| 8 | + every day they run is named here instead -- so a service-day filter that | |
| 9 | + reads only calendar.txt drops trips that are genuinely running. | |
| 10 | + """ | |
| 11 | + use Ecto.Schema | |
| 12 | + import Ecto.Changeset | |
| 13 | + | |
| 14 | + @added 1 | |
| 15 | + @removed 2 | |
| 16 | + | |
| 17 | + def added, do: @added | |
| 18 | + def removed, do: @removed | |
| 19 | + | |
| 20 | + schema "gtfs_calendar_dates" do | |
| 21 | + belongs_to :source, RoomSanctum.Configuration.Source | |
| 22 | + field :service_id, :string | |
| 23 | + field :date, :date | |
| 24 | + field :exception_type, :integer | |
| 25 | + | |
| 26 | + timestamps() | |
| 27 | + end | |
| 28 | + | |
| 29 | + @doc false | |
| 30 | + def changeset(calendar_date, attrs) do | |
| 31 | + calendar_date | |
| 32 | + |> cast(normalize_dates(attrs), [:service_id, :date, :exception_type, :source_id]) | |
| 33 | + |> foreign_key_constraint(:source_id) | |
| 34 | + |> validate_required([:service_id, :date, :exception_type]) | |
| 35 | + |> validate_inclusion(:exception_type, [@added, @removed]) | |
| 36 | + end | |
| 37 | + | |
| 38 | + # GTFS writes dates as YYYYMMDD, which Ecto's :date cast rejects -- it wants | |
| 39 | + # the extended form, and a rejected cast here means a row silently imported | |
| 40 | + # with no date at all. Both forms are accepted; anything else is passed | |
| 41 | + # through to fail casting rather than be guessed at. | |
| 42 | + defp normalize_dates(attrs) do | |
| 43 | + Enum.into(attrs, %{}, fn | |
| 44 | + {k, v} when k in ["date", :date] -> {k, iso_date(v)} | |
| 45 | + pair -> pair | |
| 46 | + end) | |
| 47 | + end | |
| 48 | + | |
| 49 | + defp iso_date(<<y::binary-4, m::binary-2, d::binary-2>> = raw) do | |
| 50 | + case Integer.parse(raw) do | |
| 51 | + {_, ""} -> "#{y}-#{m}-#{d}" | |
| 52 | + _otherwise -> raw | |
| 53 | + end | |
| 54 | + end | |
| 55 | + | |
| 56 | + defp iso_date(other), do: other | |
| 57 | +end |
added
apps/room_sanctum/priv/repo/migrations/20260901200000_create_gtfs_calendar_dates.exs
+27
−0
@@ -0,0 +1,27 @@
| 1 | +defmodule RoomSanctum.Repo.Migrations.CreateGtfsCalendarDates do | |
| 2 | + use Ecto.Migration | |
| 3 | + | |
| 4 | + # calendar_dates.txt was the one part of a feed's service definition this app | |
| 5 | + # did not read, and for several feeds it is where most of the definition | |
| 6 | + # lives. MBTA's bus feed has services whose seven day flags are all zero and | |
| 7 | + # whose date range is a single day -- they run entirely on exceptions here, | |
| 8 | + # and without this table there is no way to know which trips run today. | |
| 9 | + def change do | |
| 10 | + create table(:gtfs_calendar_dates) do | |
| 11 | + add :source_id, references(:cfg_sources, on_delete: :delete_all), null: false | |
| 12 | + add :service_id, :string, null: false | |
| 13 | + add :date, :date, null: false | |
| 14 | + # 1 adds service for that date, 2 removes it. | |
| 15 | + add :exception_type, :integer, null: false | |
| 16 | + | |
| 17 | + timestamps() | |
| 18 | + end | |
| 19 | + | |
| 20 | + # A service names a given date once. The import upserts on this. | |
| 21 | + create unique_index(:gtfs_calendar_dates, [:source_id, :service_id, :date]) | |
| 22 | + | |
| 23 | + # "which services run on this date", which is how the arrival query will | |
| 24 | + # read it: by day, not by service. | |
| 25 | + create index(:gtfs_calendar_dates, [:source_id, :date]) | |
| 26 | + end | |
| 27 | +end |
added
apps/room_sanctum/test/room_sanctum/calendar_dates_test.exs
+147
−0
@@ -0,0 +1,147 @@
| 1 | +defmodule RoomSanctum.CalendarDatesTest do | |
| 2 | + @moduledoc """ | |
| 3 | + calendar_dates.txt, and the service-day answer that needs it. | |
| 4 | + | |
| 5 | + For several feeds this is not a footnote to calendar.txt. MBTA's bus feed | |
| 6 | + carries services whose seven weekday flags are all zero and whose date range | |
| 7 | + is a single day -- every day they run is named in calendar_dates instead -- | |
| 8 | + so a filter reading only calendar.txt drops trips that are genuinely running. | |
| 9 | + """ | |
| 10 | + use RoomSanctum.DataCase | |
| 11 | + | |
| 12 | + alias RoomSanctum.{Accounts, Configuration, Repo, Storage} | |
| 13 | + alias RoomSanctum.Storage.GTFS.{Calendar, CalendarDate} | |
| 14 | + | |
| 15 | + setup do | |
| 16 | + {:ok, user} = | |
| 17 | + Accounts.register_user(%{ | |
| 18 | + email: "cd#{System.unique_integer([:positive])}@example.com", | |
| 19 | + password: "hello world!hello world!" | |
| 20 | + }) | |
| 21 | + | |
| 22 | + {:ok, source} = | |
| 23 | + Configuration.create_source(%{ | |
| 24 | + name: "T", notes: "", type: :gtfs, enabled: true, user_id: user.id, | |
| 25 | + config: %{"__type__" => "gtfs", "url" => "https://e.test/g.zip", "tz" => "UTC"} | |
| 26 | + }) | |
| 27 | + | |
| 28 | + %{source: source} | |
| 29 | + end | |
| 30 | + | |
| 31 | + defp calendar(source, service_id, days, start_date, end_date) do | |
| 32 | + attrs = | |
| 33 | + %{source_id: source.id, service_id: service_id, start_date: start_date, end_date: end_date} | |
| 34 | + |> Map.merge(Map.new(~w(monday tuesday wednesday thursday friday saturday sunday)a, &{&1, 0})) | |
| 35 | + |> Map.merge(Map.new(days, &{&1, 1})) | |
| 36 | + | |
| 37 | + Repo.insert!(Calendar.changeset(%Calendar{}, attrs)) | |
| 38 | + end | |
| 39 | + | |
| 40 | + defp exception(source, service_id, date, type) do | |
| 41 | + Repo.insert!( | |
| 42 | + CalendarDate.changeset(%CalendarDate{}, %{ | |
| 43 | + source_id: source.id, service_id: service_id, date: date, exception_type: type | |
| 44 | + }) | |
| 45 | + ) | |
| 46 | + end | |
| 47 | + | |
| 48 | + describe "the date format" do | |
| 49 | + test "GTFS writes YYYYMMDD, which Ecto's date cast rejects on its own" do | |
| 50 | + cs = CalendarDate.changeset(%CalendarDate{}, %{service_id: "s", date: "20260901", exception_type: 1}) | |
| 51 | + | |
| 52 | + assert cs.valid? | |
| 53 | + assert get_change(cs, :date) == ~D[2026-09-01] | |
| 54 | + end | |
| 55 | + | |
| 56 | + test "an already-dashed date is left alone" do | |
| 57 | + cs = CalendarDate.changeset(%CalendarDate{}, %{service_id: "s", date: "2026-09-01", exception_type: 1}) | |
| 58 | + | |
| 59 | + assert get_change(cs, :date) == ~D[2026-09-01] | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "eight characters that are not a date do not become one" do | |
| 63 | + cs = CalendarDate.changeset(%CalendarDate{}, %{service_id: "s", date: "not-adat", exception_type: 1}) | |
| 64 | + | |
| 65 | + refute cs.valid? | |
| 66 | + end | |
| 67 | + | |
| 68 | + test "only 1 and 2 are exception types" do | |
| 69 | + for t <- [1, 2] do | |
| 70 | + assert CalendarDate.changeset(%CalendarDate{}, %{service_id: "s", date: "20260901", exception_type: t}).valid? | |
| 71 | + end | |
| 72 | + | |
| 73 | + refute CalendarDate.changeset(%CalendarDate{}, %{service_id: "s", date: "20260901", exception_type: 3}).valid? | |
| 74 | + end | |
| 75 | + end | |
| 76 | + | |
| 77 | + describe "services_on/2" do | |
| 78 | + test "a service whose weekday flag is set, inside its date range", %{source: source} do | |
| 79 | + # 2026-09-01 is a Tuesday. | |
| 80 | + calendar(source, "weekday", [:tuesday], ~D[2026-08-31], ~D[2026-09-04]) | |
| 81 | + | |
| 82 | + assert MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "weekday") | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "the same service outside its date range does not run", %{source: source} do | |
| 86 | + calendar(source, "expired", [:tuesday], ~D[2026-07-01], ~D[2026-08-28]) | |
| 87 | + | |
| 88 | + refute MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "expired") | |
| 89 | + end | |
| 90 | + | |
| 91 | + test "the wrong weekday does not run", %{source: source} do | |
| 92 | + calendar(source, "saturday", [:saturday], ~D[2026-08-31], ~D[2026-09-04]) | |
| 93 | + | |
| 94 | + refute MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "saturday") | |
| 95 | + end | |
| 96 | + | |
| 97 | + test "a service defined only by exception runs -- the case that needed this", %{source: source} do | |
| 98 | + # Every weekday flag zero, a single-day range: MBTA's shape exactly. | |
| 99 | + calendar(source, "by-exception", [], ~D[2026-09-01], ~D[2026-09-01]) | |
| 100 | + exception(source, "by-exception", ~D[2026-09-01], 1) | |
| 101 | + | |
| 102 | + assert MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "by-exception") | |
| 103 | + end | |
| 104 | + | |
| 105 | + test "an added service needs no calendar row at all", %{source: source} do | |
| 106 | + exception(source, "orphan", ~D[2026-09-01], 1) | |
| 107 | + | |
| 108 | + assert MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "orphan") | |
| 109 | + end | |
| 110 | + | |
| 111 | + test "a removal beats the weekday flag", %{source: source} do | |
| 112 | + calendar(source, "holiday", [:tuesday], ~D[2026-08-31], ~D[2026-09-04]) | |
| 113 | + exception(source, "holiday", ~D[2026-09-01], 2) | |
| 114 | + | |
| 115 | + refute MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "holiday") | |
| 116 | + end | |
| 117 | + | |
| 118 | + test "exceptions apply to their own date only", %{source: source} do | |
| 119 | + exception(source, "one-day", ~D[2026-09-01], 1) | |
| 120 | + | |
| 121 | + assert MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "one-day") | |
| 122 | + refute MapSet.member?(Storage.services_on(source.id, ~D[2026-09-02]), "one-day") | |
| 123 | + end | |
| 124 | + | |
| 125 | + test "another source's services are not this source's", %{source: source} do | |
| 126 | + {:ok, other_user} = | |
| 127 | + Accounts.register_user(%{ | |
| 128 | + email: "cd2#{System.unique_integer([:positive])}@example.com", | |
| 129 | + password: "hello world!hello world!" | |
| 130 | + }) | |
| 131 | + | |
| 132 | + {:ok, other} = | |
| 133 | + Configuration.create_source(%{ | |
| 134 | + name: "U", notes: "", type: :gtfs, enabled: true, user_id: other_user.id, | |
| 135 | + config: %{"__type__" => "gtfs", "url" => "https://e.test/u.zip", "tz" => "UTC"} | |
| 136 | + }) | |
| 137 | + | |
| 138 | + exception(other, "theirs", ~D[2026-09-01], 1) | |
| 139 | + | |
| 140 | + refute MapSet.member?(Storage.services_on(source.id, ~D[2026-09-01]), "theirs") | |
| 141 | + end | |
| 142 | + | |
| 143 | + test "a source with nothing loaded runs nothing", %{source: source} do | |
| 144 | + assert Storage.services_on(source.id, ~D[2026-09-01]) == MapSet.new() | |
| 145 | + end | |
| 146 | + end | |
| 147 | +end |
modified
config/dev.exs
+5
−7
@@ -9,12 +9,10 @@ config :room_hermes,
| 9 | 9 | database: "room_sanctum_dev", |
| 10 | 10 | show_sensitive_data_on_connection_error: true, |
| 11 | 11 | port: 54321, |
| 12 | − # Matched to prod's POOL_SIZE. The workers run on the same timers here | |
| 13 | − # as they do there -- every source and vision re-reading its config row | |
| 14 | − # every few seconds -- so a dev machine with a few sources configured | |
| 15 | − # hits the same queue timeouts, and they read as a broken app rather | |
| 16 | − # than as a small pool. | |
| 17 | − pool_size: 50 | |
| 12 | + # Matched to prod's POOL_SIZE. Both repos open a pool of this size at | |
| 13 | + # startup, so the compose postgres is given headroom for it -- 50 each | |
| 14 | + # against the default ceiling of 100 left nothing for psql. | |
| 15 | + pool_size: 15 | |
| 18 | 16 | |
| 19 | 17 | # For development, we disable any cache and enable |
| 20 | 18 | # debugging and code reloading. |
@@ -80,7 +78,7 @@ config :room_sanctum,
| 80 | 78 | database: "room_sanctum_dev", |
| 81 | 79 | port: 54321, |
| 82 | 80 | show_sensitive_data_on_connection_error: true, |
| 83 | − pool_size: 50, | |
| 81 | + pool_size: 15, | |
| 84 | 82 | types: RoomSanctum.PostgresTypes |
| 85 | 83 | |
| 86 | 84 | # For development, we disable any cache and enable |
modified
docker-compose.yml
+6
−0
@@ -5,6 +5,12 @@ services:
| 5 | 5 | image: postgis/postgis:15-master |
| 6 | 6 | ports: |
| 7 | 7 | - "54321:5432" |
| 8 | + # Two repos, a pool each, and both are opened as soon as the app starts -- | |
| 9 | + # so the default ceiling of 100 is already most of the way spent before | |
| 10 | + # anything queries anything. Matching prod's 300 rather than picking a dev | |
| 11 | + # number, because the point of dev matching prod here is that a pool | |
| 12 | + # problem shows up in both places or neither. | |
| 13 | + command: postgres -c max_connections=300 | |
| 8 | 14 | volumes: |
| 9 | 15 | - pgdata:/var/lib/postgresql/data/ |
| 10 | 16 | environment: |
Parents: 6193c2b