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 @doc """ Delete every entry matching a match pattern. The pattern follows `ets:match_delete/2` conventions — use `:"_"` for wildcards. Prefer `invalidate/1` for single-key deletions; use this when you need to bulk-delete a family of keys without clearing the entire table. """ @spec match_delete(atom() | tuple()) :: :ok def match_delete(pattern) do :ets.match_delete(@table, pattern) :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