From af2ab3c9698e57b12b6344ac164d532d3f150d81 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 26 Apr 2026 09:25:04 -0500 Subject: [PATCH] fix(rover): bound Overpass with hard 8s deadline + 6h ETS cache by bbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/microwaveprop/rover/road_proximity.ex | 55 ++++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/lib/microwaveprop/rover/road_proximity.ex b/lib/microwaveprop/rover/road_proximity.ex index a2ad784e..d579b900 100644 --- a/lib/microwaveprop/rover/road_proximity.ex +++ b/lib/microwaveprop/rover/road_proximity.ex @@ -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 ->