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>
229 lines
6.2 KiB
Elixir
229 lines
6.2 KiB
Elixir
defmodule Aprsme.CachedQueries do
|
|
@moduledoc """
|
|
Caching layer for database queries to improve performance
|
|
"""
|
|
|
|
alias Aprsme.Cache
|
|
alias Aprsme.Packet
|
|
alias Aprsme.Packets
|
|
alias Aprsme.Repo
|
|
alias Ecto.Adapters.SQL
|
|
|
|
# 1 minute for frequently changing data
|
|
@cache_ttl_short Cache.to_timeout(minute: 1)
|
|
# 1 minute for moderately changing data
|
|
@cache_ttl_medium Cache.to_timeout(minute: 1)
|
|
|
|
@doc """
|
|
Get recent packets with caching
|
|
"""
|
|
def get_recent_packets_cached(opts) do
|
|
cache_key = generate_cache_key("recent_packets", opts)
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
result = Packets.get_recent_packets_optimized(opts)
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_short)
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get weather packets with caching
|
|
"""
|
|
def get_weather_packets_cached(callsign, start_time, end_time, opts) do
|
|
cache_key = generate_cache_key("weather", {callsign, start_time, end_time, opts})
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
result = Packets.get_weather_packets(callsign, start_time, end_time, opts)
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium)
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get packet count with caching.
|
|
Now uses the efficient packet_counters table for O(1) performance.
|
|
"""
|
|
def get_total_packet_count_cached do
|
|
cache_key = "total_packet_count"
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
# This is now extremely fast due to the counter table
|
|
result = Packets.get_total_packet_count()
|
|
# Cache for only 5 seconds since the query is now instant
|
|
Cache.put(:query_cache, cache_key, result, ttl: Cache.to_timeout(second: 5))
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get latest packet for callsign with caching
|
|
"""
|
|
def get_latest_packet_for_callsign_cached(callsign) do
|
|
cache_key = generate_cache_key("latest_packet", callsign)
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
result = Packets.get_latest_packet_for_callsign(callsign)
|
|
# Shorter TTL for latest packets as they change frequently
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_short)
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get latest weather packet for callsign with caching.
|
|
Uses the optimized query that checks recent data first.
|
|
"""
|
|
def get_latest_weather_packet_cached(callsign) do
|
|
cache_key = generate_cache_key("latest_weather_packet", callsign)
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
result = Packets.get_latest_weather_packet(callsign)
|
|
# Cache for 5 minutes since weather updates are less frequent
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_short)
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Check if a callsign has weather packets with caching.
|
|
Uses exact match for performance.
|
|
"""
|
|
def has_weather_packets_cached?(callsign) do
|
|
cache_key = generate_cache_key("has_weather_packets", callsign)
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
# Use exact match with proper index instead of ilike
|
|
import Ecto.Query
|
|
|
|
query =
|
|
from p in Packet,
|
|
where: p.sender == ^callsign,
|
|
where:
|
|
not is_nil(p.temperature) or not is_nil(p.humidity) or not is_nil(p.pressure) or
|
|
not is_nil(p.wind_speed) or not is_nil(p.wind_direction) or not is_nil(p.rain_1h),
|
|
limit: 1,
|
|
select: true
|
|
|
|
result = Repo.exists?(query)
|
|
# Cache for 15 minutes
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium)
|
|
result
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Invalidate cache entries for a specific callsign
|
|
"""
|
|
def invalidate_callsign_cache(callsign) do
|
|
# Pattern-based cache invalidation
|
|
patterns = [
|
|
"latest_packet:#{callsign}",
|
|
"latest_weather_packet:#{callsign}",
|
|
"has_weather_packets:#{callsign}",
|
|
"weather:#{callsign}:*"
|
|
]
|
|
|
|
Enum.each(patterns, fn pattern ->
|
|
Cache.del(:query_cache, pattern)
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Invalidate all cached queries
|
|
"""
|
|
def invalidate_all_cache do
|
|
Cache.clear(:query_cache)
|
|
end
|
|
|
|
@doc """
|
|
Get cache statistics
|
|
"""
|
|
def get_cache_stats do
|
|
{:ok, stats} = Cache.stats(:query_cache)
|
|
stats
|
|
end
|
|
|
|
@doc """
|
|
Get path station positions with caching
|
|
"""
|
|
def get_path_station_positions_cached(callsigns) when is_list(callsigns) do
|
|
cache_key = generate_cache_key("path_stations", Enum.sort(callsigns))
|
|
|
|
case Cache.get(:query_cache, cache_key) do
|
|
{:ok, result} when not is_nil(result) ->
|
|
result
|
|
|
|
_ ->
|
|
query = """
|
|
WITH latest_positions AS (
|
|
SELECT DISTINCT ON (sender)
|
|
sender,
|
|
lat,
|
|
lon,
|
|
received_at
|
|
FROM packets
|
|
WHERE
|
|
sender = ANY($1::text[])
|
|
AND lat IS NOT NULL
|
|
AND lon IS NOT NULL
|
|
AND received_at > NOW() - INTERVAL '7 days'
|
|
ORDER BY sender, received_at DESC
|
|
)
|
|
SELECT sender, lat, lon
|
|
FROM latest_positions
|
|
ORDER BY array_position($1::text[], sender)
|
|
"""
|
|
|
|
result =
|
|
case SQL.query(Repo, query, [callsigns]) do
|
|
{:ok, %{rows: rows}} ->
|
|
Enum.map(rows, fn [callsign, lat, lon] ->
|
|
%{
|
|
callsign: callsign,
|
|
lat: Decimal.to_float(lat),
|
|
lng: Decimal.to_float(lon)
|
|
}
|
|
end)
|
|
|
|
{:error, _} ->
|
|
[]
|
|
end
|
|
|
|
# Cache for 5 minutes
|
|
Cache.put(:query_cache, cache_key, result, ttl: @cache_ttl_medium)
|
|
result
|
|
end
|
|
end
|
|
|
|
# Private helper functions
|
|
|
|
defp generate_cache_key(prefix, data) do
|
|
hash = :erlang.phash2(data)
|
|
"#{prefix}:#{hash}"
|
|
end
|
|
end
|