perf(regex_cache): replace half-wipe eviction with LRU
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.
This commit is contained in:
parent
8fd62fba26
commit
8a0381d9bd
1 changed files with 35 additions and 23 deletions
|
|
@ -1,7 +1,11 @@
|
||||||
defmodule Aprsme.RegexCache do
|
defmodule Aprsme.RegexCache do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
A simple in-memory cache for compiled regex patterns to avoid recompilation.
|
In-memory cache for compiled regex patterns that avoids recompilation.
|
||||||
Uses ETS for thread-safe access.
|
|
||||||
|
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
|
use GenServer
|
||||||
|
|
@ -10,6 +14,9 @@ defmodule Aprsme.RegexCache do
|
||||||
|
|
||||||
@table_name :regex_cache
|
@table_name :regex_cache
|
||||||
@max_cache_size 1000
|
@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
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||||
|
|
@ -17,9 +24,9 @@ defmodule Aprsme.RegexCache do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def init(_) do
|
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])
|
:ets.new(@table_name, [:set, :protected, :named_table, read_concurrency: true])
|
||||||
{:ok, %{}}
|
{:ok, %{counter: 0}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
@ -30,27 +37,31 @@ defmodule Aprsme.RegexCache do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_call({:get_or_compile, pattern_string}, _from, state) do
|
def handle_call({:get_or_compile, pattern_string}, _from, %{counter: counter} = state) do
|
||||||
result =
|
next_counter = counter + 1
|
||||||
case :ets.lookup(@table_name, pattern_string) do
|
|
||||||
[{^pattern_string, regex}] ->
|
|
||||||
{:ok, regex}
|
|
||||||
|
|
||||||
[] ->
|
case :ets.lookup(@table_name, pattern_string) do
|
||||||
compile_and_cache(pattern_string)
|
[{^pattern_string, regex, _last}] ->
|
||||||
end
|
# 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
|
end
|
||||||
|
|
||||||
defp compile_and_cache(pattern_string) do
|
defp compile_and_cache(pattern_string, counter) do
|
||||||
case Regex.compile(pattern_string) do
|
case Regex.compile(pattern_string) do
|
||||||
{:ok, regex} ->
|
{:ok, regex} ->
|
||||||
if :ets.info(@table_name, :size) >= @max_cache_size do
|
if :ets.info(@table_name, :size) >= @max_cache_size do
|
||||||
clear_oldest_entries()
|
evict_lru()
|
||||||
end
|
end
|
||||||
|
|
||||||
:ets.insert(@table_name, {pattern_string, regex})
|
:ets.insert(@table_name, {pattern_string, regex, counter})
|
||||||
{:ok, regex}
|
{:ok, regex}
|
||||||
|
|
||||||
error ->
|
error ->
|
||||||
|
|
@ -58,14 +69,15 @@ defmodule Aprsme.RegexCache do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp clear_oldest_entries do
|
# Evict the @evict_fraction oldest entries by access counter. This is O(n)
|
||||||
# Simple strategy: clear half the cache
|
# over the table, but only runs when the table is full, not on every insert.
|
||||||
# In production, you might want LRU eviction
|
defp evict_lru do
|
||||||
entries = :ets.tab2list(@table_name)
|
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, _} ->
|
entries
|
||||||
:ets.delete(@table_name, key)
|
|> Enum.sort_by(fn {_key, _regex, last} -> last end)
|
||||||
end)
|
|> Enum.take(target)
|
||||||
|
|> Enum.each(fn {key, _regex, _last} -> :ets.delete(@table_name, key) end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue