Packages

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

Current section

Files

Jump to
vela_cache lib vela backend dets.ex
Raw

lib/vela/backend/dets.ex

defmodule Vela.Backend.DETS do
@moduledoc """
DETS-backed cache backend. Data persists to disk and survives restarts.
Slower than ETS (~10-100x) but durable. Good for caches where a cold
start is expensive and the dataset fits within DETS limits (~2GB).
Uses two DETS tables: one for data, one for the TTL index (same
pattern as the ETS backend).
## Backend Options
backend_opts: [
data_dir: "/tmp/vela" # optional, defaults to "vela_data"
]
"""
@behaviour Vela.Backend
alias Vela.Cache.Entry
defstruct [:data_table, :ttl_table]
@impl true
def init(config) do
opts = config.backend_opts
data_dir = Keyword.get(opts, :data_dir, "vela_data")
File.mkdir_p!(data_dir)
data_path = Path.join(data_dir, "#{config.name}_data.dets") |> String.to_charlist()
ttl_path = Path.join(data_dir, "#{config.name}_ttl.dets") |> String.to_charlist()
{:ok, data_table} =
:dets.open_file(:"vela_dets_data_#{config.name}",
file: data_path,
type: :set
)
{:ok, ttl_table} =
:dets.open_file(:"vela_dets_ttl_#{config.name}",
file: ttl_path,
type: :set
)
state = %__MODULE__{
data_table: data_table,
ttl_table: ttl_table
}
{:ok, state}
end
@impl true
def get(%__MODULE__{data_table: table}, key) do
case :dets.lookup(table, key) do
[{^key, entry}] -> {:ok, entry}
[] -> {:error, :not_found}
end
end
@impl true
def put(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state, %Entry{} = entry) do
# Clean up old TTL entry if overwriting
case :dets.lookup(data_table, entry.key) do
[{_key, old_entry}] ->
unless old_entry.expires_at == :infinity do
:dets.delete(ttl_table, {old_entry.expires_at, entry.key})
end
[] ->
:ok
end
:dets.insert(data_table, {entry.key, entry})
unless entry.expires_at == :infinity do
:dets.insert(ttl_table, {{entry.expires_at, entry.key}, entry.key})
end
{:ok, state}
end
@impl true
def delete(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state, key) do
case :dets.lookup(data_table, key) do
[{^key, entry}] ->
:dets.delete(data_table, key)
unless entry.expires_at == :infinity do
:dets.delete(ttl_table, {entry.expires_at, key})
end
[] ->
:ok
end
{:ok, state}
end
@impl true
def get_many(%__MODULE__{} = state, keys) do
result =
Enum.reduce(keys, %{}, fn key, acc ->
case get(state, key) do
{:ok, entry} -> Map.put(acc, key, entry)
_ -> acc
end
end)
{:ok, result}
end
@impl true
def put_many(state, entries) do
Enum.reduce(entries, {:ok, state}, fn entry, {:ok, acc} ->
put(acc, entry)
end)
end
@impl true
def flush(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state) do
:dets.delete_all_objects(data_table)
:dets.delete_all_objects(ttl_table)
{:ok, state}
end
@impl true
def flush_expired(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state, now) do
# Scan TTL table for expired entries
expired =
:dets.foldl(
fn {{expires_at, key}, _value}, acc ->
if expires_at <= now, do: [key | acc], else: acc
end,
[],
ttl_table
)
# Delete from both tables
Enum.each(expired, fn key ->
case :dets.lookup(data_table, key) do
[{^key, entry}] ->
:dets.delete(ttl_table, {entry.expires_at, key})
[] ->
:ok
end
:dets.delete(data_table, key)
end)
{:ok, length(expired), state}
end
@impl true
def size(%__MODULE__{data_table: table}) do
:dets.info(table, :size)
end
@impl true
def delete_by_tag(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state, tag) do
matching =
:dets.foldl(
fn {key, entry}, acc ->
if tag in entry.tags, do: [{key, entry} | acc], else: acc
end,
[],
data_table
)
Enum.each(matching, fn {key, entry} ->
:dets.delete(data_table, key)
unless entry.expires_at == :infinity do
:dets.delete(ttl_table, {entry.expires_at, key})
end
end)
{:ok, length(matching), state}
end
end