defmodule Vela.Stats.Collector do @moduledoc """ Tracks cache statistics using lock-free atomic counters. The hot path (every get/put) increments counters directly using the :counters module — no GenServer message needed. This GenServer only wakes periodically to emit a telemetry summary. """ use GenServer # Counter indices @hits 1 @misses 2 @writes 3 @deletes 4 @evictions 5 @errors 6 @num_counters 6 @flush_interval 60_000 # ---- Public API ---- def start_link(opts) do cache_name = Keyword.fetch!(opts, :name) GenServer.start_link(__MODULE__, opts, name: collector_name(cache_name)) end def collector_name(cache_name), do: :"vela_stats_#{cache_name}" @doc "Increment the hits counter. Called from the hot path — no GenServer involved." def incr_hits(cache_name), do: incr(cache_name, @hits) def incr_misses(cache_name), do: incr(cache_name, @misses) def incr_writes(cache_name), do: incr(cache_name, @writes) def incr_deletes(cache_name), do: incr(cache_name, @deletes) def incr_evictions(cache_name), do: incr(cache_name, @evictions) def incr_errors(cache_name), do: incr(cache_name, @errors) @doc "Return current stats as a map." def stats(cache_name) do ref = get_ref(cache_name) %{ hits: :counters.get(ref, @hits), misses: :counters.get(ref, @misses), writes: :counters.get(ref, @writes), deletes: :counters.get(ref, @deletes), evictions: :counters.get(ref, @evictions), errors: :counters.get(ref, @errors) } end # ---- GenServer Callbacks ---- @impl true def init(opts) do config = Keyword.fetch!(opts, :config) ref = :counters.new(@num_counters, [:atomics]) # Store the ref in persistent_term so callers can access it # without going through this GenServer :persistent_term.put({:vela_stats_ref, config.name}, ref) Process.send_after(self(), :flush, @flush_interval) {:ok, %{config: config, ref: ref, window_start: System.monotonic_time()}} end @impl true def handle_info(:flush, state) do s = stats(state.config.name) duration = System.monotonic_time() - state.window_start # Emit a telemetry snapshot — dashboards/exporters can compute rates # from successive snapshots. Counters are cumulative and never reset, # so stats/1 always returns lifetime totals. :telemetry.execute( state.config.telemetry_prefix ++ [:stats, :flush], Map.put(s, :window_duration, duration), %{cache: state.config.name} ) Process.send_after(self(), :flush, @flush_interval) {:noreply, %{state | window_start: System.monotonic_time()}} end # ---- Private ---- defp incr(cache_name, index) do ref = get_ref(cache_name) :counters.add(ref, index, 1) end defp get_ref(cache_name) do :persistent_term.get({:vela_stats_ref, cache_name}) end end