fix(rover): bound Overpass with hard 8s deadline + 6h ETS cache by bbox

Prod logs showed road_proximity took 186 s on a single Calculate.
Req's receive_timeout only catches TCP idle, not slow streaming, so a
trickling Overpass response could not be cancelled. Wrap the call in a
Task.yield/shutdown deadline and cache successful results by 0.05°-rounded
bbox so adjacent Calculates skip Overpass entirely.
This commit is contained in:
Graham McIntire 2026-04-26 09:25:04 -05:00
parent 6a79458ea9
commit af2ab3c969
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

@ -12,12 +12,16 @@ defmodule Microwaveprop.Rover.RoadProximity do
`:unavailable` and the scorer falls back to ignoring road proximity.
"""
alias Microwaveprop.Cache
require Logger
@overpass_url "https://overpass-api.de/api/interpreter"
@road_types ~w(motorway trunk primary secondary tertiary unclassified residential)
@overpass_query_timeout_s 10
@overpass_receive_timeout_ms 12_000
@overpass_query_timeout_s 6
@overpass_receive_timeout_ms 8_000
@overpass_hard_deadline_ms 8_000
@cache_ttl_ms 6 * 60 * 60 * 1_000
@type latlon :: {float(), float()}
@type segment :: {latlon(), latlon()}
@ -42,7 +46,21 @@ defmodule Microwaveprop.Rover.RoadProximity do
end
@spec fetch_segments(bbox()) :: {:ok, [segment()]} | {:error, term()}
def fetch_segments(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
def fetch_segments(%{"south" => _, "west" => _, "north" => _, "east" => _} = bbox) do
key = {:rover_road_segments, cache_key(bbox)}
case Cache.fetch_or_store(key, @cache_ttl_ms, fn -> do_fetch_segments(bbox) end) do
{:ok, _segments} = ok ->
ok
{:error, _} = err ->
# Don't poison the cache with errors — subsequent Calculates retry.
Cache.invalidate(key)
err
end
end
defp do_fetch_segments(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
types = Enum.join(@road_types, "|")
query = """
@ -51,22 +69,37 @@ defmodule Microwaveprop.Rover.RoadProximity do
out geom;
"""
case Req.post(@overpass_url,
form: [data: query],
receive_timeout: @overpass_receive_timeout_ms,
retry: false
) do
{:ok, %{status: 200, body: %{"elements" => elements}}} ->
task =
Task.async(fn ->
Req.post(@overpass_url,
form: [data: query],
receive_timeout: @overpass_receive_timeout_ms,
retry: false
)
end)
case Task.yield(task, @overpass_hard_deadline_ms) || Task.shutdown(task, :brutal_kill) do
{:ok, {:ok, %{status: 200, body: %{"elements" => elements}}}} ->
{:ok, segments_from_elements(elements)}
{:ok, %{status: status}} ->
{:ok, {:ok, %{status: status}}} ->
{:error, {:http, status}}
{:error, reason} ->
{:ok, {:error, reason}} ->
{:error, reason}
nil ->
{:error, :overpass_deadline_exceeded}
end
end
# Round to 0.05° (~5 km) so adjacent Calculate calls share cache hits.
defp cache_key(%{"south" => s, "west" => w, "north" => n, "east" => e}) do
{round_to(s, 0.05), round_to(w, 0.05), round_to(n, 0.05), round_to(e, 0.05)}
end
defp round_to(v, step), do: Float.round(v / step) * step
defp segments_from_elements(elements) do
Enum.flat_map(elements, fn
%{"type" => "way", "geometry" => geom} when is_list(geom) and length(geom) >= 2 ->