defmodule ExSQL.Codegen.Cache do @moduledoc """ Shape-keyed cache + compile gate for `ExSQL.Codegen`. Maps a query *shape* (the param-lifted IR — same shape for any literal values) to either a sighting count or a compiled module. A shape is only compiled once it has been seen `threshold` times (default 2): one-shot ad-hoc queries never pay the compile cost, while repeated/prepared queries amortize it. The compiled modules are global (a `Module.create` side effect), so the cache lives in a `:public` ETS table owned by this process — like `ExSQL.Registry`. """ use GenServer @table :exsql_codegen_cache @default_threshold 2 def start_link(opts \\ []), do: GenServer.start_link(__MODULE__, :ok, opts) @impl true def init(:ok) do :ets.new(@table, [:named_table, :public, :set, read_concurrency: true]) {:ok, %{}} end @doc """ Records a sighting of `shape_key` and decides what to do: * `{:hit, module}` — already compiled, run it. * `{:compile, n}` — seen enough times; the caller should compile + `store/2`. * `:too_few` — not seen enough yet; fall back to the tree walker this time. """ @spec fetch(term()) :: {:hit, module()} | {:compile, pos_integer()} | :too_few def fetch(shape_key) do case :ets.lookup(@table, shape_key) do [{_, {:module, module}}] -> {:hit, module} [{_, count}] when is_integer(count) -> n = :ets.update_counter(@table, shape_key, 1) if n >= threshold(), do: {:compile, n}, else: :too_few [] -> :ets.insert(@table, {shape_key, 1}) if threshold() <= 1, do: {:compile, 1}, else: :too_few end rescue ArgumentError -> :too_few end @doc "Stores the compiled module for a shape (subsequent `fetch/1` returns `{:hit, module}`)." @spec store(term(), module()) :: :ok def store(shape_key, module) do :ets.insert(@table, {shape_key, {:module, module}}) :ok rescue ArgumentError -> :ok end defp threshold do case System.get_env("EXSQL_CODEGEN_THRESHOLD") do nil -> @default_threshold str -> String.to_integer(str) end end end