104 lines
2.9 KiB
Elixir
104 lines
2.9 KiB
Elixir
defmodule Microwaveprop.Cache do
|
|
@moduledoc """
|
|
Tiny ETS-backed TTL cache for values that are expensive to compute but
|
|
tolerate short staleness. Used for things like `Repo.aggregate` counts that
|
|
would otherwise run on every page load.
|
|
|
|
Not a replacement for `Microwaveprop.Propagation.ScoreCache` or
|
|
`Microwaveprop.Weather.NexradCache` — those have bespoke invalidation logic
|
|
driven by PubSub. This module is for generic time-boxed memoization.
|
|
"""
|
|
use GenServer
|
|
|
|
@table :microwaveprop_cache
|
|
@sweep_interval_ms to_timeout(minute: 1)
|
|
|
|
@spec start_link(keyword()) :: GenServer.on_start()
|
|
def start_link(opts) do
|
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
|
end
|
|
|
|
@doc """
|
|
Return the cached value for `key` if it's still fresh, otherwise invoke
|
|
`fun`, store the result with `ttl_ms` lifetime, and return it.
|
|
"""
|
|
@spec fetch_or_store(term(), non_neg_integer(), (-> value)) :: value when value: term()
|
|
def fetch_or_store(key, ttl_ms, fun) when is_function(fun, 0) do
|
|
now = System.monotonic_time(:millisecond)
|
|
|
|
case :ets.lookup(@table, key) do
|
|
[{_, value, expires_at}] when expires_at > now ->
|
|
value
|
|
|
|
_ ->
|
|
value = fun.()
|
|
:ets.insert(@table, {key, value, now + ttl_ms})
|
|
value
|
|
end
|
|
end
|
|
|
|
@doc "Insert `value` directly, overwriting any existing entry for `key`."
|
|
@spec put(term(), term(), integer()) :: :ok
|
|
def put(key, value, ttl_ms) do
|
|
:ets.insert(@table, {key, value, System.monotonic_time(:millisecond) + ttl_ms})
|
|
:ok
|
|
end
|
|
|
|
@doc "Remove `key` from the cache, forcing the next fetch to recompute."
|
|
@spec invalidate(term()) :: :ok
|
|
def invalidate(key) do
|
|
:ets.delete(@table, key)
|
|
:ok
|
|
end
|
|
|
|
@spec clear() :: :ok
|
|
def clear do
|
|
:ets.delete_all_objects(@table)
|
|
:ok
|
|
end
|
|
|
|
@doc """
|
|
Delete every entry whose TTL has elapsed. Returns the number of rows
|
|
removed.
|
|
|
|
Because TTL enforcement in `fetch_or_store/3` is lazy, keys minted
|
|
dynamically (e.g. per-URL HRRR idx entries) would otherwise accumulate
|
|
forever. A supervised timer calls this on an interval; tests can call
|
|
it directly to avoid waiting on the timer.
|
|
|
|
Uses `:ets.select_delete/2` so the sweep happens in a single BIF call
|
|
without blocking concurrent readers or writers.
|
|
"""
|
|
@spec sweep() :: non_neg_integer()
|
|
def sweep do
|
|
now = System.monotonic_time(:millisecond)
|
|
match_spec = [{{:_, :_, :"$1"}, [{:"=<", :"$1", now}], [true]}]
|
|
:ets.select_delete(@table, match_spec)
|
|
end
|
|
|
|
@impl true
|
|
def init(_opts) do
|
|
_ =
|
|
:ets.new(@table, [
|
|
:set,
|
|
:named_table,
|
|
:public,
|
|
read_concurrency: true,
|
|
write_concurrency: true
|
|
])
|
|
|
|
schedule_sweep()
|
|
{:ok, %{}}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:sweep, state) do
|
|
_ = sweep()
|
|
schedule_sweep()
|
|
{:noreply, state}
|
|
end
|
|
|
|
defp schedule_sweep do
|
|
Process.send_after(self(), :sweep, @sweep_interval_ms)
|
|
end
|
|
end
|