defmodule Aprsme.DeviceCache do @moduledoc """ Caches device information and provides efficient lookup by identifier with wildcard matching. """ use GenServer alias Aprsme.Cache alias Aprsme.Devices alias Aprsme.Repo @cache_name :device_cache @refresh_interval Cache.to_timeout(day: 1) # Client API @doc """ Starts the DeviceCache GenServer. """ def start_link(_opts) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @doc """ Looks up a device by identifier using wildcard matching. Returns the device struct if found, or nil. """ def lookup_device(nil), do: nil def lookup_device(identifier) when is_binary(identifier) do case Cache.get(@cache_name, :all_devices) do {:ok, nil} -> # Cache miss - trigger async refresh and return nil for now GenServer.cast(__MODULE__, :refresh_cache_async) nil {:ok, devices} when is_list(devices) -> find_matching_device(devices, identifier) _ -> nil end end @doc """ Refreshes the device cache from the database. """ def refresh_cache do case Process.whereis(__MODULE__) do nil -> :ok _pid -> GenServer.call(__MODULE__, :refresh_cache) end end # Server callbacks @impl true def init(_) do # Delay initial load to allow Redis connections to establish Process.send_after(self(), :initial_load, 1_000) {:ok, %{initial_load_done: false}} end @impl true def handle_call(:refresh_cache, _from, state) do result = load_devices_into_cache() {:reply, result, state} end @impl true def handle_cast(:refresh_cache_async, state) do load_devices_into_cache() {:noreply, state} end @impl true def handle_info(:initial_load, state) do load_devices_into_cache() # Schedule periodic refresh Process.send_after(self(), :refresh_cache, @refresh_interval) {:noreply, %{state | initial_load_done: true}} end def handle_info(:refresh_cache, state) do load_devices_into_cache() # Schedule next refresh Process.send_after(self(), :refresh_cache, @refresh_interval) {:noreply, state} end # Private functions defp load_devices_into_cache do require Logger if Application.get_env(:aprsme, :env) == :test do Cache.put(@cache_name, :all_devices, []) :ok else devices = try do Repo.all(Devices) rescue error -> Logger.error("Failed to load devices from database: #{inspect(error)}") [] end case Cache.put(@cache_name, :all_devices, devices) do {:ok, true} -> :ok error -> error end end end defp find_matching_device(devices, identifier) do Enum.find(devices, fn device -> pattern_matches?(device.identifier, identifier) end) end defp pattern_matches?(pattern, identifier) do cond do String.contains?(pattern, "?") -> matches_wildcard_pattern?(pattern, identifier) String.contains?(pattern, "*") -> pattern == identifier true -> pattern == identifier end end defp matches_wildcard_pattern?(pattern, identifier) do regex_pattern = wildcard_pattern_to_regex_string(pattern) case Aprsme.RegexCache.get_or_compile(regex_pattern) do {:ok, regex} -> Regex.match?(regex, identifier) {:error, _} -> false end end # Converts a pattern with ? wildcards to a regex string (for caching) defp wildcard_pattern_to_regex_string(pattern) when is_binary(pattern) do # Replace ? with a placeholder, escape all regex metacharacters except the placeholder, # then replace placeholder with . pattern |> String.replace("?", "__WILDCARD__") # Escape all regex metacharacters |> String.replace(~r/([\\.\+\*\?\[\^\]\$\(\)\{\}=!<>\|:\-])/, "\\\\\\1") |> String.replace("__WILDCARD__", ".") |> then(fn s -> "^" <> s <> "$" end) end end