diff --git a/lib/aprsme/regex_cache.ex b/lib/aprsme/regex_cache.ex index a36a0e0..488963d 100644 --- a/lib/aprsme/regex_cache.ex +++ b/lib/aprsme/regex_cache.ex @@ -1,7 +1,11 @@ defmodule Aprsme.RegexCache do @moduledoc """ - A simple in-memory cache for compiled regex patterns to avoid recompilation. - Uses ETS for thread-safe access. + 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 @@ -10,6 +14,9 @@ defmodule Aprsme.RegexCache do @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__) @@ -17,9 +24,9 @@ defmodule Aprsme.RegexCache do @impl true def init(_) do - # Create ETS table for caching compiled regexes + # Table stores {pattern, regex, last_access_counter} :ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true]) - {:ok, %{}} + {:ok, %{counter: 0}} end @doc """ @@ -30,27 +37,31 @@ defmodule Aprsme.RegexCache do end @impl true - def handle_call({:get_or_compile, pattern_string}, _from, state) do - result = - case :ets.lookup(@table_name, pattern_string) do - [{^pattern_string, regex}] -> - {:ok, regex} + def handle_call({:get_or_compile, pattern_string}, _from, %{counter: counter} = state) do + next_counter = counter + 1 - [] -> - compile_and_cache(pattern_string) - end + 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}} - {:reply, result, state} + [] -> + 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) do + 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 - clear_oldest_entries() + evict_lru() end - :ets.insert(@table_name, {pattern_string, regex}) + :ets.insert(@table_name, {pattern_string, regex, counter}) {:ok, regex} error -> @@ -58,14 +69,15 @@ defmodule Aprsme.RegexCache do end end - defp clear_oldest_entries do - # Simple strategy: clear half the cache - # In production, you might want LRU eviction + # 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) - to_remove = Enum.take(entries, div(length(entries), 2)) + target = max(1, trunc(length(entries) * @evict_fraction)) - Enum.each(to_remove, fn {key, _} -> - :ets.delete(@table_name, key) - end) + 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