Stored entries now carry a monotonic access counter refreshed on every hit. When the cache fills, the 10% least-recently-used entries are dropped instead of an arbitrary half of the table, which previously caused thrashing under diverse input by evicting hot entries.
83 lines
2.6 KiB
Elixir
83 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
|
|
|
|
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}
|
|
: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
|