Packages

A distributed cache library for Elixir with pluggable backends, topologies, and near-cache support.

Current section

Files

Jump to
vela_cache lib vela cache.ex
Raw

lib/vela/cache.ex

defmodule Vela.Cache do
@moduledoc """
Public API for Vela caches.
## Usage
defmodule MyApp.Cache do
use Vela.Cache,
backend: Vela.Backend.ETS,
default_ttl: :timer.minutes(5)
end
# Add to your supervision tree:
children = [MyApp.Cache]
# Use it:
MyApp.Cache.put(:greeting, "hello")
MyApp.Cache.get(:greeting) # => {:ok, "hello"}
"""
alias Vela.Cache.Entry
# ---- The `use` macro ----
defmacro __using__(opts) do
quote do
@vela_opts unquote(opts)
def child_spec(runtime_opts \\ []) do
opts = Keyword.merge(@vela_opts, runtime_opts)
config = Vela.Cache.Config.new(__MODULE__, opts)
%{
id: __MODULE__,
start: {Vela.Cache, :start_link, [config]},
type: :supervisor
}
end
def start_link(runtime_opts \\ []) do
opts = Keyword.merge(@vela_opts, runtime_opts)
config = Vela.Cache.Config.new(__MODULE__, opts)
Vela.Cache.start_link(config)
end
def get(key, opts \\ []), do: Vela.Cache.get(__MODULE__, key, opts)
def get!(key, opts \\ []), do: Vela.Cache.get!(__MODULE__, key, opts)
def put(key, value, opts \\ []), do: Vela.Cache.put(__MODULE__, key, value, opts)
def delete(key, opts \\ []), do: Vela.Cache.delete(__MODULE__, key, opts)
def exists?(key, opts \\ []), do: Vela.Cache.exists?(__MODULE__, key, opts)
def flush(opts \\ []), do: Vela.Cache.flush(__MODULE__, opts)
def size, do: Vela.Cache.size(__MODULE__)
def get_or_fetch(key, fetch_fn, opts \\ []),
do: Vela.Cache.get_or_fetch(__MODULE__, key, fetch_fn, opts)
def stats, do: Vela.Cache.stats(__MODULE__)
def invalidate_tag(tag, opts \\ []), do: Vela.Cache.invalidate_tag(__MODULE__, tag, opts)
end
end
# ---- Lifecycle ----
def start_link(%Vela.Cache.Config{} = config) do
DynamicSupervisor.start_child(
Vela.Cache.DynamicSupervisor,
{Vela.Cache.Supervisor, config: config}
)
end
# ---- Core API ----
@spec get(atom(), term(), keyword()) :: {:ok, term()} | {:error, :not_found}
def get(cache, key, _opts \\ []) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
start = System.monotonic_time()
result =
if config.near_cache do
case Vela.NearCache.get(cache, key) do
{:ok, entry} -> {:ok, entry.value}
:miss -> get_from_topology(config, ts, cache, key)
end
else
get_from_topology(config, ts, cache, key)
end
duration = System.monotonic_time() - start
if config.stats_enabled do
case result do
{:ok, _} -> Vela.Stats.Collector.incr_hits(cache)
{:error, _} -> Vela.Stats.Collector.incr_misses(cache)
end
end
:telemetry.execute(
config.telemetry_prefix ++ [:get, :stop],
%{duration: duration},
%{cache: cache, key: key, result: if(match?({:ok, _}, result), do: :hit, else: :miss)}
)
result
end
defp get_from_topology(config, ts, cache, key) do
case config.topology.get(ts, key) do
{:ok, entry} ->
if Entry.expired?(entry) do
config.topology.delete(ts, key)
{:error, :not_found}
else
maybe_promote_to_l1(config, cache, entry)
{:ok, entry.value}
end
{:error, :not_found} ->
{:error, :not_found}
end
end
defp maybe_promote_to_l1(%{near_cache: true} = config, cache, entry) do
Vela.NearCache.put(cache, entry, config.near_cache_l1_ttl)
end
defp maybe_promote_to_l1(_config, _cache, _entry), do: :ok
@spec get!(atom(), term(), keyword()) :: term()
def get!(cache, key, opts \\ []) do
case get(cache, key, opts) do
{:ok, value} -> value
{:error, :not_found} -> raise KeyError, key: key, term: cache
end
end
@spec put(atom(), term(), term(), keyword()) :: :ok | {:error, term()}
def put(cache, key, value, opts \\ []) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
ttl = Keyword.get(opts, :ttl, config.default_ttl)
entry = Entry.new(key, value, Keyword.put(opts, :ttl, ttl))
start = System.monotonic_time()
result =
case config.topology.put(ts, entry) do
{:ok, _new_ts} ->
if config.near_cache do
Vela.NearCache.put(cache, entry, config.near_cache_l1_ttl)
end
:ok
error ->
error
end
duration = System.monotonic_time() - start
if config.stats_enabled, do: Vela.Stats.Collector.incr_writes(cache)
:telemetry.execute(
config.telemetry_prefix ++ [:put, :stop],
%{duration: duration},
%{cache: cache, key: key, ttl: ttl}
)
result
end
@spec delete(atom(), term(), keyword()) :: :ok
def delete(cache, key, _opts \\ []) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
{:ok, _new_ts} = config.topology.delete(ts, key)
if config.near_cache, do: Vela.NearCache.delete(cache, key)
if config.stats_enabled, do: Vela.Stats.Collector.incr_deletes(cache)
:ok
end
@spec exists?(atom(), term(), keyword()) :: boolean()
def exists?(cache, key, opts \\ []) do
match?({:ok, _}, get(cache, key, opts))
end
@spec flush(atom(), keyword()) :: :ok
def flush(cache, _opts \\ []) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
{:ok, _new_ts} = config.topology.flush(ts)
if config.near_cache, do: Vela.NearCache.flush(cache)
:ok
end
@spec size(atom()) :: non_neg_integer()
def size(cache) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
config.topology.size(ts)
end
@spec stats(atom()) :: map()
def stats(cache) do
Vela.Stats.Collector.stats(cache)
end
@spec invalidate_tag(atom(), atom(), keyword()) :: {:ok, non_neg_integer()}
def invalidate_tag(cache, tag, _opts \\ []) do
config = get_config(cache)
ts = :persistent_term.get({:vela_topology_state, cache})
{:ok, count, _new_ts} = config.topology.invalidate_tag(ts, tag)
if config.near_cache, do: Vela.NearCache.invalidate_tag(cache, tag)
:telemetry.execute(
config.telemetry_prefix ++ [:invalidate_tag, :stop],
%{count: count},
%{cache: cache, tag: tag}
)
{:ok, count}
end
# ---- Stampede-protected fetch ----
@spec get_or_fetch(atom(), term(), (term() -> {:ok, term()} | {:error, term()}), keyword()) ::
{:ok, term()} | {:error, term()}
def get_or_fetch(cache, key, fetch_fn, opts \\ []) do
case get(cache, key, opts) do
{:ok, value} ->
{:ok, value}
{:error, :not_found} ->
config = get_config(cache)
if config.stampede_protection do
fetch_with_lock(cache, key, fetch_fn, opts)
else
fetch_and_store(cache, key, fetch_fn, opts)
end
end
end
defp fetch_with_lock(cache, key, fetch_fn, opts) do
case Vela.Lock.Manager.acquire(cache, key) do
:granted ->
# Re-check cache — another process may have populated it while we waited
result =
case get(cache, key, opts) do
{:ok, value} ->
{:ok, value}
{:error, :not_found} ->
fetch_and_store(cache, key, fetch_fn, opts)
end
Vela.Lock.Manager.release(cache, key)
result
{:error, :timeout} ->
# Waited too long — fetch directly as fallback
fetch_and_store(cache, key, fetch_fn, opts)
end
end
defp fetch_and_store(cache, key, fetch_fn, opts) do
start = System.monotonic_time()
result =
case fetch_fn.(key) do
{:ok, value} ->
put(cache, key, value, opts)
{:ok, value}
{:error, reason} ->
{:error, reason}
end
duration = System.monotonic_time() - start
config = get_config(cache)
:telemetry.execute(
config.telemetry_prefix ++ [:fetch, :stop],
%{duration: duration},
%{cache: cache, key: key, result: if(match?({:ok, _}, result), do: :fetched, else: :error)}
)
result
end
# ---- Helpers ----
defp get_config(cache) do
:persistent_term.get({:vela_config, cache})
end
end