aprs.me/lib/aprsme/regex_cache.ex

81 lines
2.6 KiB
Elixir

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
@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