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
|
||||
@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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue