81 lines
2.6 KiB
Elixir
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
|