Packages
phoenix_kit
1.7.54
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
Current section
Files
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_uuid, [])
"""
@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_uuid, ["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 """
Clears all cache entries whose keys start with the given prefix.
Returns the count of entries cleared.
## Examples
PhoenixKit.Cache.clear_by_prefix(:publishing_posts, "v1:blog_post:my-blog:")
# => {:ok, 15}
"""
@spec clear_by_prefix(cache_name(), String.t()) :: {:ok, non_neg_integer()} | {:error, any()}
def clear_by_prefix(cache_name, prefix) do
GenServer.call(via_tuple(cache_name), {:clear_by_prefix, prefix}, 10_000)
rescue
error in [ArgumentError, RuntimeError] ->
Logger.warning("Cache #{cache_name} unavailable: #{inspect(error)}")
{:ok, 0}
catch
:exit, {:noproc, _} ->
Logger.warning("Cache #{cache_name} not started")
{:ok, 0}
:exit, {:timeout, _} ->
Logger.warning("Cache #{cache_name} timeout during clear_by_prefix")
{:error, :timeout}
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)
critical_warmer = Keyword.get(opts, :critical_warmer)
sync_init = Keyword.get(opts, :sync_init, false)
ttl = Keyword.get(opts, :ttl)
max_size = Keyword.get(opts, :max_size)
table =
:ets.new(:"cache_#{name}", [:set, :protected, :named_table, {:read_concurrency, true}])
# Support legacy cache_settings table for backwards compatibility
if name == :settings do
try do
:ets.new(:cache_settings, [:set, :protected, :named_table, {:read_concurrency, true}])
rescue
# Table already exists
ArgumentError -> :ok
end
end
state = %__MODULE__{
name: name,
table: table,
warmer: warmer,
ttl: ttl,
max_size: max_size
}
Logger.info(
"Started cache #{name} with table #{table}#{if sync_init, do: " (sync_init enabled)", else: ""}"
)
cond do
sync_init and critical_warmer ->
# Warm via handle_continue (legacy path kept for compatibility)
{:ok, state, {:continue, {:warm_critical, critical_warmer, warmer}}}
sync_init and warmer ->
do_sync_warm(state, warmer)
{:ok, state}
warmer ->
send(self(), :warm_cache)
{:ok, state}
true ->
{:ok, state}
end
end
# Attempt synchronous warming with a 5-second cap so we don't block the supervisor
# indefinitely when the DB is under heavy load (e.g. during mix phoenix_kit.update
# while the production app is already holding connections).
# On success: downstream GenServers (Dashboard.Registry) start with a warm cache.
# On timeout/failure: start empty and schedule an async retry after 5 s, giving
# the DB time to shed load before we try again.
defp do_sync_warm(%{name: name} = state, warmer) do
task = Task.async(fn -> safe_warm(warmer) end)
case Task.yield(task, 5_000) do
{:ok, {:ok, data}} when is_map(data) and map_size(data) > 0 ->
warm_critical_data(state, data)
Logger.info(
"Synchronously warmed cache #{name} with #{map_size(data)} entries (sync_init)"
)
{:ok, _} ->
Logger.warning(
"Cache #{name} sync_init: warmer returned empty data, retrying async in 5 s"
)
Process.send_after(self(), :warm_cache, 5_000)
nil ->
# Task is still running (DB blocked); kill it and retry async
Task.shutdown(task, :brutal_kill)
Logger.warning(
"Cache #{name} sync_init: warming timed out (DB busy?), retrying async in 5 s"
)
Process.send_after(self(), :warm_cache, 5_000)
end
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_call({:clear_by_prefix, prefix}, _from, %{table: table, stats: stats} = state) do
# Find all keys matching the prefix and delete them
matching_keys =
:ets.foldl(
fn {key, _value, _expires_at}, acc ->
if is_binary(key) and String.starts_with?(key, prefix) do
[key | acc]
else
acc
end
end,
[],
table
)
# Delete matching entries
Enum.each(matching_keys, fn key -> :ets.delete(table, key) end)
count = length(matching_keys)
new_stats = %{stats | invalidations: stats.invalidations + count}
{:reply, {:ok, count}, %{state | stats: new_stats}}
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) and map_size(data) > 0 ->
put_multiple(state.name, data)
Logger.info("Warmed cache #{state.name} with #{map_size(data)} entries")
{:ok, _empty} ->
# Warmer ran but returned nothing — DB might not be ready yet. Retry in 10 s.
Logger.warning("Cache #{state.name}: warmer returned empty data, retrying in 10 s")
Process.send_after(self(), :warm_cache, 10_000)
{:error, error} ->
# DB error (timeout, connection refused, etc.). Retry in 10 s.
Logger.warning(
"Cache #{state.name}: warming failed (#{inspect(error)}), retrying in 10 s"
)
Process.send_after(self(), :warm_cache, 10_000)
end
{:noreply, state}
end
@impl GenServer
def handle_continue({:warm_critical, _critical_warmer, warmer}, %{name: name} = state) do
# Load ALL data synchronously in handle_continue when sync_init is enabled
# This runs after init returns, so supervisor can continue starting other processes
# We load all data instead of just critical to avoid race conditions
# Retry warming if data is empty (repo might not be ready yet)
case warm_with_retry(warmer, name, 3, 100) do
{:ok, data} when is_map(data) and map_size(data) > 0 ->
warm_critical_data(state, data)
Logger.info(
"Synchronously warmed cache #{name} with #{map_size(data)} entries (sync_init mode)"
)
{:ok, empty_data} when is_map(empty_data) ->
Logger.warning(
"Cache #{name} warmed but no data loaded - repository may not be ready yet. Will retry asynchronously."
)
# Schedule async retry
Process.send_after(self(), :warm_cache, 1000)
{:error, error} ->
Logger.error("Failed to synchronously warm cache #{name}: #{inspect(error)}")
# Schedule async retry
Process.send_after(self(), :warm_cache, 1000)
_ ->
Logger.warning("Warmer for cache #{name} returned invalid data")
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 warm_critical_data(%{name: name, table: table, ttl: ttl}, data) do
Enum.each(data, fn {key, value} ->
# Use 2-tuple when no TTL (matches handle_call pattern [{^key, value}])
# Use 3-tuple only when TTL is set (matches [{^key, value, expires_at}] when is_integer)
entry =
if ttl do
{key, value, System.monotonic_time(:millisecond) + ttl}
else
{key, value}
end
:ets.insert(table, entry)
# Also write to legacy cache_settings table if this is settings cache
if name == :settings do
:ets.insert(:cache_settings, entry)
end
end)
end
defp safe_warm(warmer) when is_function(warmer, 0) do
{:ok, warmer.()}
rescue
error -> {:error, error}
end
defp warm_with_retry(warmer, name, retries, delay) do
case safe_warm(warmer) do
{:ok, data} when is_map(data) and map_size(data) > 0 ->
{:ok, data}
{:ok, empty_data} when is_map(empty_data) and retries > 0 ->
Logger.debug(
"Cache #{name} warming returned empty data, retrying in #{delay}ms (#{retries} retries left)"
)
Process.sleep(delay)
warm_with_retry(warmer, name, retries - 1, delay * 2)
result ->
result
end
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