diff --git a/lib/aprsme/application.ex b/lib/aprsme/application.ex index 1a1ab60..70ea461 100644 --- a/lib/aprsme/application.ex +++ b/lib/aprsme/application.ex @@ -197,6 +197,7 @@ defmodule Aprsme.Application do _ = :ets.new(:query_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) _ = :ets.new(:device_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) _ = :ets.new(:symbol_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) + _ = :ets.new(:weather_cache, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) _ = :ets.new(:aprsme, [:set, :public, :named_table, read_concurrency: true, write_concurrency: true]) _ = :ets.insert(:aprsme, {:message_number, 0}) diff --git a/lib/aprsme/weather_cache.ex b/lib/aprsme/weather_cache.ex new file mode 100644 index 0000000..be53c09 --- /dev/null +++ b/lib/aprsme/weather_cache.ex @@ -0,0 +1,93 @@ +defmodule Aprsme.WeatherCache do + @moduledoc """ + Persistent ETS-based cache for weather callsign lookups. + + Eliminates N+1 queries for `has_weather_packets?` checks during map + marker rendering. Entries expire after `@ttl_ms` to stay reasonably + current without per-callsign DB queries. + """ + @cache_name :weather_cache + @ttl_ms to_timeout(minute: 5) + + @doc """ + Create the ETS table if it doesn't exist yet. + The table is typically created in application.ex alongside other ETS tables. + """ + def setup do + _ = + case :ets.info(@cache_name) do + :undefined -> + :ets.new(@cache_name, [ + :named_table, + :public, + :set, + :protected, + read_concurrency: true, + write_concurrency: true + ]) + + _ -> + :ok + end + + :ok + end + + @doc """ + Batch-populate the cache for the given list of callsigns. + """ + def cache_weather_callsigns(callsigns) when is_list(callsigns) do + weather_set = Aprsme.Packets.weather_callsigns(callsigns) + expires_at = System.monotonic_time(:millisecond) + @ttl_ms + + Enum.each(callsigns, fn cs -> + key = String.upcase(String.trim(cs)) + has_weather = MapSet.member?(weather_set, key) + :ets.insert(@cache_name, {key, has_weather, expires_at}) + end) + + :ok + rescue + _ -> :ok + end + + @doc """ + Check if a callsign has weather packets. + Returns `:unknown` if not cached, `true`/`false` if cached. + """ + def weather_callsign?(callsign) when is_binary(callsign) do + key = String.upcase(String.trim(callsign)) + + case :ets.lookup(@cache_name, key) do + [{^key, value, expires_at}] -> + if System.monotonic_time(:millisecond) < expires_at do + value + else + :ets.delete(@cache_name, key) + :unknown + end + + _ -> + :unknown + end + end + + @doc """ + Evict expired entries. + """ + def evict_expired do + now = System.monotonic_time(:millisecond) + + @cache_name + |> :ets.tab2list() + |> Enum.each(fn + {key, _value, expires_at} when expires_at < now -> + :ets.delete(@cache_name, key) + + _ -> + :ok + end) + + :ok + end +end