defmodule Aprsme.RegexCache do @moduledoc """ In-memory cache for compiled regex patterns that avoids recompilation. Stored values carry a monotonic access counter so eviction can drop the least-recently-used entries instead of an arbitrary half of the table — previously a thrashing pattern under diverse input would churn the cache and keep paying compile cost on every miss. """ use GenServer require Logger @table_name :regex_cache @max_cache_size 1000 # When the cache is full, drop this fraction of least-recently-used entries. # 10% keeps hot entries warm instead of wiping half the cache every churn. @evict_fraction 0.1 def start_link(_opts) do GenServer.start_link(__MODULE__, %{}, name: __MODULE__) end @impl true def init(_) do # Table stores {pattern, regex, last_access_counter} _table = :ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true]) {:ok, %{counter: 0}} end @doc """ Get or compile a regex pattern. Returns {:ok, regex} or {:error, reason}. """ def get_or_compile(pattern_string) do GenServer.call(__MODULE__, {:get_or_compile, pattern_string}) end @impl true def handle_call({:get_or_compile, pattern_string}, _from, %{counter: counter} = state) do next_counter = counter + 1 case :ets.lookup(@table_name, pattern_string) do [{^pattern_string, regex, _last}] -> # Refresh the access counter so subsequent evictions see this as recent. :ets.insert(@table_name, {pattern_string, regex, next_counter}) {:reply, {:ok, regex}, %{state | counter: next_counter}} [] -> case compile_and_cache(pattern_string, next_counter) do {:ok, regex} -> {:reply, {:ok, regex}, %{state | counter: next_counter}} error -> {:reply, error, state} end end end defp compile_and_cache(pattern_string, counter) do case Regex.compile(pattern_string) do {:ok, regex} -> if :ets.info(@table_name, :size) >= @max_cache_size do evict_lru() end :ets.insert(@table_name, {pattern_string, regex, counter}) {:ok, regex} error -> error end end # Evict the @evict_fraction oldest entries by access counter. This is O(n) # over the table, but only runs when the table is full, not on every insert. defp evict_lru do entries = :ets.tab2list(@table_name) target = max(1, trunc(length(entries) * @evict_fraction)) entries |> Enum.sort_by(fn {_key, _regex, last} -> last end) |> Enum.take(target) |> Enum.each(fn {key, _regex, _last} -> :ets.delete(@table_name, key) end) end end