aprs.me/lib/aprsme/device_cache.ex
Graham McIntire d5781f2581
Fix comprehensive crash prevention and add missing RF path station markers
This commit addresses two major areas:

1. Crash Prevention & Error Handling:
   - Added error handling to all database queries to prevent crashes
   - Implemented circuit breaker pattern for APRS-IS connection
   - Added timeouts to all Redis operations (5 second timeout)
   - Fixed unsafe pattern matches that could cause MatchError crashes
   - Added try/rescue blocks around background tasks
   - Improved socket operation error handling with proper cleanup
   - Enhanced packet cleanup worker with comprehensive error handling

2. RF Path Station Markers:
   - Fixed issue where digipeater/iGate icons weren't shown when searching for callsigns
   - Now loads and displays position markers for all stations in the RF path
   - Extracts RF path stations from tracked callsign packets
   - Fetches latest position for each digipeater/iGate
   - Properly displays complete RF path visualization with station positions

Technical details:
- Added Process.alive? checks before sending messages
- Fixed resource cleanup in terminate functions
- Added proper error handling to File.read operations
- Implemented safe wrapper for ShutdownHandler health checks
- All changes ensure the app continues running even when errors occur

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 11:56:00 -05:00

167 lines
4.1 KiB
Elixir

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
GenServer.call(__MODULE__, :refresh_cache)
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 on startup
case load_devices_into_cache() do
:ok ->
# Schedule periodic refresh
Process.send_after(self(), :refresh_cache, @refresh_interval)
{:noreply, %{state | initial_load_done: true}}
:error ->
# If initial load failed, retry sooner
Process.send_after(self(), :initial_load, 5_000)
{:noreply, state}
end
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
try do
devices =
try do
Repo.all(Devices)
rescue
error ->
Logger.error("Failed to load devices from database: #{inspect(error)}")
[]
end
# Store all devices in cache
case Cache.put(@cache_name, :all_devices, devices) do
{:ok, true} -> :ok
error -> error
end
rescue
error in [Postgrex.Error, DBConnection.ConnectionError] ->
# Handle case where database or table doesn't exist yet
Logger.warning("Failed to load devices: #{inspect(error)}. Will retry later.")
# Store empty list for now
Cache.put(@cache_name, :all_devices, [])
:error
end
end
defp find_matching_device(devices, identifier) do
Enum.find(devices, fn device ->
pattern = device.identifier
cond do
String.contains?(pattern, "?") ->
try do
regex = wildcard_pattern_to_regex(pattern)
Regex.match?(regex, identifier)
rescue
_e in Regex.CompileError ->
false
end
String.contains?(pattern, "*") ->
# Compare literally if pattern contains * but not ?
pattern == identifier
true ->
pattern == identifier
end
end)
end
# Converts a pattern with ? wildcards to a regex
defp wildcard_pattern_to_regex(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 ->
regex = "^" <> s <> "$"
~r/#{regex}/
end)
end
end