aprs.me/lib/aprsme/redis_cache.ex
Graham McIntire 226a53bf61
Add Redis-based distributed caching and rate limiting
This commit introduces a dual-mode caching and rate limiting system that can use
either Redis (for distributed deployments) or ETS/Cachex (for single-node setups).

Key changes:
- Add Cache abstraction layer that automatically switches between Redis and Cachex
- Implement RedisCache module with full distributed cache functionality
- Implement RedisRateLimiter with sliding window algorithm for accurate rate limiting
- Add RateLimiterWrapper to provide unified API for both implementations
- Update application startup to conditionally use Redis when REDIS_URL is set
- Migrate all cache operations to use the new Cache abstraction
- Support graceful fallback to ETS-based solutions when Redis is unavailable

The system automatically detects Redis availability via REDIS_URL environment
variable and switches between implementations without code changes. This enables
proper distributed caching and rate limiting in Kubernetes deployments while
maintaining backward compatibility for development environments.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-26 15:29:38 -05:00

253 lines
5.8 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
@redis_pool_size 5
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(),
pool_size: @redis_pool_size}
]
%{
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]) 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]) 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]) 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]) 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])) 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]) 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]) 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"]) 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