defmodule DaProductApp.TransactionRuleCache do @moduledoc """ Read-through ETS cache for TransactionRules.fetch_rules_for/2. Lazily populated on cache miss; entries expire after @ttl_ms and are refetched from the database on next access. Only successful {:ok, rules} results are cached - errors always fall through to the database. """ use GenServer require Logger @name __MODULE__ @table :transaction_rules_cache @ttl_ms Application.compile_env(:da_product_app, :transaction_rule_cache_ttl_ms, :timer.minutes(5)) # Public API def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: @name) end @doc """ Returns cached rules for {merchant_id, terminal_id} if present and fresh; otherwise calls `fetch_fun.()`, caches the result if successful, and returns it. """ def get_or_load(merchant_id, terminal_id, fetch_fun) when is_function(fetch_fun, 0) do key = {merchant_id, terminal_id} case :ets.lookup(@table, key) do [{^key, result, cached_at}] -> if fresh?(cached_at) do Logger.info("TransactionRuleCache HIT - key: #{inspect(key)}") result else Logger.info("TransactionRuleCache EXPIRED - key: #{inspect(key)}, refreshing") GenServer.call(@name, {:refresh, key, fetch_fun}) end [] -> Logger.info("TransactionRuleCache MISS - key: #{inspect(key)}") GenServer.call(@name, {:refresh, key, fetch_fun}) end end @doc """ Clears the entire cache. Intended for manual/ops-triggered invalidation after out-of-band database changes (e.g. direct SQL updates). """ def refresh_all do GenServer.call(@name, :refresh_all) end # Callbacks @impl true def init(_args) do :ets.new(@table, [:named_table, :set, :protected, read_concurrency: true]) {:ok, %{}} end @impl true def handle_call({:refresh, key, fetch_fun}, _from, state) do # Double-check inside the GenServer in case another caller already # refreshed this key while we were waiting for the call. case :ets.lookup(@table, key) do [{^key, result, cached_at}] -> if fresh?(cached_at) do {:reply, result, state} else {:reply, do_fetch_and_store(key, fetch_fun), state} end [] -> {:reply, do_fetch_and_store(key, fetch_fun), state} end end @impl true def handle_call(:refresh_all, _from, state) do :ets.delete_all_objects(@table) Logger.info("TransactionRuleCache REFRESH ALL - cache cleared") {:reply, :ok, state} end defp do_fetch_and_store(key, fetch_fun) do Logger.info("TransactionRuleCache LOADING FROM DB - key: #{inspect(key)}") case fetch_fun.() do {:ok, _rules} = result -> :ets.insert(@table, {key, result, System.monotonic_time(:millisecond)}) Logger.info("TransactionRuleCache STORED - key: #{inspect(key)}") result {:error, reason} = other -> Logger.warning( "TransactionRuleCache DB ERROR - key: #{inspect(key)}, reason: #{inspect(reason)}, not caching" ) other other -> other end end defp fresh?(cached_at) do System.monotonic_time(:millisecond) - cached_at < @ttl_ms end end