Stop the arrival lookup starving the vision that asked for it

27a12aa · Gabriel Morell · 2026-09-01 19:52

23 files +383 -27
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