Packages
localize
0.40.0
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.0
0.41.3
0.41.2
0.41.1
0.41.0
0.40.0
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.0
0.31.0
0.30.1
0.30.0
retired
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
0.1.0-alpha.1
Localization (parsing, formatting) of numbers, dates/time/calendar, units of measure, messages and lists. Includes localized collation.
Current section
Files
Jump to
Current section
Files
lib/localize/format_cache.ex
defmodule Localize.FormatCache do
@moduledoc """
An ETS-backed cache for compiled format patterns.
Number format metadata and datetime format tokens are cached
here after first compilation. The cache is hard-bounded: when
inserting an entry would exceed the configured maximum, an
existing entry is evicted synchronously, keeping the cache at
or below the cap at all times.
The maximum number of entries defaults to 2,000 and can be
overridden with:
config :localize, :format_cache_max_entries, 5_000
## Trust model
The ETS table is `:protected` — only the cache GenServer can
write to it; any process can read directly. This keeps the
cache from being polluted by other libraries running in the
same BEAM, and ensures the size invariant cannot be violated
by a non-owner write.
Writes go through `store/2`, which is a `GenServer.call` to the
owner. A miss-then-store pattern from a hot path therefore pays
one gen-server round-trip per *first-time* format compilation;
subsequent lookups are direct ETS reads with no synchronisation
cost.
"""
use GenServer
@table :localize_format_cache
@default_max_entries 2_000
# ── Client API ──────────────────────────────────────────────
@doc """
Look up a compiled format pattern by its cache key.
### Arguments
* `key` is the cache key, typically a tuple like
`{:localize, :number_format_meta, format_string}`.
### Returns
* `{:ok, value}` if the key is present.
* `:miss` if not cached or the table does not exist.
"""
@spec lookup(term()) :: {:ok, term()} | :miss
def lookup(key) do
if :ets.whereis(@table) != :undefined do
case :ets.lookup(@table, key) do
[{^key, value}] -> {:ok, value}
[] -> :miss
end
else
:miss
end
end
@doc """
Store a compiled format pattern in the cache.
Routed through the cache GenServer so the size cap can be
enforced synchronously. If the table doesn't exist (e.g. during
a bare unit test), the call is a no-op.
### Arguments
* `key` is the cache key.
* `value` is the compiled artifact to cache.
### Returns
* `:ok`.
"""
@spec store(term(), term()) :: :ok
def store(key, value) do
if Process.whereis(__MODULE__) do
GenServer.call(__MODULE__, {:store, key, value})
else
:ok
end
end
@doc """
Returns the current number of entries in the cache.
Primarily useful in tests; production callers should not need
to inspect the size directly.
"""
@spec size() :: non_neg_integer()
def size do
if :ets.whereis(@table) != :undefined do
:ets.info(@table, :size)
else
0
end
end
@doc """
Returns the configured maximum number of cache entries.
"""
@spec max_entries() :: pos_integer()
def max_entries do
Application.get_env(:localize, :format_cache_max_entries, @default_max_entries)
end
@doc """
Clears all entries from the cache.
Routed through the GenServer so the operation respects the
table's `:protected` ownership. Intended for tests and
maintenance; production callers should not need this.
"""
@spec clear() :: :ok
def clear do
if Process.whereis(__MODULE__) do
GenServer.call(__MODULE__, :clear)
else
:ok
end
end
# ── GenServer ──────────────────────────────────────────────
@doc false
def start_link(options) do
GenServer.start_link(__MODULE__, options, name: __MODULE__)
end
@impl true
def init(_options) do
ensure_table()
{:ok, []}
end
@impl true
def handle_call({:store, key, value}, _from, state) do
cap = max_entries()
size = :ets.info(@table, :size)
# If the key already exists, this is an update — no growth.
# Otherwise, evict to stay at or below the cap before insert.
cond do
:ets.member(@table, key) ->
:ets.insert(@table, {key, value})
size >= cap ->
evict(size - cap + 1)
:ets.insert(@table, {key, value})
true ->
:ets.insert(@table, {key, value})
end
{:reply, :ok, state}
end
@impl true
def handle_call(:clear, _from, state) do
if :ets.whereis(@table) != :undefined do
:ets.delete_all_objects(@table)
end
{:reply, :ok, state}
end
defp ensure_table do
if :ets.whereis(@table) == :undefined do
:ets.new(@table, [
:set,
:protected,
:named_table,
read_concurrency: true
])
end
end
# Synchronous bounded eviction. Removes `count` entries by
# walking the table; ETS `:set` doesn't preserve insertion order,
# but the deterministic `:ets.first/1` traversal gives us a
# bounded eviction strategy without per-entry bookkeeping.
# True LRU would require an access-order index whose write cost
# exceeds the cache's protection benefit.
defp evict(count) when count <= 0, do: :ok
defp evict(count) do
case :ets.first(@table) do
:"$end_of_table" ->
:ok
key ->
:ets.delete(@table, key)
evict(count - 1)
end
end
end