defmodule Mv.Membership.SettingsCache do @moduledoc """ Process-based cache for global settings to avoid repeated DB reads on hot paths (e.g. RegistrationEnabled validation, Layouts.public_page, Plugs). Uses a short TTL (default 60 seconds). Cache is invalidated on every settings update so that changes take effect quickly. If no settings process exists (e.g. in tests), get/1 falls back to direct read. """ use GenServer @default_ttl_seconds 60 def start_link(opts \\ []) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end @doc """ Returns cached settings or fetches and caches them. Uses TTL; invalidate on update. """ def get do case Process.whereis(__MODULE__) do nil -> # No cache process (e.g. test) – read directly do_fetch() _pid -> GenServer.call(__MODULE__, :get, 10_000) end end @doc """ Invalidates the cache so the next get/0 will refetch from the database. Call after update_settings and any other path that mutates settings. """ def invalidate do case Process.whereis(__MODULE__) do nil -> :ok _pid -> GenServer.cast(__MODULE__, :invalidate) end end @impl true def init(opts) do ttl = Keyword.get(opts, :ttl_seconds, @default_ttl_seconds) state = %{ttl_seconds: ttl, cached: nil, expires_at: nil} {:ok, state} end @impl true def handle_call(:get, _from, state) do now = System.monotonic_time(:second) expired? = state.expires_at == nil or state.expires_at <= now {result, new_state} = if expired? do fetch_and_cache(now, state) else {{:ok, state.cached}, state} end {:reply, result, new_state} end defp fetch_and_cache(now, state) do case do_fetch() do {:ok, settings} = ok -> expires = now + state.ttl_seconds {ok, %{state | cached: settings, expires_at: expires}} err -> result = if state.cached, do: {:ok, state.cached}, else: err {result, state} end end @impl true def handle_cast(:invalidate, state) do {:noreply, %{state | cached: nil, expires_at: nil}} end defp do_fetch do Mv.Membership.get_settings_uncached() end end