Packages

Helper library to better join + preload Ecto associations, and other goodies

Current section

Files

Jump to
ecto_sparkles lib n_plus_1_detector.ex
Raw

lib/n_plus_1_detector.ex

defmodule EctoSparkles.NPlus1Detector do
@moduledoc """
Accumulates repeated SELECT shapes per Proces or unit of work (LiveView mount/params/event, HTTP request, Oban job) for N+1 detection.
`check/1` counts query shapes in one process-dictionary entry; `flush/0` returns the counts and clears them. The host app attaches telemetry span-stop handlers that flush at the end of each unit of work and report offenders in one consolidated warning (see example implementation in `priv/sample_telemetry.ex`), so counts can never leak across user actions, and no state lingers in idle/hibernated processes.
Local pdict only as parallel preload Tasks are not N+1 loops, so nothing is read from or written to ancestor processes.
Disabled unless `config :ecto_sparkles, :n_plus_1_detect, true` is enabled.
"""
@key :ecto_sparkles_n_plus_1
# backstop for boundary-less processes (long-lived GenServers that never hit a flush point):
# new shapes beyond the cap aren't tracked, already-tracked shapes keep counting
@max_shapes 64
def enabled?, do: Application.get_env(:ecto_sparkles, :n_plus_1_detect, false) == true
@doc "Accumulate one occurrence of a query shape (SELECTs only; no-op unless enabled)."
def check("SELECT" <> _ = query) do
if enabled?() do
case Process.get(@key) do
nil ->
Process.put(@key, %{query => 1})
counts when is_map_key(counts, query) ->
Process.put(@key, Map.update!(counts, query, &(&1 + 1)))
counts when map_size(counts) < @max_shapes ->
Process.put(@key, Map.put(counts, query, 1))
_at_cap ->
:ok
end
end
:ok
end
def check(_query), do: :ok
@doc "Returns the accumulated `%{query => count}` and clears the state (call at the end of a unit of work)."
def flush do
Process.delete(@key) || %{}
end
end