aprs.me/lib/aprsme/redis_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

251 lines
6 KiB
Elixir

defmodule Aprsme.RedisCache do
@moduledoc """
Redis-backed distributed cache implementation.
Provides a similar API to Cachex but uses Redis for distributed caching.
"""
require Logger
# 5 minutes in seconds
@default_ttl 300
# 5 seconds timeout for Redis operations
@redis_timeout 5000
def child_spec(opts) do
name = Keyword.fetch!(opts, :name)
children = [
{Redix, name: redis_name(name), host: redis_host(), port: redis_port(), password: redis_password()}
]
%{
id: {__MODULE__, name},
type: :supervisor,
start: {Supervisor, :start_link, [children, [strategy: :one_for_one, name: :"#{name}_redis_supervisor"]]}
}
end
@doc """
Get a value from the cache
"""
def get(cache_name, key) do
redis_key = make_redis_key(cache_name, key)
case Redix.command(redis_name(cache_name), ["GET", redis_key], timeout: @redis_timeout) do
{:ok, nil} ->
{:ok, nil}
{:ok, value} ->
{:ok, deserialize(value)}
{:error, reason} ->
Logger.error("Redis GET error for #{redis_key}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Put a value in the cache with optional TTL
"""
def put(cache_name, key, value, opts \\ []) do
redis_key = make_redis_key(cache_name, key)
ttl = Keyword.get(opts, :ttl, @default_ttl)
serialized = serialize(value)
case Redix.command(redis_name(cache_name), ["SETEX", redis_key, ttl, serialized], timeout: @redis_timeout) do
{:ok, "OK"} ->
{:ok, true}
{:error, reason} ->
Logger.error("Redis SETEX error for #{redis_key}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Delete a value from the cache
"""
def del(cache_name, key) do
redis_key = make_redis_key(cache_name, key)
case Redix.command(redis_name(cache_name), ["DEL", redis_key], timeout: @redis_timeout) do
{:ok, _} ->
{:ok, true}
{:error, reason} ->
Logger.error("Redis DEL error for #{redis_key}: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Delete multiple keys matching a pattern
"""
def del_pattern(cache_name, pattern) do
redis_pattern = make_redis_key(cache_name, pattern)
# Use SCAN to find keys matching pattern
case scan_keys(cache_name, redis_pattern) do
{:ok, keys} when keys != [] ->
case Redix.command(redis_name(cache_name), ["DEL" | keys], timeout: @redis_timeout) do
{:ok, count} ->
{:ok, count}
{:error, reason} ->
Logger.error("Redis DEL error for pattern #{redis_pattern}: #{inspect(reason)}")
{:error, reason}
end
{:ok, []} ->
{:ok, 0}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Clear all keys for a cache
"""
def clear(cache_name) do
pattern = make_redis_key(cache_name, "*")
case scan_keys(cache_name, pattern) do
{:ok, keys} when keys != [] ->
case Redix.pipeline(redis_name(cache_name), Enum.map(keys, &["DEL", &1]), timeout: @redis_timeout) do
{:ok, _results} ->
{:ok, true}
{:error, reason} ->
Logger.error("Redis CLEAR error for #{cache_name}: #{inspect(reason)}")
{:error, reason}
end
{:ok, []} ->
{:ok, true}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Get cache statistics
"""
def stats(cache_name) do
pattern = make_redis_key(cache_name, "*")
case scan_keys(cache_name, pattern) do
{:ok, keys} ->
{:ok,
%{
size: length(keys),
keys: keys
}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Check if a key exists
"""
def exists?(cache_name, key) do
redis_key = make_redis_key(cache_name, key)
case Redix.command(redis_name(cache_name), ["EXISTS", redis_key], timeout: @redis_timeout) do
{:ok, 1} -> true
{:ok, 0} -> false
{:error, _} -> false
end
end
@doc """
Get remaining TTL for a key
"""
def ttl(cache_name, key) do
redis_key = make_redis_key(cache_name, key)
case Redix.command(redis_name(cache_name), ["TTL", redis_key], timeout: @redis_timeout) do
# Key doesn't exist
{:ok, -2} -> {:ok, nil}
# Key exists but has no TTL
{:ok, -1} -> {:ok, :infinity}
{:ok, seconds} -> {:ok, seconds}
{:error, reason} -> {:error, reason}
end
end
# Private functions
defp redis_name(cache_name), do: :"#{cache_name}_redis"
defp make_redis_key(cache_name, key) when is_binary(key) do
"aprsme:#{cache_name}:#{key}"
end
defp make_redis_key(cache_name, key) do
"aprsme:#{cache_name}:#{:erlang.phash2(key)}"
end
defp serialize(value) do
:erlang.term_to_binary(value)
end
defp deserialize(binary) when is_binary(binary) do
:erlang.binary_to_term(binary)
rescue
_ -> nil
end
defp scan_keys(cache_name, pattern) do
scan_keys(cache_name, pattern, "0", [])
end
defp scan_keys(cache_name, pattern, cursor, acc) do
case Redix.command(redis_name(cache_name), ["SCAN", cursor, "MATCH", pattern, "COUNT", "100"],
timeout: @redis_timeout
) do
{:ok, [new_cursor, keys]} ->
new_acc = acc ++ keys
if new_cursor == "0" do
{:ok, new_acc}
else
scan_keys(cache_name, pattern, new_cursor, new_acc)
end
{:error, reason} ->
{:error, reason}
end
end
defp redis_host do
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
uri = URI.parse(redis_url)
uri.host || "localhost"
end
defp redis_port do
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
uri = URI.parse(redis_url)
uri.port || 6379
end
defp redis_password do
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
uri = URI.parse(redis_url)
case uri.userinfo do
nil ->
nil
userinfo ->
case String.split(userinfo, ":") do
[_, password] -> password
_ -> nil
end
end
end
end