Credo: GBFS and GTFS workers in smaller pieces

68a501b · Gabe Morell · 2026-11-04 20:55

10 files +685 -761
Message
{commit_body(@commit)}

Files changed

modified apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+360 −373
@@ -126,214 +126,7 @@ defmodule RoomGbfs.Worker do
126 126 case result.body
127 127 |> Jason.decode(keys: :atoms) do
128 128 {:ok, json} ->
129 − case type do
130 − :sys_info ->
131 − Repo.insert(
132 − RoomSanctum.Storage.change_sys_info(
133 − %RoomSanctum.Storage.GBFS.V1.SysInfo{},
134 − json.data
135 − |> RoomGbfs.V3.system_information(lang)
136 − |> Map.put(:source_id, id |> intify)
137 − ),
138 − on_conflict: {:replace_all_except, [:id]},
139 − conflict_target: [:source_id, :system_id]
140 − )
141 −
142 − :stat_info ->
143 − data =
144 − json.data.stations
145 − |> Enum.map(&RoomGbfs.V3.station_info(&1, lang))
146 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
147 − |> Enum.map(fn x ->
148 − point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
149 − x |> Map.put(:place, point)
150 − end)
151 − |> Enum.map(fn x ->
152 − RoomSanctum.Storage.change_station_info(
153 − %RoomSanctum.Storage.GBFS.V1.StationInfo{},
154 − x
155 − ).changes
156 − |> inj_iddt(id, dt)
157 − end)
158 −
159 − Repo.insert_all(
160 − RoomSanctum.Storage.GBFS.V1.StationInfo,
161 − data,
162 − on_conflict: {:replace_all_except, [:id]},
163 − conflict_target: [:source_id, :station_id]
164 − )
165 −
166 − :stat_status ->
167 − # v3 dropped the electric count; the types stored from
168 − # vehicle_types.json are what puts it back.
169 − electric =
170 − id
171 − |> intify()
172 − |> RoomSanctum.Storage.gbfs_vehicle_types()
173 − |> RoomGbfs.V3.electric_type_ids()
174 −
175 − data =
176 − json.data.stations
177 − |> Enum.map(&RoomGbfs.V3.station_status(&1, electric))
178 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
179 − |> Enum.map(fn x ->
180 − RoomSanctum.Storage.change_station_status(
181 − %RoomSanctum.Storage.GBFS.V1.StationStatus{},
182 − x
183 − ).changes
184 − |> inj_iddt(id, dt)
185 − end)
186 −
187 − Repo.insert_all(
188 − RoomSanctum.Storage.GBFS.V1.StationStatus,
189 − data,
190 − on_conflict: {:replace_all_except, [:id]},
191 − conflict_target: [:source_id, :station_id]
192 − )
193 −
194 − :free_bike ->
195 − # v3 calls them vehicles and puts them under `vehicles`.
196 − data =
197 − (Map.get(json.data, :bikes) || Map.get(json.data, :vehicles) || [])
198 − |> Enum.map(&RoomGbfs.V3.vehicle_status/1)
199 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
200 − |> Enum.map(fn x ->
201 − point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
202 − x |> Map.put(:point, point)
203 − end)
204 − |> Enum.map(fn x ->
205 − RoomSanctum.Storage.change_free_bike_status(
206 − %RoomSanctum.Storage.GBFS.V1.FreeBikeStatus{},
207 − x
208 − ).changes
209 − |> inj_iddt(id, dt)
210 − end)
211 −
212 − RoomSanctum.Storage.truncate_free_bike_status(id)
213 −
214 − Repo.insert_all(
215 − RoomSanctum.Storage.GBFS.V1.FreeBikeStatus,
216 − data,
217 − on_conflict: {:replace_all_except, [:id]},
218 − conflict_target: [:source_id, :bike_id]
219 − )
220 −
221 − :vehicle_types ->
222 − data =
223 − json.data.vehicle_types
224 − |> Enum.map(&RoomGbfs.V3.vehicle_types(&1, lang))
225 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
226 − |> Enum.map(fn x ->
227 − RoomSanctum.Storage.change_vehicle_types(
228 − %RoomSanctum.Storage.GBFS.V1.VehicleTypes{},
229 − x
230 − ).changes
231 − |> inj_iddt(id, dt)
232 − end)
233 −
234 − RoomSanctum.Storage.truncate_vehicle_types(id)
235 −
236 − Repo.insert_all(
237 − RoomSanctum.Storage.GBFS.V1.VehicleTypes,
238 − data,
239 − on_conflict: {:replace_all_except, [:id]},
240 − conflict_target: [:source_id, :vehicle_type_id]
241 − )
242 −
243 − :pricing_plans ->
244 − data =
245 − json.data.plans
246 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
247 − |> Enum.map(fn x ->
248 − RoomSanctum.Storage.change_system_pricing_plans(
249 − %RoomSanctum.Storage.GBFS.V1.SystemPricingPlans{},
250 − x
251 − ).changes
252 − |> inj_iddt(id, dt)
253 − end)
254 −
255 − RoomSanctum.Storage.truncate_system_pricing_plans(id)
256 −
257 − Repo.insert_all(
258 − RoomSanctum.Storage.GBFS.V1.SystemPricingPlans,
259 − data,
260 − on_conflict: {:replace_all_except, [:id]},
261 − conflict_target: [:source_id, :plan_id]
262 − )
263 −
264 − :geofencing_zones ->
265 − data =
266 − json.data.geofencing_zones.features
267 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
268 − |> Enum.map(fn x ->
269 − point = %Geo.MultiPolygon{coordinates: x.geometry.coordinates, srid: 4326}
270 − x |> Map.put(:place, point)
271 − end)
272 − |> Enum.map(fn x ->
273 − RoomSanctum.Storage.change_geo_fencing_zones(
274 − %RoomSanctum.Storage.GBFS.V1.GeoFencingZones{},
275 − x
276 − ).changes
277 − |> inj_iddt(id, dt)
278 − end)
279 −
280 − RoomSanctum.Storage.truncate_geo_fencing_zones(id)
281 −
282 − # Repo.insert_all(
283 − # RoomSanctum.Storage.GBFS.V1.GeoFencingZones,
284 − # data
285 − # )
286 −
287 − :sys_hours ->
288 − :ok
289 −
290 − :sys_cal ->
291 − :ok
292 −
293 − :sys_regions ->
294 − :ok
295 −
296 − :sys_alerts ->
297 − data =
298 − json.data.alerts
299 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
300 − |> Enum.map(fn x ->
301 − RoomSanctum.Storage.change_gbfs_alert(%RoomSanctum.Storage.GBFS.V1.Alert{}, x).changes
302 − |> inj_iddt(id, dt)
303 − end)
304 −
305 − Repo.insert_all(
306 − RoomSanctum.Storage.GBFS.V1.Alert,
307 − data,
308 − on_conflict: {:replace_all_except, [:id]},
309 − conflict_target: [:source_id, :alert_id]
310 − )
311 −
312 − :ebikes_at_stations ->
313 − data =
314 − json.data.stations
315 − |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
316 − |> Enum.map(fn x ->
317 − cs =
318 − RoomSanctum.Storage.change_ebikes_at_stations(
319 − %RoomSanctum.Storage.GBFS.V1.EbikesAtStations{},
320 − x
321 − ).changes
322 − |> inj_iddt(id, dt)
323 −
324 − eb = cs.ebikes |> Enum.map(fn eb -> eb |> Ecto.Changeset.apply_changes() end)
325 − cs |> Map.put(:ebikes, eb)
326 − end)
327 −
328 − RoomSanctum.Storage.truncate_ebikes_at_stations(id)
329 −
330 − Repo.insert_all(
331 − RoomSanctum.Storage.GBFS.V1.EbikesAtStations,
332 − data,
333 − on_conflict: {:replace_all_except, [:id]},
334 − conflict_target: [:source_id, :station_id]
335 − )
336 − end
129 + write_feed(type, json, id, dt, lang)
337 130
338 131 {:error, info} ->
339 132 Logger.info(info.reason)
@@ -344,6 +137,225 @@ defmodule RoomGbfs.Worker do
344 137 end
345 138 end
346 139
140 + defp write_feed(:sys_info, json, id, _dt, lang) do
141 + Repo.insert(
142 + RoomSanctum.Storage.change_sys_info(
143 + %RoomSanctum.Storage.GBFS.V1.SysInfo{},
144 + json.data
145 + |> RoomGbfs.V3.system_information(lang)
146 + |> Map.put(:source_id, id |> intify)
147 + ),
148 + on_conflict: {:replace_all_except, [:id]},
149 + conflict_target: [:source_id, :system_id]
150 + )
151 + end
152 +
153 + defp write_feed(:stat_info, json, id, dt, lang) do
154 + data =
155 + json.data.stations
156 + |> Enum.map(&RoomGbfs.V3.station_info(&1, lang))
157 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
158 + |> Enum.map(fn x ->
159 + point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
160 + x |> Map.put(:place, point)
161 + end)
162 + |> Enum.map(fn x ->
163 + RoomSanctum.Storage.change_station_info(
164 + %RoomSanctum.Storage.GBFS.V1.StationInfo{},
165 + x
166 + ).changes
167 + |> inj_iddt(id, dt)
168 + end)
169 +
170 + Repo.insert_all(
171 + RoomSanctum.Storage.GBFS.V1.StationInfo,
172 + data,
173 + on_conflict: {:replace_all_except, [:id]},
174 + conflict_target: [:source_id, :station_id]
175 + )
176 + end
177 +
178 + defp write_feed(:stat_status, json, id, dt, _lang) do
179 + # v3 dropped the electric count; the types stored from
180 + # vehicle_types.json are what puts it back.
181 + electric =
182 + id
183 + |> intify()
184 + |> RoomSanctum.Storage.gbfs_vehicle_types()
185 + |> RoomGbfs.V3.electric_type_ids()
186 +
187 + data =
188 + json.data.stations
189 + |> Enum.map(&RoomGbfs.V3.station_status(&1, electric))
190 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
191 + |> Enum.map(fn x ->
192 + RoomSanctum.Storage.change_station_status(
193 + %RoomSanctum.Storage.GBFS.V1.StationStatus{},
194 + x
195 + ).changes
196 + |> inj_iddt(id, dt)
197 + end)
198 +
199 + Repo.insert_all(
200 + RoomSanctum.Storage.GBFS.V1.StationStatus,
201 + data,
202 + on_conflict: {:replace_all_except, [:id]},
203 + conflict_target: [:source_id, :station_id]
204 + )
205 + end
206 +
207 + defp write_feed(:free_bike, json, id, dt, _lang) do
208 + # v3 calls them vehicles and puts them under `vehicles`.
209 + data =
210 + (Map.get(json.data, :bikes) || Map.get(json.data, :vehicles) || [])
211 + |> Enum.map(&RoomGbfs.V3.vehicle_status/1)
212 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
213 + |> Enum.map(fn x ->
214 + point = %Geo.Point{coordinates: {x.lon, x.lat}, srid: 4326}
215 + x |> Map.put(:point, point)
216 + end)
217 + |> Enum.map(fn x ->
218 + RoomSanctum.Storage.change_free_bike_status(
219 + %RoomSanctum.Storage.GBFS.V1.FreeBikeStatus{},
220 + x
221 + ).changes
222 + |> inj_iddt(id, dt)
223 + end)
224 +
225 + RoomSanctum.Storage.truncate_free_bike_status(id)
226 +
227 + Repo.insert_all(
228 + RoomSanctum.Storage.GBFS.V1.FreeBikeStatus,
229 + data,
230 + on_conflict: {:replace_all_except, [:id]},
231 + conflict_target: [:source_id, :bike_id]
232 + )
233 + end
234 +
235 + defp write_feed(:vehicle_types, json, id, dt, lang) do
236 + data =
237 + json.data.vehicle_types
238 + |> Enum.map(&RoomGbfs.V3.vehicle_types(&1, lang))
239 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
240 + |> Enum.map(fn x ->
241 + RoomSanctum.Storage.change_vehicle_types(
242 + %RoomSanctum.Storage.GBFS.V1.VehicleTypes{},
243 + x
244 + ).changes
245 + |> inj_iddt(id, dt)
246 + end)
247 +
248 + RoomSanctum.Storage.truncate_vehicle_types(id)
249 +
250 + Repo.insert_all(
251 + RoomSanctum.Storage.GBFS.V1.VehicleTypes,
252 + data,
253 + on_conflict: {:replace_all_except, [:id]},
254 + conflict_target: [:source_id, :vehicle_type_id]
255 + )
256 + end
257 +
258 + defp write_feed(:pricing_plans, json, id, dt, _lang) do
259 + data =
260 + json.data.plans
261 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
262 + |> Enum.map(fn x ->
263 + RoomSanctum.Storage.change_system_pricing_plans(
264 + %RoomSanctum.Storage.GBFS.V1.SystemPricingPlans{},
265 + x
266 + ).changes
267 + |> inj_iddt(id, dt)
268 + end)
269 +
270 + RoomSanctum.Storage.truncate_system_pricing_plans(id)
271 +
272 + Repo.insert_all(
273 + RoomSanctum.Storage.GBFS.V1.SystemPricingPlans,
274 + data,
275 + on_conflict: {:replace_all_except, [:id]},
276 + conflict_target: [:source_id, :plan_id]
277 + )
278 + end
279 +
280 + defp write_feed(:geofencing_zones, json, id, dt, _lang) do
281 + data =
282 + json.data.geofencing_zones.features
283 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
284 + |> Enum.map(fn x ->
285 + point = %Geo.MultiPolygon{coordinates: x.geometry.coordinates, srid: 4326}
286 + x |> Map.put(:place, point)
287 + end)
288 + |> Enum.map(fn x ->
289 + RoomSanctum.Storage.change_geo_fencing_zones(
290 + %RoomSanctum.Storage.GBFS.V1.GeoFencingZones{},
291 + x
292 + ).changes
293 + |> inj_iddt(id, dt)
294 + end)
295 +
296 + RoomSanctum.Storage.truncate_geo_fencing_zones(id)
297 +
298 + # Repo.insert_all(
299 + # RoomSanctum.Storage.GBFS.V1.GeoFencingZones,
300 + # data
301 + # )
302 + end
303 +
304 + defp write_feed(:sys_hours, _json, _id, _dt, _lang) do
305 + :ok
306 + end
307 +
308 + defp write_feed(:sys_cal, _json, _id, _dt, _lang) do
309 + :ok
310 + end
311 +
312 + defp write_feed(:sys_regions, _json, _id, _dt, _lang) do
313 + :ok
314 + end
315 +
316 + defp write_feed(:sys_alerts, json, id, dt, _lang) do
317 + data =
318 + json.data.alerts
319 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
320 + |> Enum.map(fn x ->
321 + RoomSanctum.Storage.change_gbfs_alert(%RoomSanctum.Storage.GBFS.V1.Alert{}, x).changes
322 + |> inj_iddt(id, dt)
323 + end)
324 +
325 + Repo.insert_all(
326 + RoomSanctum.Storage.GBFS.V1.Alert,
327 + data,
328 + on_conflict: {:replace_all_except, [:id]},
329 + conflict_target: [:source_id, :alert_id]
330 + )
331 + end
332 +
333 + defp write_feed(:ebikes_at_stations, json, id, dt, _lang) do
334 + data =
335 + json.data.stations
336 + |> Enum.map(fn x -> inj_iddt(x, id, dt) end)
337 + |> Enum.map(fn x ->
338 + cs =
339 + RoomSanctum.Storage.change_ebikes_at_stations(
340 + %RoomSanctum.Storage.GBFS.V1.EbikesAtStations{},
341 + x
342 + ).changes
343 + |> inj_iddt(id, dt)
344 +
345 + eb = cs.ebikes |> Enum.map(fn eb -> eb |> Ecto.Changeset.apply_changes() end)
346 + cs |> Map.put(:ebikes, eb)
347 + end)
348 +
349 + RoomSanctum.Storage.truncate_ebikes_at_stations(id)
350 +
351 + Repo.insert_all(
352 + RoomSanctum.Storage.GBFS.V1.EbikesAtStations,
353 + data,
354 + on_conflict: {:replace_all_except, [:id]},
355 + conflict_target: [:source_id, :station_id]
356 + )
357 + end
358 +
347 359 def handle_call(_msg, _from, state) do
348 360 {:reply, :ok, state}
349 361 end
@@ -356,6 +368,23 @@ defmodule RoomGbfs.Worker do
356 368
357 369 @tfh 14
358 370
371 + # Each discovery feed this worker writes, in v2's names: what write_data/4
372 + # calls it, the event broadcast once it is written, and its step of @tfh.
373 + @static_feeds %{
374 + "ebikes_at_stations" => {:ebikes_at_stations, :system_information, 3},
375 + "system_information" => {:sys_info, :system_information, 4},
376 + "station_information" => {:stat_info, :system_information, 5},
377 + "station_status" => {:stat_status, :system_information, 6},
378 + "free_bike_status" => {:free_bike, :system_information, 7},
379 + "system_hours" => {:sys_hours, :system_information, 8},
380 + "system_calendar" => {:sys_cal, :system_information, 9},
381 + "system_regions" => {:sys_regions, :system_information, 10},
382 + "system_alerts" => {:sys_alerts, :system_alerts, 11},
383 + "vehicle_types" => {:vehicle_types, :vehicle_types, 12},
384 + "system_pricing_plans" => {:pricing_plans, :pricing_plans, 13},
385 + "geofencing_zones" => {:geofencing_zones, :geofencing_zones, 14}
386 + }
387 +
359 388 @impl true
360 389 def handle_cast(:update_static, state) do
361 390 cfg = Configuration.get_source!(state.id)
@@ -364,111 +393,8 @@ defmodule RoomGbfs.Worker do
364 393 true ->
365 394 Logger.info("GBFS::#{state.id} updating static info ")
366 395 bcast(state.id, :downloading, 1, @tfh)
367 −
368 − case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
369 − {:ok, result} ->
370 − # decode! here took the whole worker down when the body was not JSON,
371 − # which is exactly what an unfollowed redirect hands you.
372 − json_body =
373 − case Jason.decode(result.body) do
374 − {:ok, decoded} ->
375 − decoded
376 −
377 − {:error, _} ->
378 − Logger.error(
379 − "GBFS::#{state.id} discovery returned non-JSON (HTTP #{result.status_code}) from #{cfg.config.url}"
380 − )
381 −
382 − %{}
383 − end
384 −
385 − case RoomGbfs.V3.feeds(json_body, cfg.config.lang) do
386 − {:ok, version, feeds} ->
387 − if version == :v3 do
388 − Logger.info("GBFS::#{state.id} discovery is v3")
389 − end
390 −
391 − bcast(state.id, :parsing, 2, @tfh)
392 −
393 − feeds
394 − # Vehicle types first: station_status needs them stored to
395 − # work out how many of its bikes are electric, and operators
396 − # list the feeds in whatever order suits them.
397 − |> Enum.sort_by(&RoomGbfs.V3.feed_order/1)
398 − |> Enum.each(fn %{"name" => name, "url" => url} ->
399 − # v3 renamed the loose-vehicle feed; everything below is
400 − # written in v2's names, which is the one dialect this worker
401 − # speaks.
402 − case RoomGbfs.V3.feed_name(name) do
403 − "ebikes_at_stations" ->
404 − write_data(url, :ebikes_at_stations, state.id, cfg.config.lang)
405 − bcast(state.id, :system_information, 3, @tfh)
406 −
407 − "system_information" ->
408 − write_data(url, :sys_info, state.id, cfg.config.lang)
409 − bcast(state.id, :system_information, 4, @tfh)
410 −
411 − "station_information" ->
412 − write_data(url, :stat_info, state.id, cfg.config.lang)
413 − bcast(state.id, :system_information, 5, @tfh)
414 −
415 − "station_status" ->
416 − write_data(url, :stat_status, state.id, cfg.config.lang)
417 − bcast(state.id, :system_information, 6, @tfh)
418 −
419 − "free_bike_status" ->
420 − write_data(url, :free_bike, state.id, cfg.config.lang)
421 − bcast(state.id, :system_information, 7, @tfh)
422 −
423 − "system_hours" ->
424 − write_data(url, :sys_hours, state.id, cfg.config.lang)
425 − bcast(state.id, :system_information, 8, @tfh)
426 −
427 − "system_calendar" ->
428 − write_data(url, :sys_cal, state.id, cfg.config.lang)
429 − bcast(state.id, :system_information, 9, @tfh)
430 −
431 − "system_regions" ->
432 − write_data(url, :sys_regions, state.id, cfg.config.lang)
433 − bcast(state.id, :system_information, 10, @tfh)
434 −
435 − "system_alerts" ->
436 − write_data(url, :sys_alerts, state.id, cfg.config.lang)
437 − bcast(state.id, :system_alerts, 11, @tfh)
438 −
439 − "vehicle_types" ->
440 − write_data(url, :vehicle_types, state.id, cfg.config.lang)
441 − bcast(state.id, :vehicle_types, 12, @tfh)
442 −
443 − "system_pricing_plans" ->
444 − write_data(url, :pricing_plans, state.id, cfg.config.lang)
445 − bcast(state.id, :pricing_plans, 13, @tfh)
446 −
447 − "geofencing_zones" ->
448 − write_data(url, :geofencing_zones, state.id, cfg.config.lang)
449 − bcast(state.id, :geofencing_zones, 14, @tfh)
450 −
451 − otherwise ->
452 − Logger.info("GBFS::#{state.id} System has unhandled file #{otherwise}")
453 − end
454 − end)
455 −
456 − {:noreply, state}
457 −
458 − {:error, languages} ->
459 − bcast(
460 − state.id,
461 − :language_error,
462 − "Invalid Language Selected, available #{Enum.join(languages, ", ")}, selected: #{cfg.config.lang}"
463 − )
464 −
465 − {:noreply, state}
466 − end
467 −
468 − {:error, info} ->
469 − Logger.info(info.reason)
470 − {:noreply, state}
471 − end
396 + update_static(cfg, state.id)
397 + {:noreply, state}
472 398
473 399 false ->
474 400 bcast(state.id, :disabled)
@@ -484,77 +410,138 @@ defmodule RoomGbfs.Worker do
484 410 true ->
485 411 Logger.info("GBFS::#{state.id} updating realtime info ")
486 412 bcast(state.id, :downloading, 1, 5)
487 −
488 − case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
489 − {:ok, result} ->
490 − case result.body |> Jason.decode() do
491 − {:ok, json_body} ->
492 − case RoomGbfs.V3.feeds(json_body, cfg.config.lang) do
493 − {:ok, _version, feeds} ->
494 − bcast(state.id, :parsing, 2, 5)
495 −
496 − feeds
497 − |> Enum.sort_by(&RoomGbfs.V3.feed_order/1)
498 − |> Enum.each(fn %{"name" => name, "url" => url} ->
499 − case RoomGbfs.V3.feed_name(name) do
500 − "station_information" ->
501 − write_data(url, :stat_info, state.id, cfg.config.lang)
502 − bcast(state.id, :system_information, 3, 5)
503 −
504 − # The counts. Fetched here rather than only on the
505 − # daily static pass, which is how a dock could report
506 − # yesterday's bikes for a whole day.
507 − "station_status" ->
508 − write_data(url, :stat_status, state.id, cfg.config.lang)
509 − bcast(state.id, :system_information, 4, 5)
510 −
511 − "ebikes_at_stations" ->
512 − write_data(url, :ebikes_at_stations, state.id, cfg.config.lang)
513 − bcast(state.id, :system_information, 4, 5)
514 −
515 − "free_bike_status" ->
516 − write_data(url, :free_bike, state.id, cfg.config.lang)
517 − bcast(state.id, :system_information, 5, 5)
518 −
519 − _otherwise ->
520 − :ok
521 − end
522 − end)
523 −
524 − {:noreply, state}
525 −
526 − {:error, languages} ->
527 − bcast(
528 − state.id,
529 − :language_error,
530 − "Invalid Language Selected, available #{Enum.join(languages, ", ")}, selected: #{cfg.config.lang}"
531 − )
532 −
533 − {:noreply, state}
534 − end
535 −
536 − {:error, error} ->
537 − Logger.info(error.data)
538 − {:noreply, state}
539 − end
540 −
541 − {:error, error} ->
542 − Logger.info(error.reason)
543 − {:noreply, state}
544 − end
413 + update_realtime(cfg, state.id)
414 + {:noreply, state}
545 415
546 416 false ->
547 417 bcast(state.id, :disabled)
548 418 {:noreply, state}
549 419 end
550 −
551 − {:noreply, state}
552 420 end
553 421
554 422 def handle_cast(_msg, state) do
555 423 {:noreply, state}
556 424 end
557 425
426 + defp update_static(cfg, id) do
427 + case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
428 + {:ok, result} ->
429 + result
430 + |> discovery_json(id, cfg.config.url)
431 + |> RoomGbfs.V3.feeds(cfg.config.lang)
432 + |> write_static_feeds(id, cfg.config.lang)
433 +
434 + {:error, info} ->
435 + Logger.info(info.reason)
436 + end
437 + end
438 +
439 + # decode! here took the whole worker down when the body was not JSON,
440 + # which is exactly what an unfollowed redirect hands you.
441 + defp discovery_json(result, id, url) do
442 + case Jason.decode(result.body) do
443 + {:ok, decoded} ->
444 + decoded
445 +
446 + {:error, _} ->
447 + Logger.error(
448 + "GBFS::#{id} discovery returned non-JSON (HTTP #{result.status_code}) from #{url}"
449 + )
450 +
451 + %{}
452 + end
453 + end
454 +
455 + defp write_static_feeds({:ok, version, feeds}, id, lang) do
456 + if version == :v3 do
457 + Logger.info("GBFS::#{id} discovery is v3")
458 + end
459 +
460 + bcast(id, :parsing, 2, @tfh)
461 +
462 + feeds
463 + # Vehicle types first: station_status needs them stored to
464 + # work out how many of its bikes are electric, and operators
465 + # list the feeds in whatever order suits them.
466 + |> Enum.sort_by(&RoomGbfs.V3.feed_order/1)
467 + |> Enum.each(&write_static_feed(&1, id, lang))
468 + end
469 +
470 + defp write_static_feeds({:error, languages}, id, lang), do: language_error(id, languages, lang)
471 +
472 + defp write_static_feed(%{"name" => name, "url" => url}, id, lang) do
473 + # v3 renamed the loose-vehicle feed; everything below is
474 + # written in v2's names, which is the one dialect this worker
475 + # speaks.
476 + case Map.fetch(@static_feeds, RoomGbfs.V3.feed_name(name)) do
477 + {:ok, {type, event, step}} ->
478 + write_data(url, type, id, lang)
479 + bcast(id, event, step, @tfh)
480 +
481 + :error ->
482 + Logger.info("GBFS::#{id} System has unhandled file #{RoomGbfs.V3.feed_name(name)}")
483 + end
484 + end
485 +
486 + # The feeds the five minute pass refreshes, and each one's step of five.
487 + @realtime_feeds %{
488 + "station_information" => {:stat_info, 3},
489 + # The counts. Fetched here rather than only on the
490 + # daily static pass, which is how a dock could report
491 + # yesterday's bikes for a whole day.
492 + "station_status" => {:stat_status, 4},
493 + "ebikes_at_stations" => {:ebikes_at_stations, 4},
494 + "free_bike_status" => {:free_bike, 5}
495 + }
496 +
497 + defp update_realtime(cfg, id) do
498 + case HTTPoison.get(cfg.config.url, [], follow_redirect: true) do
499 + {:ok, result} ->
500 + case result.body |> Jason.decode() do
501 + {:ok, json_body} ->
502 + json_body
503 + |> RoomGbfs.V3.feeds(cfg.config.lang)
504 + |> write_realtime_feeds(id, cfg.config.lang)
505 +
506 + {:error, error} ->
507 + Logger.info(error.data)
508 + end
509 +
510 + {:error, error} ->
511 + Logger.info(error.reason)
512 + end
513 + end
514 +
515 + defp write_realtime_feeds({:ok, _version, feeds}, id, lang) do
516 + bcast(id, :parsing, 2, 5)
517 +
518 + feeds
519 + |> Enum.sort_by(&RoomGbfs.V3.feed_order/1)
520 + |> Enum.each(&write_realtime_feed(&1, id, lang))
521 + end
522 +
523 + defp write_realtime_feeds({:error, languages}, id, lang),
524 + do: language_error(id, languages, lang)
525 +
526 + defp write_realtime_feed(%{"name" => name, "url" => url}, id, lang) do
527 + case Map.fetch(@realtime_feeds, RoomGbfs.V3.feed_name(name)) do
528 + {:ok, {type, step}} ->
529 + write_data(url, type, id, lang)
530 + bcast(id, :system_information, step, 5)
531 +
532 + :error ->
533 + :ok
534 + end
535 + end
536 +
537 + defp language_error(id, languages, lang) do
538 + bcast(
539 + id,
540 + :language_error,
541 + "Invalid Language Selected, available #{Enum.join(languages, ", ")}, selected: #{lang}"
542 + )
543 + end
544 +
558 545 defp via_tuple(name), do: {:via, Registry, {@registry, name}}
559 546
560 547 def sys_info_as_stats(id) do
modified apps/room_gtfs/lib/room_gtfs/feed_tester.ex
+23 −15
@@ -260,21 +260,29 @@ defmodule RoomGtfs.FeedTester do
260 260 end
261 261 end
262 262
263 − defp do_check_agency(result, feed, source) do
264 − prefixes =
265 − feed.entity
266 − |> Enum.map(&trip_id/1)
267 − |> Enum.reject(&is_nil/1)
268 − |> Enum.map(fn id ->
269 − case String.split(id, ":", parts: 2) do
270 − [prefix, _rest] -> prefix
271 − _ -> nil
272 − end
273 − end)
274 − |> Enum.reject(&is_nil/1)
275 − |> Enum.uniq()
276 − |> Enum.sort()
263 + # The operator prefixes on the feed's trip ids -- the part before the first
264 + # colon -- each once, sorted.
265 + defp trip_prefixes(feed) do
266 + feed.entity
267 + |> Enum.map(&trip_id/1)
268 + |> Enum.reject(&is_nil/1)
269 + |> Enum.map(fn id ->
270 + case String.split(id, ":", parts: 2) do
271 + [prefix, _rest] -> prefix
272 + _ -> nil
273 + end
274 + end)
275 + |> Enum.reject(&is_nil/1)
276 + |> Enum.uniq()
277 + |> Enum.sort()
278 + end
277 279
280 + defp shown_prefixes(prefixes) do
281 + "#{Enum.join(Enum.take(prefixes, 6), ", ")}#{if length(prefixes) > 6, do: ", …", else: ""}"
282 + end
283 +
284 + defp do_check_agency(result, feed, source) do
285 + prefixes = trip_prefixes(feed)
278 286 configured = Map.get(source.config, :rt_agency)
279 287 result = Map.put(result, :prefixes, prefixes)
280 288
@@ -283,7 +291,7 @@ defmodule RoomGtfs.FeedTester do
283 291 add(
284 292 result,
285 293 :error,
286 − "trip ids are prefixed (#{Enum.join(Enum.take(prefixes, 6), ", ")}#{if length(prefixes) > 6, do: ", …", else: ""}) " <>
294 + "trip ids are prefixed (#{shown_prefixes(prefixes)}) " <>
287 295 "but no operator code is set, so nothing will match the schedule"
288 296 )
289 297
modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+302 −373
@@ -308,41 +308,40 @@ defmodule RoomGtfs.Worker do
308 308 vehicles = occupancy_vehicles(id, config, trips)
309 309
310 310 res
311 − |> Enum.map(fn x ->
312 − case Enum.find(rtvals, fn v ->
313 − trip_id_match?(config, x.trip_id, v.trip_id)
314 − end) do
315 − nil ->
316 − x
317 −
318 − val ->
319 − stu = val.stop
320 − time_event = stu && (stu.arrival || stu.departure)
321 −
322 − x =
323 − x
324 − |> merge_occupancy(occupancy_for(config, x.trip_id, stu, vehicles))
325 − |> merge_fleet_mark(config, vehicles)
326 − |> merge_schedule_relationship(val.schedule_relationship, stu)
327 − |> merge_stop_properties(stu)
328 −
329 − case time_event do
330 − nil ->
331 − x
332 −
333 − event ->
334 − x
335 − |> Map.put(:arrival_time_live_ts, event.time)
336 − |> Map.put(:arrival_time_live_delay, event.delay)
337 − |> Map.put(:arrival_time_live_uncertianty, event.uncertainty)
338 − end
339 − end
340 − end)
311 + |> Enum.map(&merge_realtime(&1, rtvals, config, vehicles))
341 312 |> resolve_assigned_platforms(id)
342 313 end
343 314 end
344 315 end
345 316
317 + # One scheduled arrival with whatever the trip update for its trip says.
318 + defp merge_realtime(x, rtvals, config, vehicles) do
319 + case Enum.find(rtvals, fn v -> trip_id_match?(config, x.trip_id, v.trip_id) end) do
320 + nil ->
321 + x
322 +
323 + val ->
324 + stu = val.stop
325 + time_event = stu && (stu.arrival || stu.departure)
326 +
327 + x
328 + |> merge_occupancy(occupancy_for(config, x.trip_id, stu, vehicles))
329 + |> merge_fleet_mark(config, vehicles)
330 + |> merge_schedule_relationship(val.schedule_relationship, stu)
331 + |> merge_stop_properties(stu)
332 + |> merge_live_time(time_event)
333 + end
334 + end
335 +
336 + defp merge_live_time(x, nil), do: x
337 +
338 + defp merge_live_time(x, event) do
339 + x
340 + |> Map.put(:arrival_time_live_ts, event.time)
341 + |> Map.put(:arrival_time_live_delay, event.delay)
342 + |> Map.put(:arrival_time_live_uncertianty, event.uncertainty)
343 + end
344 +
346 345 # How long to wait for vehicle positions before giving up on occupancy.
347 346 #
348 347 # Deliberately short. This is the second call query_stop makes to the same
@@ -962,47 +961,10 @@ defmodule RoomGtfs.Worker.RT do
962 961 |> Enum.filter(& &1.alert)
963 962 |> Enum.map(& &1.alert)
964 963 |> Enum.filter(fn alert ->
965 − active =
966 − case alert.active_period do
967 − [] ->
968 − true
969 −
970 − periods ->
971 − Enum.any?(periods, fn p ->
972 − (p.start == nil || p.start <= now) &&
973 − (Map.get(p, :end) == nil || Map.get(p, :end) >= now)
974 − end)
975 − end
976 −
977 − relevant =
978 − Enum.any?(alert.informed_entity, fn e ->
979 − (e.stop_id != nil && e.stop_id == stop) ||
980 − (e.route_id != nil && e.route_id in route_ids) ||
981 − (e.agency_id != nil && e.stop_id == nil && e.route_id == nil) ||
982 − (e.stop_id == nil && e.route_id == nil && e.trip == nil && e.agency_id == nil)
983 − end)
984 −
985 − active && relevant
986 − end)
987 − |> Enum.map(fn alert ->
988 − route_id = Enum.find_value(alert.informed_entity, fn e -> e.route_id end)
989 −
990 − %{
991 − effect: alert.effect |> to_string(),
992 − cause: alert.cause |> to_string(),
993 − header: get_translation(alert.header_text),
994 − description: get_translation(alert.description_text),
995 − route_id: route_id,
996 − severity: alert.severity_level |> to_string(),
997 − # Whether the alert names this stop, as opposed to reaching it by
998 − # naming the line it is on. "Elevator out here" and "delays on the
999 − # Red Line" are both true of the stop and are not the same news.
1000 − stop_specific:
1001 − Enum.any?(alert.informed_entity, fn e ->
1002 − e.stop_id != nil and e.stop_id == stop
1003 − end)
1004 − }
964 + alert_active?(alert, now) &&
965 + Enum.any?(alert.informed_entity, &reaches_stop?(&1, stop, route_ids))
1005 966 end)
967 + |> Enum.map(&present_stop_alert(&1, stop))
1006 968 end
1007 969
1008 970 {:reply, alerts, state}
@@ -1038,6 +1000,35 @@ defmodule RoomGtfs.Worker.RT do
1038 1000 end
1039 1001 end
1040 1002
1003 + # An informed entity reaches a stop by naming it, by naming a route in
1004 + # route_ids, or by naming neither -- the whole agency when it carries an
1005 + # agency id, the whole feed when it carries no trip either.
1006 + defp reaches_stop?(e, stop, route_ids) do
1007 + (e.stop_id != nil and e.stop_id == stop) or
1008 + (e.route_id != nil and e.route_id in route_ids) or
1009 + (e.stop_id == nil and e.route_id == nil and (e.agency_id != nil or e.trip == nil))
1010 + end
1011 +
1012 + defp present_stop_alert(alert, stop) do
1013 + route_id = Enum.find_value(alert.informed_entity, fn e -> e.route_id end)
1014 +
1015 + %{
1016 + effect: alert.effect |> to_string(),
1017 + cause: alert.cause |> to_string(),
1018 + header: get_translation(alert.header_text),
1019 + description: get_translation(alert.description_text),
1020 + route_id: route_id,
1021 + severity: alert.severity_level |> to_string(),
1022 + # Whether the alert names this stop, as opposed to reaching it by
1023 + # naming the line it is on. "Elevator out here" and "delays on the
1024 + # Red Line" are both true of the stop and are not the same news.
1025 + stop_specific:
1026 + Enum.any?(alert.informed_entity, fn e ->
1027 + e.stop_id != nil and e.stop_id == stop
1028 + end)
1029 + }
1030 + end
1031 +
1041 1032 # An alert names what it affects as a list of informed entities, each of
1042 1033 # which may carry a route, a stop, both, or neither -- "neither" meaning the
1043 1034 # whole agency. Those ids are the thing worth reading: they say whether an
@@ -1162,11 +1153,11 @@ defmodule RoomGtfs.Worker.RT do
1162 1153
1163 1154 defp place_vehicle(v, stops) do
1164 1155 base = %{
1165 − vehicle_id: v.vehicle && v.vehicle.id,
1156 + vehicle_id: field_of(v.vehicle, :id),
1166 1157 # What is painted on it, which for the MBTA is the car numbers.
1167 − label: v.vehicle && v.vehicle.label,
1168 − trip_id: v.trip && v.trip.trip_id,
1169 − route_id: v.trip && v.trip.route_id,
1158 + label: field_of(v.vehicle, :label),
1159 + trip_id: field_of(v.trip, :trip_id),
1160 + route_id: field_of(v.trip, :route_id),
1170 1161 stop_id: v.stop_id,
1171 1162 current_status: v.current_status,
1172 1163 occupancy_status: v.occupancy_status,
@@ -1198,6 +1189,10 @@ defmodule RoomGtfs.Worker.RT do
1198 1189 end
1199 1190 end
1200 1191
1192 + # A field of a descriptor the feed may have left out.
1193 + defp field_of(nil, _key), do: nil
1194 + defp field_of(descriptor, key), do: Map.fetch!(descriptor, key)
1195 +
1201 1196 # Positions for the stops this feed actually names, as the fallback above
1202 1197 # needs them.
1203 1198 #
@@ -1944,6 +1939,20 @@ defmodule RoomGtfs.Worker.Static do
1944 1939 )
1945 1940 end
1946 1941
1942 + # Each file type written by COPY: its schema, and what empties the source's
1943 + # rows of it first.
1944 + @copy_tables %{
1945 + agencies: {GTFS.Agency, &RoomSanctum.Storage.truncate_agency/1},
1946 + calendars: {GTFS.Calendar, &RoomSanctum.Storage.truncate_calendar/1},
1947 + calendar_dates: {GTFS.CalendarDate, &RoomSanctum.Storage.truncate_calendar_date/1},
1948 + directions: {GTFS.Direction, &RoomSanctum.Storage.truncate_direction/1},
1949 + routes: {GTFS.Route, &RoomSanctum.Storage.truncate_route/1},
1950 + stops: {GTFS.Stop, &RoomSanctum.Storage.truncate_stop/1},
1951 + stop_times: {GTFS.StopTime, &RoomSanctum.Storage.truncate_stop_time/1},
1952 + trips: {GTFS.Trip, &RoomSanctum.Storage.truncate_trip/1},
1953 + shapes: {GTFS.Shape, &RoomSanctum.Storage.truncate_shape/1}
1954 + }
1955 +
1947 1956 defp write_file(contents, type, id, pid) do
1948 1957 datetime = NaiveDateTime.local_now()
1949 1958 Logger.info("GTFS::#{id} writing #{type} (c)")
@@ -1968,58 +1977,12 @@ defmodule RoomGtfs.Worker.Static do
1968 1977 # contents |> Stream.take(500) |> Enum.to_list() |> IO.inspect
1969 1978
1970 1979 # add truncation here as necessary
1971 − case type do
1972 − :agencies -> RoomSanctum.Storage.truncate_agency(id)
1973 − :calendars -> RoomSanctum.Storage.truncate_calendar(id)
1974 − :calendar_dates -> RoomSanctum.Storage.truncate_calendar_date(id)
1975 − :directions -> RoomSanctum.Storage.truncate_direction(id)
1976 − :routes -> RoomSanctum.Storage.truncate_route(id)
1977 − :stops -> RoomSanctum.Storage.truncate_stop(id)
1978 − :stop_times -> RoomSanctum.Storage.truncate_stop_time(id)
1979 − :trips -> RoomSanctum.Storage.truncate_trip(id)
1980 − :shapes -> RoomSanctum.Storage.truncate_shape(id)
1981 − _ -> :ok
1982 − end
1980 + {schema, truncate} = Map.fetch!(@copy_tables, type)
1981 + truncate.(id)
1983 1982
1984 1983 # set our variables based on the type
1985 − {table, columns, pg_cols} =
1986 − case type do
1987 − :agencies ->
1988 − {:gtfs_agencies, [GTFS.Agency |> get_cols(cols_j_plus)],
1989 − GTFS.Agency |> get_cols_pgtypes(cols_j_plus)}
1990 −
1991 − :calendars ->
1992 − {:gtfs_calendars, [GTFS.Calendar |> get_cols(cols_j_plus)],
1993 − GTFS.Calendar |> get_cols_pgtypes(cols_j_plus)}
1994 −
1995 − :calendar_dates ->
1996 − {:gtfs_calendar_dates, [GTFS.CalendarDate |> get_cols(cols_j_plus)],
1997 − GTFS.CalendarDate |> get_cols_pgtypes(cols_j_plus)}
1998 −
1999 − :directions ->
2000 − {:gtfs_directions, [GTFS.Direction |> get_cols(cols_j_plus)],
2001 − GTFS.Direction |> get_cols_pgtypes(cols_j_plus)}
2002 −
2003 − :routes ->
2004 − {:gtfs_routes, [GTFS.Route |> get_cols(cols_j_plus)],
2005 − GTFS.Route |> get_cols_pgtypes(cols_j_plus)}
2006 −
2007 − :stops ->
2008 − {:gtfs_stops, [GTFS.Stop |> get_cols(cols_j_plus)],
2009 − GTFS.Stop |> get_cols_pgtypes(cols_j_plus)}
2010 −
2011 − :stop_times ->
2012 − {:gtfs_stop_times, [GTFS.StopTime |> get_cols(cols_j_plus)],
2013 − GTFS.StopTime |> get_cols_pgtypes(cols_j_plus)}
2014 −
2015 − :trips ->
2016 − {:gtfs_trips, [GTFS.Trip |> get_cols(cols_j_plus)],
2017 − GTFS.Trip |> get_cols_pgtypes(cols_j_plus)}
2018 −
2019 − :shapes ->
2020 − {:gtfs_shapes, [GTFS.Shape |> get_cols(cols_j_plus)],
2021 − GTFS.Shape |> get_cols_pgtypes(cols_j_plus)}
2022 − end
1984 + columns = [schema |> get_cols(cols_j_plus)]
1985 + pg_cols = schema |> get_cols_pgtypes(cols_j_plus)
2023 1986
2024 1987 # IO.inspect({table, columns, pg_cols})
2025 1988 # `Repo.config/0` carries Ecto's `timeout: 15000`, which looks like it would
@@ -2107,12 +2070,7 @@ defmodule RoomGtfs.Worker.Static do
2107 2070 datetime = NaiveDateTime.local_now()
2108 2071 Logger.info("GTFS::#{id} writing #{type}")
2109 2072
2110 − case type do
2111 − :routes -> RoomSanctum.Storage.truncate_route(id)
2112 − :stops -> RoomSanctum.Storage.truncate_stop(id)
2113 − :stop_times -> RoomSanctum.Storage.truncate_stop_time(id)
2114 − _ -> :ok
2115 − end
2073 + truncate_rows(type, id)
2116 2074
2117 2075 contents
2118 2076 |> Stream.filter(fn {status, data} -> status == :ok end)
@@ -2124,34 +2082,8 @@ defmodule RoomGtfs.Worker.Static do
2124 2082 |> Map.put("inserted_at", datetime)
2125 2083 |> Map.put("updated_at", datetime)
2126 2084
2127 − case type do
2128 − :agency ->
2129 − RoomSanctum.Storage.change_agency(%RoomSanctum.Storage.GTFS.Agency{}, relevant_data).changes
2130 −
2131 − :calendar ->
2132 − RoomSanctum.Storage.change_calendar(%RoomSanctum.Storage.GTFS.Calendar{}, relevant_data).changes
2133 −
2134 − :directions ->
2135 − RoomSanctum.Storage.change_direction(
2136 − %RoomSanctum.Storage.GTFS.Direction{},
2137 − relevant_data
2138 − ).changes
2139 −
2140 − :routes ->
2141 − RoomSanctum.Storage.change_route(%RoomSanctum.Storage.GTFS.Route{}, relevant_data).changes
2142 −
2143 − :stops ->
2144 − RoomSanctum.Storage.change_stop(%RoomSanctum.Storage.GTFS.Stop{}, relevant_data).changes
2145 −
2146 − :stop_times ->
2147 − RoomSanctum.Storage.change_stop_time(
2148 − %RoomSanctum.Storage.GTFS.StopTime{},
2149 − relevant_data
2150 − ).changes
2151 −
2152 − :trips ->
2153 − RoomSanctum.Storage.change_trip(%RoomSanctum.Storage.GTFS.Trip{}, relevant_data).changes
2154 − end
2085 + type
2086 + |> row_changes(relevant_data)
2155 2087 |> Map.put(:inserted_at, datetime)
2156 2088 |> Map.put(:updated_at, datetime)
2157 2089 end)
@@ -2161,61 +2093,64 @@ defmodule RoomGtfs.Worker.Static do
2161 2093 chunked_data
2162 2094 |> Enum.uniq()
2163 2095
2164 − case type do
2165 − :agency ->
2166 − Repo.insert_all(
2167 − RoomSanctum.Storage.GTFS.Agency,
2168 − chunked_data,
2169 − on_conflict: {:replace_all_except, [:id]},
2170 − conflict_target: [:source_id, :agency_id]
2171 − )
2096 + insert_rows(type, chunked_data)
2097 + end)
2098 + |> Stream.run()
2172 2099
2173 − :calendar ->
2174 − Repo.insert_all(
2175 − RoomSanctum.Storage.GTFS.Calendar,
2176 − chunked_data,
2177 − on_conflict: {:replace_all_except, [:id]},
2178 − conflict_target: [:source_id, :service_id]
2179 − )
2100 + DateTime.utc_now()
2101 + end
2180 2102
2181 − :directions ->
2182 − Repo.insert_all(
2183 − RoomSanctum.Storage.GTFS.Direction,
2184 − chunked_data,
2185 − on_conflict: {:replace_all_except, [:id]},
2186 − conflict_target: [:source_id, :route_id, :direction_id]
2187 − )
2103 + # The types written row by row that are emptied first rather than upserted.
2104 + defp truncate_rows(:routes, id), do: RoomSanctum.Storage.truncate_route(id)
2105 + defp truncate_rows(:stops, id), do: RoomSanctum.Storage.truncate_stop(id)
2106 + defp truncate_rows(:stop_times, id), do: RoomSanctum.Storage.truncate_stop_time(id)
2107 + defp truncate_rows(_type, _id), do: :ok
2188 2108
2189 − :routes ->
2190 − Repo.insert_all(
2191 − RoomSanctum.Storage.GTFS.Route,
2192 − chunked_data
2193 − )
2109 + defp row_changes(:agency, data),
2110 + do: RoomSanctum.Storage.change_agency(%RoomSanctum.Storage.GTFS.Agency{}, data).changes
2194 2111
2195 − :stops ->
2196 − Repo.insert_all(
2197 − RoomSanctum.Storage.GTFS.Stop,
2198 − chunked_data
2199 − )
2112 + defp row_changes(:calendar, data),
2113 + do: RoomSanctum.Storage.change_calendar(%RoomSanctum.Storage.GTFS.Calendar{}, data).changes
2200 2114
2201 − :stop_times ->
2202 − Repo.insert_all(
2203 − RoomSanctum.Storage.GTFS.StopTime,
2204 − chunked_data
2205 − )
2115 + defp row_changes(:directions, data),
2116 + do: RoomSanctum.Storage.change_direction(%RoomSanctum.Storage.GTFS.Direction{}, data).changes
2206 2117
2207 − :trips ->
2208 − Repo.insert_all(
2209 − RoomSanctum.Storage.GTFS.Trip,
2210 − chunked_data,
2211 − on_conflict: {:replace_all_except, [:id]},
2212 − conflict_target: [:source_id, :trip_id]
2213 − )
2214 − end
2215 − end)
2216 − |> Stream.run()
2118 + defp row_changes(:routes, data),
2119 + do: RoomSanctum.Storage.change_route(%RoomSanctum.Storage.GTFS.Route{}, data).changes
2217 2120
2218 − DateTime.utc_now()
2121 + defp row_changes(:stops, data),
2122 + do: RoomSanctum.Storage.change_stop(%RoomSanctum.Storage.GTFS.Stop{}, data).changes
2123 +
2124 + defp row_changes(:stop_times, data),
2125 + do: RoomSanctum.Storage.change_stop_time(%RoomSanctum.Storage.GTFS.StopTime{}, data).changes
2126 +
2127 + defp row_changes(:trips, data),
2128 + do: RoomSanctum.Storage.change_trip(%RoomSanctum.Storage.GTFS.Trip{}, data).changes
2129 +
2130 + defp insert_rows(:agency, rows),
2131 + do: upsert_rows(RoomSanctum.Storage.GTFS.Agency, rows, [:source_id, :agency_id])
2132 +
2133 + defp insert_rows(:calendar, rows),
2134 + do: upsert_rows(RoomSanctum.Storage.GTFS.Calendar, rows, [:source_id, :service_id])
2135 +
2136 + defp insert_rows(:directions, rows),
2137 + do:
2138 + upsert_rows(RoomSanctum.Storage.GTFS.Direction, rows, [:source_id, :route_id, :direction_id])
2139 +
2140 + defp insert_rows(:routes, rows), do: Repo.insert_all(RoomSanctum.Storage.GTFS.Route, rows)
2141 + defp insert_rows(:stops, rows), do: Repo.insert_all(RoomSanctum.Storage.GTFS.Stop, rows)
2142 +
2143 + defp insert_rows(:stop_times, rows),
2144 + do: Repo.insert_all(RoomSanctum.Storage.GTFS.StopTime, rows)
2145 +
2146 + defp insert_rows(:trips, rows),
2147 + do: upsert_rows(RoomSanctum.Storage.GTFS.Trip, rows, [:source_id, :trip_id])
2148 +
2149 + defp upsert_rows(schema, rows, conflict_target) do
2150 + Repo.insert_all(schema, rows,
2151 + on_conflict: {:replace_all_except, [:id]},
2152 + conflict_target: conflict_target
2153 + )
2219 2154 end
2220 2155
2221 2156 # linked_datasets.txt associates GTFS-RT feeds with the schedule that
@@ -2346,36 +2281,19 @@ defmodule RoomGtfs.Worker.Static do
2346 2281 String.starts_with?(name, "__MACOSX/") or String.starts_with?(Path.basename(name), "._")
2347 2282 end
2348 2283
2349 − defp file_to_atom(filename) do
2350 − case filename do
2351 − "agency.txt" ->
2352 − :agencies
2353 −
2354 − "calendar.txt" ->
2355 − :calendars
2356 −
2357 − "calendar_dates.txt" ->
2358 − :calendar_dates
2359 −
2360 − "directions.txt" ->
2361 − :directions
2362 −
2363 − "routes.txt" ->
2364 − :routes
2365 −
2366 − "stops.txt" ->
2367 − :stops
2368 −
2369 − "stop_times.txt" ->
2370 − :stop_times
2284 + @file_types %{
2285 + "agency.txt" => :agencies,
2286 + "calendar.txt" => :calendars,
2287 + "calendar_dates.txt" => :calendar_dates,
2288 + "directions.txt" => :directions,
2289 + "routes.txt" => :routes,
2290 + "stops.txt" => :stops,
2291 + "stop_times.txt" => :stop_times,
2292 + "trips.txt" => :trips,
2293 + "shapes.txt" => :shapes
2294 + }
2371 2295
2372 − "trips.txt" ->
2373 − :trips
2374 −
2375 − "shapes.txt" ->
2376 − :shapes
2377 − end
2378 − end
2296 + defp file_to_atom(filename), do: Map.fetch!(@file_types, filename)
2379 2297
2380 2298 # The order the progress bar counts in, which has to be the order the files are
2381 2299 # actually written in or the bar moves backwards. Entries come from the zip's
@@ -2384,19 +2302,19 @@ defmodule RoomGtfs.Worker.Static do
2384 2302 # Being numbered last meant its step arrived as "10 of 10", which the source
2385 2303 # page reads as completion: the bar hit 100% and flipped to idle while three
2386 2304 # more files, stop_times included, were still to load.
2387 − def file_to_order(filename) do
2388 − case filename do
2389 − "agency.txt" -> 3
2390 − "calendar.txt" -> 4
2391 − "calendar_dates.txt" -> 5
2392 − "directions.txt" -> 6
2393 − "routes.txt" -> 7
2394 − "shapes.txt" -> 8
2395 − "stops.txt" -> 9
2396 − "stop_times.txt" -> 10
2397 − "trips.txt" -> 11
2398 − end
2399 − end
2305 + @file_order %{
2306 + "agency.txt" => 3,
2307 + "calendar.txt" => 4,
2308 + "calendar_dates.txt" => 5,
2309 + "directions.txt" => 6,
2310 + "routes.txt" => 7,
2311 + "shapes.txt" => 8,
2312 + "stops.txt" => 9,
2313 + "stop_times.txt" => 10,
2314 + "trips.txt" => 11
2315 + }
2316 +
2317 + def file_to_order(filename), do: Map.fetch!(@file_order, filename)
2400 2318
2401 2319 defp atom_to_table(atom) do
2402 2320 "gtfs_#{atom}"
@@ -2499,60 +2417,7 @@ defmodule RoomGtfs.Worker.Static do
2499 2417 bcast(id, :extracting, 2, 11)
2500 2418 Logger.info("GTFS::#{id} downloaded #{byte_size(result.body)} bytes")
2501 2419
2502 − case result.body |> trim_zip_tail() |> Unzip.InMem.new() |> Unzip.new() do
2503 − {:ok, unzip} ->
2504 − files = Unzip.list_entries(unzip)
2505 −
2506 − case Enum.find(files, &(&1.file_name == "linked_datasets.txt")) do
2507 − nil ->
2508 − :ok
2509 −
2510 − entry ->
2511 − Unzip.file_stream!(unzip, entry.file_name)
2512 − |> Enum.to_list()
2513 − |> IO.iodata_to_binary()
2514 − |> parse_linked_datasets()
2515 − |> linked_dataset_urls()
2516 − |> then(&apply_linked_datasets(cfg, &1))
2517 − end
2518 −
2519 − try do
2520 − # Sorted, because `Unzip.list_entries/1` maps over `cd_list`, which
2521 − # is a map -- so the order it hands back is neither the zip's nor
2522 − # alphabetical, it is whatever the hash gives. Left to that, the
2523 − # progress bar walks backwards: stop_times (step 9) came out ahead
2524 − # of stops (step 8) on the MTA feeds. Sorting by the same function
2525 − # that numbers the steps makes the order the bar claims the order
2526 − # the files are actually written in, for every feed.
2527 − files
2528 − |> gtfs_entries(id)
2529 − |> Enum.sort_by(&file_to_order(Path.basename(&1.file_name)))
2530 − |> Enum.each(fn e ->
2531 − name = Path.basename(e.file_name)
2532 − type = file_to_atom(name)
2533 −
2534 − bcast(id, type, file_to_order(name), 12)
2535 −
2536 − Unzip.file_stream!(unzip, e.file_name)
2537 − |> write_file(type, id, nil)
2538 − end)
2539 − rescue
2540 − e ->
2541 − Logger.error("GTFS::#{id} error during static import: #{inspect(e)}")
2542 − after
2543 − Configuration.update_source_meta(cfg, %{last_run: DateTime.utc_now()})
2544 − bcast(id, :done)
2545 − end
2546 −
2547 − :ok
2548 −
2549 − {:error, term} ->
2550 − # `Logger.error/1` takes chardata, not a struct: passing the
2551 − # raw term raised here, inside the branch meant to report the
2552 − # error.
2553 − Logger.error("GTFS::#{id} static feed was not a readable zip: #{inspect(term)}")
2554 − {:error, {:unzip, term}}
2555 − end
2420 + import_zip(id, cfg, result.body)
2556 2421
2557 2422 # A server that answers a zip URL with an error page was, until now,
2558 2423 # reported as a feed that was not a readable zip -- true, and useless.
@@ -2572,6 +2437,66 @@ defmodule RoomGtfs.Worker.Static do
2572 2437 end
2573 2438 end
2574 2439
2440 + defp import_zip(id, cfg, body) do
2441 + case body |> trim_zip_tail() |> Unzip.InMem.new() |> Unzip.new() do
2442 + {:ok, unzip} ->
2443 + files = Unzip.list_entries(unzip)
2444 + apply_linked_datasets_entry(unzip, files, cfg)
2445 + write_entries(unzip, files, id, cfg)
2446 + :ok
2447 +
2448 + {:error, term} ->
2449 + # `Logger.error/1` takes chardata, not a struct: passing the
2450 + # raw term raised here, inside the branch meant to report the
2451 + # error.
2452 + Logger.error("GTFS::#{id} static feed was not a readable zip: #{inspect(term)}")
2453 + {:error, {:unzip, term}}
2454 + end
2455 + end
2456 +
2457 + defp apply_linked_datasets_entry(unzip, files, cfg) do
2458 + case Enum.find(files, &(&1.file_name == "linked_datasets.txt")) do
2459 + nil ->
2460 + :ok
2461 +
2462 + entry ->
2463 + Unzip.file_stream!(unzip, entry.file_name)
2464 + |> Enum.to_list()
2465 + |> IO.iodata_to_binary()
2466 + |> parse_linked_datasets()
2467 + |> linked_dataset_urls()
2468 + |> then(&apply_linked_datasets(cfg, &1))
2469 + end
2470 + end
2471 +
2472 + defp write_entries(unzip, files, id, cfg) do
2473 + # Sorted, because `Unzip.list_entries/1` maps over `cd_list`, which
2474 + # is a map -- so the order it hands back is neither the zip's nor
2475 + # alphabetical, it is whatever the hash gives. Left to that, the
2476 + # progress bar walks backwards: stop_times (step 9) came out ahead
2477 + # of stops (step 8) on the MTA feeds. Sorting by the same function
2478 + # that numbers the steps makes the order the bar claims the order
2479 + # the files are actually written in, for every feed.
2480 + files
2481 + |> gtfs_entries(id)
2482 + |> Enum.sort_by(&file_to_order(Path.basename(&1.file_name)))
2483 + |> Enum.each(fn e ->
2484 + name = Path.basename(e.file_name)
2485 + type = file_to_atom(name)
2486 +
2487 + bcast(id, type, file_to_order(name), 12)
2488 +
2489 + Unzip.file_stream!(unzip, e.file_name)
2490 + |> write_file(type, id, nil)
2491 + end)
2492 + rescue
2493 + e ->
2494 + Logger.error("GTFS::#{id} error during static import: #{inspect(e)}")
2495 + after
2496 + Configuration.update_source_meta(cfg, %{last_run: DateTime.utc_now()})
2497 + bcast(id, :done)
2498 + end
2499 +
2575 2500 # The first line of whatever came instead, flattened. Enough to tell a rate
2576 2501 # limit from a login page from a 404, and short enough to sit in a log line.
2577 2502 defp static_body_hint(body) when is_binary(body) do
@@ -2609,74 +2534,74 @@ defmodule RoomGtfs.Worker.Static do
2609 2534 case HTTPoison.get(cfg.config.url) do
2610 2535 {:ok, result} ->
2611 2536 bcast(state.id, :extracting, 2, 10)
2537 + import_old_zip(result.body, cfg, state.id)
2612 2538
2613 − case result.body
2614 − |> :zip.unzip([:memory]) do
2615 − {:ok, files} ->
2616 − files
2617 − |> Enum.each(fn {name, data} ->
2618 − as_csv =
2619 − data
2620 − |> String.split("\n")
2621 − |> Enum.map(&replace/1)
2622 − |> Enum.filter(fn x -> x != "" end)
2623 − |> XP.parse_stream()
2624 −
2625 − Logger.info({"datum", as_csv})
2626 −
2627 − case name do
2628 − 'agency.txt' ->
2629 − write_file(as_csv, :agency, state.id)
2630 − bcast(state.id, :agency, 3, 9)
2631 −
2632 − 'calendar.txt' ->
2633 − write_file(as_csv, :calendar, state.id)
2634 − bcast(state.id, :calendar, 4, 9)
2539 + {:error, info} ->
2540 + Logger.info(info.reason)
2541 + end
2635 2542
2636 − 'directions.txt' ->
2637 − write_file(as_csv, :directions, state.id)
2638 − bcast(state.id, :directions, 5, 9)
2543 + false ->
2544 + bcast(state.id, :disabled)
2545 + {:noreply, state}
2546 + end
2639 2547
2640 − 'routes.txt' ->
2641 − write_file(as_csv, :routes, state.id)
2642 − bcast(state.id, :routes, 6, 9)
2548 + {:noreply, state}
2549 + end
2643 2550
2644 − 'stops.txt' ->
2645 − write_file(as_csv, :stops, state.id)
2646 − bcast(state.id, :stops, 7, 9)
2551 + defp import_old_zip(body, cfg, id) do
2552 + case :zip.unzip(body, [:memory]) do
2553 + {:ok, files} ->
2554 + Enum.each(files, fn {name, data} -> import_old_file(name, data, id) end)
2647 2555
2648 − 'stop_times.txt' ->
2649 − write_file(data, :stop_times, state.id, via: :copy)
2650 − bcast(state.id, :stop_times, 8, 9)
2556 + Logger.info("GTFS::#{id} completed import")
2651 2557
2652 − 'trips.txt' ->
2653 − write_file(as_csv, :trips, state.id)
2654 − bcast(state.id, :trips, 9, 9)
2558 + Configuration.update_source_meta(cfg, %{last_run: DateTime.utc_now()})
2655 2559
2656 − _other ->
2657 − :ok
2658 − end
2659 − end)
2560 + {:error, _info} ->
2561 + bcast(id, :error, 1, 1)
2562 + Logger.info("GTFS::#{id} Got invalid zip file #{_info.reason}")
2563 + end
2564 + end
2660 2565
2661 − Logger.info("GTFS::#{state.id} completed import")
2566 + # What the zip-in-memory import above made of one file.
2567 + @old_files %{
2568 + 'agency.txt' => {:agency, 3},
2569 + 'calendar.txt' => {:calendar, 4},
2570 + 'directions.txt' => {:directions, 5},
2571 + 'routes.txt' => {:routes, 6},
2572 + 'stops.txt' => {:stops, 7},
2573 + 'trips.txt' => {:trips, 9}
2574 + }
2662 2575
2663 − Configuration.update_source_meta(cfg, %{last_run: DateTime.utc_now()})
2576 + defp import_old_file('stop_times.txt', data, id) do
2577 + log_old_datum(data)
2578 + write_file(data, :stop_times, id, via: :copy)
2579 + bcast(id, :stop_times, 8, 9)
2580 + end
2664 2581
2665 − {:error, _info} ->
2666 − bcast(state.id, :error, 1, 1)
2667 − Logger.info("GTFS::#{state.id} Got invalid zip file #{_info.reason}")
2668 − end
2582 + defp import_old_file(name, data, id) do
2583 + as_csv = log_old_datum(data)
2669 2584
2670 − {:error, info} ->
2671 − Logger.info(info.reason)
2672 − end
2585 + case Map.fetch(@old_files, name) do
2586 + {:ok, {type, step}} ->
2587 + write_file(as_csv, type, id)
2588 + bcast(id, type, step, 9)
2673 2589
2674 − false ->
2675 − bcast(state.id, :disabled)
2676 − {:noreply, state}
2590 + :error ->
2591 + :ok
2677 2592 end
2593 + end
2678 2594
2679 − {:noreply, state}
2595 + defp log_old_datum(data) do
2596 + as_csv =
2597 + data
2598 + |> String.split("\n")
2599 + |> Enum.map(&replace/1)
2600 + |> Enum.filter(fn x -> x != "" end)
2601 + |> XP.parse_stream()
2602 +
2603 + Logger.info({"datum", as_csv})
2604 + as_csv
2680 2605 end
2681 2606 end
2682 2607
@@ -2826,27 +2751,31 @@ defmodule RoomGtfs.Debug do
2826 2751 IO.puts("rt_tu is nil — no successful fetch yet")
2827 2752
2828 2753 feed ->
2829 − entities = feed.entity |> Enum.filter(& &1.trip_update)
2830 −
2831 − entities =
2832 − if stop_id do
2833 − entities
2834 − |> Enum.filter(fn e ->
2835 − Enum.any?(e.trip_update.stop_time_update, &(&1.stop_id == stop_id))
2836 − end)
2837 − else
2838 − entities
2839 − end
2840 −
2841 − IO.puts(
2842 − "#{length(entities)} trip_update entities#{if stop_id, do: " for stop #{stop_id}", else: ""}:"
2843 − )
2844 −
2845 − entities |> show(limit: :infinity)
2754 + dump_feed(feed, stop_id)
2846 2755 end
2847 2756 end
2848 2757 end
2849 2758
2759 + defp dump_feed(feed, stop_id) do
2760 + entities = feed.entity |> Enum.filter(& &1.trip_update)
2761 +
2762 + entities =
2763 + if stop_id do
2764 + entities
2765 + |> Enum.filter(fn e ->
2766 + Enum.any?(e.trip_update.stop_time_update, &(&1.stop_id == stop_id))
2767 + end)
2768 + else
2769 + entities
2770 + end
2771 +
2772 + IO.puts(
2773 + "#{length(entities)} trip_update entities#{if stop_id, do: " for stop #{stop_id}", else: ""}:"
2774 + )
2775 +
2776 + entities |> show(limit: :infinity)
2777 + end
2778 +
2850 2779 @doc """
2851 2780 Diagnose why live times aren't appearing for a stop.
2852 2781 Compares static trip IDs against RT feed trip IDs and stop_id formats,

Parents: f1dab56