Turned on :error_handling, :underspecs, and :unmatched_returns in mix.exs dialyzer config. The 97 warnings this surfaced were fixed in place rather than suppressed: - unmatched_return (79): explicit discard with `_ = ...` for fire-and-forget side effects (Process.cancel_timer, :ets.new, send/2), and pattern-matched `:ok = ...` for control-plane Phoenix.PubSub subscribe/unsubscribe/broadcast calls so a future return-shape change fails loud. - contract_supertype (18): tightened @spec arg and return types on data_builder, historical_loader, url_params, packet_utils, encoding_utils, aprs_symbol, weather_controller, packet_replay to match each function's actual success typing. No behavioural change. mix compile clean, 1008 tests pass, dialyzer count is now 0.
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}
|
|
_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
|