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 @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 @impl true def init(_opts) do :ets.new(@table, [ :set, :named_table, :public, read_concurrency: true, write_concurrency: true ]) {:ok, %{}} end end