Packages

phoenix_kit

1.5.1
1.7.207 1.7.206 1.7.205 1.7.204 1.7.203 1.7.202 1.7.201 1.7.200 1.7.199 1.7.198 1.7.197 1.7.196 1.7.194 1.7.193 1.7.192 1.7.191 1.7.190 1.7.189 1.7.187 1.7.186 1.7.185 1.7.184 1.7.183 1.7.182 1.7.181 1.7.180 1.7.179 1.7.178 1.7.177 1.7.176 1.7.175 1.7.174 1.7.173 1.7.172 1.7.171 1.7.170 1.7.169 1.7.168 1.7.167 1.7.166 1.7.165 1.7.164 1.7.162 1.7.161 1.7.160 1.7.159 1.7.157 1.7.156 1.7.155 1.7.154 1.7.153 1.7.152 1.7.151 1.7.150 1.7.149 1.7.146 1.7.145 1.7.144 1.7.143 1.7.138 1.7.133 1.7.132 1.7.131 1.7.130 1.7.128 1.7.126 1.7.125 1.7.121 1.7.120 1.7.119 1.7.118 1.7.117 1.7.116 1.7.115 1.7.114 1.7.113 1.7.112 1.7.111 1.7.110 1.7.109 1.7.108 1.7.107 1.7.106 1.7.105 1.7.104 1.7.103 1.7.102 1.7.101 1.7.100 1.7.99 1.7.98 1.7.97 1.7.96 1.7.95 1.7.94 1.7.93 1.7.92 1.7.91 1.7.90 1.7.89 1.7.88 1.7.87 1.7.86 1.7.85 1.7.84 1.7.83 1.7.82 1.7.81 1.7.80 1.7.79 1.7.78 1.7.77 1.7.76 1.7.75 1.7.74 1.7.71 1.7.70 1.7.69 1.7.66 1.7.65 1.7.64 1.7.63 1.7.62 1.7.61 1.7.59 1.7.58 1.7.57 1.7.56 1.7.55 1.7.54 1.7.53 1.7.52 1.7.51 1.7.49 1.7.44 1.7.43 1.7.42 1.7.41 1.7.39 1.7.38 1.7.37 1.7.36 1.7.34 1.7.33 1.7.31 1.7.30 1.7.29 1.7.28 1.7.27 1.7.26 1.7.25 1.7.24 1.7.23 1.7.22 1.7.21 1.7.20 1.7.19 1.7.18 1.7.17 1.7.16 1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 1.7.10 1.7.9 1.7.8 1.7.7 1.7.6 1.7.5 1.7.4 1.7.3 1.7.2 1.7.1 1.7.0 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.6.5 1.6.4 1.6.3 1.5.2 1.5.1 1.5.0 1.4.9 1.4.8 1.4.7 1.4.6 1.4.5 1.4.4 1.4.3 1.4.2 1.4.1 1.4.0 1.3.2 1.3.1 1.3.0 1.2.10 1.2.9 1.2.8 1.2.7 1.2.5 1.2.4 1.2.2 1.2.1 1.2.0 1.1.0 1.0.0

A foundation for building Elixir Phoenix apps — SaaS, social networks, ERP systems, marketplaces, and more

Current section

Files

Jump to
phoenix_kit lib phoenix_kit cache cache.ex
Raw

lib/phoenix_kit/cache/cache.ex

defmodule PhoenixKit.Cache do
@moduledoc """
Generic caching system for PhoenixKit with ETS-backed storage.
This module provides a flexible caching foundation that can be used for:
- Settings caching
- User roles and permissions
- Module configurations
- Any frequently accessed data
## Features
- ETS-backed storage for high-performance lookups
- Automatic cache warming
- TTL support for expiring data
- Statistics tracking
- Robust fallback mechanisms
- Multiple cache instances via registry
## Usage
# Start a cache instance
{:ok, pid} = PhoenixKit.Cache.start_link(name: :my_cache, warmer: &MyApp.load_data/0)
# Basic operations
PhoenixKit.Cache.put(:my_cache, "key", "value")
PhoenixKit.Cache.get(:my_cache, "key", "default")
PhoenixKit.Cache.invalidate(:my_cache, "key")
# Batch operations
PhoenixKit.Cache.get_multiple(:my_cache, ["key1", "key2"], %{"key1" => "default1"})
PhoenixKit.Cache.invalidate_multiple(:my_cache, ["key1", "key2"])
## Configuration
Cache instances can be configured with:
- `:name` - Unique name for the cache instance
- `:warmer` - Function to warm the cache on startup
- `:ttl` - Time-to-live for cache entries (optional)
- `:max_size` - Maximum number of entries (optional)
"""
use GenServer
require Logger
@type cache_name :: atom()
@type cache_key :: any()
@type cache_value :: any()
@type default_value :: any()
@type warmer_fun :: (-> map() | nil)
@type options :: [
name: cache_name(),
warmer: warmer_fun(),
ttl: pos_integer() | nil,
max_size: pos_integer() | nil
]
defstruct [
:name,
:table,
:warmer,
:ttl,
:max_size,
stats: %{
hits: 0,
misses: 0,
puts: 0,
invalidations: 0
}
]
@doc """
Starts a new cache instance.
## Options
- `:name` - Required. Unique name for the cache instance
- `:warmer` - Optional. Function to warm the cache on startup
- `:ttl` - Optional. Time-to-live for cache entries in milliseconds
- `:max_size` - Optional. Maximum number of entries before eviction
## Examples
{:ok, pid} = PhoenixKit.Cache.start_link(name: :settings)
{:ok, pid} = PhoenixKit.Cache.start_link(name: :user_roles, warmer: &MyApp.load_user_roles/0)
"""
@spec start_link(options()) :: GenServer.on_start()
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
GenServer.start_link(__MODULE__, opts, name: via_tuple(name))
end
@doc """
Gets a value from the cache.
Returns the default value if the key is not found or the cache is unavailable.
## Examples
PhoenixKit.Cache.get(:settings, "date_format", "Y-m-d")
PhoenixKit.Cache.get(:user_roles, user_id, [])
"""
@spec get(cache_name(), cache_key(), default_value()) :: cache_value()
def get(cache_name, key, default \\ nil) do
GenServer.call(via_tuple(cache_name), {:get, key, default}, 5000)
rescue
error in [ArgumentError, RuntimeError] ->
# Only log if not during compilation (when registry doesn't exist)
unless compilation_or_test_mode?() do
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
end
default
catch
:exit, {:timeout, _} ->
Logger.warning("Cache #{cache_name} timeout")
default
:exit, {:noproc, _} ->
# Only log if not during compilation
unless compilation_or_test_mode?() do
Logger.warning("Cache #{cache_name} not started")
end
default
end
@doc """
Gets multiple values from the cache.
Returns a map with the requested keys and their values, using defaults for missing keys.
## Examples
defaults = %{"date_format" => "Y-m-d", "time_format" => "H:i"}
PhoenixKit.Cache.get_multiple(:settings, ["date_format", "time_format"], defaults)
"""
@spec get_multiple(cache_name(), [cache_key()], map()) :: map()
def get_multiple(cache_name, keys, defaults \\ %{}) do
GenServer.call(via_tuple(cache_name), {:get_multiple, keys, defaults}, 5000)
rescue
error in [ArgumentError, RuntimeError] ->
unless compilation_or_test_mode?() do
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
end
defaults
catch
:exit, {:timeout, _} ->
Logger.warning("Cache #{cache_name} timeout")
defaults
:exit, {:noproc, _} ->
unless compilation_or_test_mode?() do
Logger.warning("Cache #{cache_name} not started")
end
defaults
end
@doc """
Puts a value in the cache.
## Examples
PhoenixKit.Cache.put(:settings, "date_format", "m/d/Y")
PhoenixKit.Cache.put(:user_roles, user_id, ["admin", "user"])
"""
@spec put(cache_name(), cache_key(), cache_value()) :: :ok
def put(cache_name, key, value) do
GenServer.cast(via_tuple(cache_name), {:put, key, value})
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
@doc """
Puts multiple values in the cache.
## Examples
PhoenixKit.Cache.put_multiple(:settings, %{"date_format" => "m/d/Y", "time_format" => "h:i A"})
"""
@spec put_multiple(cache_name(), map()) :: :ok
def put_multiple(cache_name, key_values) do
GenServer.cast(via_tuple(cache_name), {:put_multiple, key_values})
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
@doc """
Invalidates a key in the cache.
## Examples
PhoenixKit.Cache.invalidate(:settings, "date_format")
"""
@spec invalidate(cache_name(), cache_key()) :: :ok
def invalidate(cache_name, key) do
GenServer.cast(via_tuple(cache_name), {:invalidate, key})
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
@doc """
Invalidates multiple keys in the cache.
## Examples
PhoenixKit.Cache.invalidate_multiple(:settings, ["date_format", "time_format"])
"""
@spec invalidate_multiple(cache_name(), [cache_key()]) :: :ok
def invalidate_multiple(cache_name, keys) do
GenServer.cast(via_tuple(cache_name), {:invalidate_multiple, keys})
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
@doc """
Clears all entries from the cache.
## Examples
PhoenixKit.Cache.clear(:settings)
"""
@spec clear(cache_name()) :: :ok
def clear(cache_name) do
GenServer.cast(via_tuple(cache_name), :clear)
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
@doc """
Gets cache statistics.
## Examples
PhoenixKit.Cache.stats(:settings)
# => %{hits: 150, misses: 5, puts: 20, invalidations: 3, hit_rate: 0.97}
"""
@spec stats(cache_name()) :: map()
def stats(cache_name) do
GenServer.call(via_tuple(cache_name), :stats, 5000)
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
%{hits: 0, misses: 0, puts: 0, invalidations: 0, hit_rate: 0.0}
catch
:exit, {:timeout, _} ->
Logger.warning("Cache #{cache_name} timeout")
%{hits: 0, misses: 0, puts: 0, invalidations: 0, hit_rate: 0.0}
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
%{hits: 0, misses: 0, puts: 0, invalidations: 0, hit_rate: 0.0}
end
@doc """
Warms the cache using the configured warmer function.
## Examples
PhoenixKit.Cache.warm(:settings)
"""
@spec warm(cache_name()) :: :ok
def warm(cache_name) do
GenServer.cast(via_tuple(cache_name), :warm)
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
:ok
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
:ok
end
# GenServer Callbacks
@impl GenServer
def init(opts) do
name = Keyword.fetch!(opts, :name)
warmer = Keyword.get(opts, :warmer)
ttl = Keyword.get(opts, :ttl)
max_size = Keyword.get(opts, :max_size)
table =
:ets.new(:"cache_#{name}", [:set, :protected, :named_table, {:read_concurrency, true}])
state = %__MODULE__{
name: name,
table: table,
warmer: warmer,
ttl: ttl,
max_size: max_size
}
# Warm cache if warmer function is provided
if warmer do
send(self(), :warm_cache)
end
Logger.info("Started cache #{name} with table #{table}")
{:ok, state}
end
@impl GenServer
def handle_call({:get, key, default}, _from, %{table: table, stats: stats} = state) do
case :ets.lookup(table, key) do
[{^key, value, expires_at}] when is_integer(expires_at) ->
if System.monotonic_time(:millisecond) < expires_at do
new_stats = %{stats | hits: stats.hits + 1}
{:reply, value, %{state | stats: new_stats}}
else
:ets.delete(table, key)
new_stats = %{stats | misses: stats.misses + 1}
{:reply, default, %{state | stats: new_stats}}
end
[{^key, value}] ->
new_stats = %{stats | hits: stats.hits + 1}
{:reply, value, %{state | stats: new_stats}}
[] ->
new_stats = %{stats | misses: stats.misses + 1}
{:reply, default, %{state | stats: new_stats}}
end
end
@impl GenServer
def handle_call({:get_multiple, keys, defaults}, _from, %{table: table, stats: stats} = state) do
{result, hits, misses} =
Enum.reduce(keys, {%{}, 0, 0}, fn key, {acc, hits, misses} ->
case :ets.lookup(table, key) do
[{^key, value, expires_at}] when is_integer(expires_at) ->
if System.monotonic_time(:millisecond) < expires_at do
{Map.put(acc, key, value), hits + 1, misses}
else
:ets.delete(table, key)
default_value = Map.get(defaults, key)
{Map.put(acc, key, default_value), hits, misses + 1}
end
[{^key, value}] ->
{Map.put(acc, key, value), hits + 1, misses}
[] ->
default_value = Map.get(defaults, key)
{Map.put(acc, key, default_value), hits, misses + 1}
end
end)
new_stats = %{stats | hits: stats.hits + hits, misses: stats.misses + misses}
{:reply, result, %{state | stats: new_stats}}
end
@impl GenServer
def handle_call(:stats, _from, %{stats: stats} = state) do
total = stats.hits + stats.misses
hit_rate = if total > 0, do: stats.hits / total, else: 0.0
response = Map.put(stats, :hit_rate, hit_rate)
{:reply, response, state}
end
@impl GenServer
def handle_cast({:put, key, value}, %{table: table, ttl: ttl, stats: stats} = state) do
entry =
if ttl do
{key, value, System.monotonic_time(:millisecond) + ttl}
else
{key, value}
end
:ets.insert(table, entry)
new_stats = %{stats | puts: stats.puts + 1}
{:noreply, maybe_evict(%{state | stats: new_stats})}
end
@impl GenServer
def handle_cast({:put_multiple, key_values}, %{table: table, ttl: ttl, stats: stats} = state) do
entries =
if ttl do
expires_at = System.monotonic_time(:millisecond) + ttl
Enum.map(key_values, fn {key, value} -> {key, value, expires_at} end)
else
Enum.map(key_values, fn {key, value} -> {key, value} end)
end
:ets.insert(table, entries)
new_stats = %{stats | puts: stats.puts + map_size(key_values)}
{:noreply, maybe_evict(%{state | stats: new_stats})}
end
@impl GenServer
def handle_cast({:invalidate, key}, %{table: table, stats: stats} = state) do
:ets.delete(table, key)
new_stats = %{stats | invalidations: stats.invalidations + 1}
{:noreply, %{state | stats: new_stats}}
end
@impl GenServer
def handle_cast({:invalidate_multiple, keys}, %{table: table, stats: stats} = state) do
Enum.each(keys, &:ets.delete(table, &1))
new_stats = %{stats | invalidations: stats.invalidations + length(keys)}
{:noreply, %{state | stats: new_stats}}
end
@impl GenServer
def handle_cast(:clear, %{table: table, stats: stats} = state) do
count = :ets.info(table, :size)
:ets.delete_all_objects(table)
new_stats = %{stats | invalidations: stats.invalidations + count}
{:noreply, %{state | stats: new_stats}}
end
@impl GenServer
def handle_cast(:warm, %{warmer: nil} = state) do
Logger.warning("Cannot warm cache #{state.name}: no warmer function configured")
{:noreply, state}
end
@impl GenServer
def handle_cast(:warm, %{warmer: warmer} = state) do
case safe_warm(warmer) do
{:ok, data} when is_map(data) ->
put_multiple(state.name, data)
Logger.info("Warmed cache #{state.name} with #{map_size(data)} entries")
{:error, error} ->
Logger.warning("Failed to warm cache #{state.name}: #{inspect(error)}")
_ ->
Logger.warning("Warmer for cache #{state.name} returned invalid data (expected map)")
end
{:noreply, state}
end
@impl GenServer
def handle_info(:warm_cache, state) do
handle_cast(:warm, state)
end
# Private Functions
defp via_tuple(name) do
PhoenixKit.Cache.Registry.via_tuple(name)
end
defp safe_warm(warmer) when is_function(warmer, 0) do
{:ok, warmer.()}
rescue
error -> {:error, error}
end
defp maybe_evict(%{max_size: nil} = state), do: state
defp maybe_evict(%{table: table, max_size: max_size} = state) do
current_size = :ets.info(table, :size)
if current_size > max_size do
# Simple FIFO eviction - delete oldest entries
excess = current_size - max_size
:ets.first(table)
|> evict_n_entries(table, excess)
end
state
end
defp evict_n_entries(_key, _table, 0), do: :ok
defp evict_n_entries(:"$end_of_table", _table, _n), do: :ok
defp evict_n_entries(key, table, n) do
next_key = :ets.next(table, key)
:ets.delete(table, key)
evict_n_entries(next_key, table, n - 1)
end
# Check if we're in compilation or test mode where cache infrastructure may not be available
defp compilation_or_test_mode? do
# During compilation, the application environment is not fully loaded
# Check if we're in a context where the registry hasn't been started
case Registry.whereis_name({PhoenixKit.Cache.Registry, :settings}) do
:undefined -> true
pid when is_pid(pid) -> false
end
rescue
# If Registry module isn't available or any error occurs, assume compilation mode
_ -> true
end
end