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:
Graham McIntire 2026-04-21 09:40:19 -05:00
parent 8fd62fba26
commit 8a0381d9bd
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59

View file

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