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