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