defmodule Vela.Backend.ETS do @moduledoc """ ETS-backed cache backend. Reads and writes go directly to ETS tables (bypassing any GenServer mailbox) for maximum throughput. The tables are owned and protected by Vela.Backend.ETS.TableOwner. """ @behaviour Vela.Backend alias Vela.Backend.ETS.TableOwner alias Vela.Cache.Entry # State for this backend — just the table names defstruct [:data_table, :ttl_table] @impl true def init(config) do state = %__MODULE__{ data_table: TableOwner.data_table(config.name), ttl_table: TableOwner.ttl_table(config.name) } {:ok, state} end @impl true def get(%__MODULE__{data_table: table}, key) do case :ets.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 an existing key case :ets.lookup(data_table, entry.key) do [{_key, old_entry}] -> unless old_entry.expires_at == :infinity do :ets.delete(ttl_table, {old_entry.expires_at, entry.key}) end [] -> :ok end # Store the entry in the main data table :ets.insert(data_table, {entry.key, entry}) # Register the expiry in the TTL index table # Key format: {expires_at, key} — ordered_set sorts by this, # so a range scan finds all expired entries efficiently unless entry.expires_at == :infinity do :ets.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 # Look up the entry first so we can remove it from the TTL index case :ets.lookup(data_table, key) do [{^key, entry}] -> :ets.delete(data_table, key) unless entry.expires_at == :infinity do :ets.delete(ttl_table, {entry.expires_at, key}) end [] -> :ok end {:ok, state} end @impl true def get_many(%__MODULE__{data_table: table}, keys) do result = Enum.reduce(keys, %{}, fn key, acc -> case :ets.lookup(table, key) do [{^key, entry}] -> Map.put(acc, key, entry) [] -> acc end end) {:ok, result} end @impl true def put_many(state, entries) do new_state = Enum.reduce(entries, state, fn entry, acc_state -> {:ok, next_state} = put(acc_state, entry) next_state end) {:ok, new_state} end @impl true def flush(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state) do :ets.delete_all_objects(data_table) :ets.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 # TTL table rows are {{expires_at, key}, key} # Match the nested tuple to extract expires_at for the guard match_spec = [ { # pattern: {{expires_at, key}, key} {{:"$1", :"$2"}, :"$3"}, # guard: expires_at <= now [{:"=<", :"$1", now}], # action: delete it [true] } ] # Collect expired keys so we can delete them from the data table too expired_keys = :ets.select(ttl_table, [ { {{:"$1", :"$2"}, :"$3"}, [{:"=<", :"$1", now}], # return the data key [:"$3"] } ]) count = length(expired_keys) # Delete from both tables :ets.select_delete(ttl_table, match_spec) Enum.each(expired_keys, &:ets.delete(data_table, &1)) {:ok, count, state} end @impl true def size(%__MODULE__{data_table: table}) do :ets.info(table, :size) end @impl true def delete_by_tag(%__MODULE__{data_table: data_table, ttl_table: ttl_table} = state, tag) do # Scan data table for entries whose tags list contains the given tag. # ETS match spec: the value is an Entry struct stored as {key, entry}. # Entry has a :tags field — we select entries where tag is in the list. matching = :ets.foldl( fn {key, entry}, acc -> if tag in entry.tags, do: [{key, entry} | acc], else: acc end, [], data_table ) # Delete each matching entry from both tables Enum.each(matching, fn {key, entry} -> :ets.delete(data_table, key) unless entry.expires_at == :infinity do :ets.delete(ttl_table, {entry.expires_at, key}) end end) {:ok, length(matching), state} end end