Current section
Files
Jump to
Current section
Files
lib/oban/met/reporter.ex
defmodule Oban.Met.Reporter do
@moduledoc false
# Periodically count and report jobs by state and queue.
#
# Because exact counts are expensive, counts for states with jobs beyond a configurable
# threshold are estimated. This is a tradeoff that aims to preserve system resources at the
# expense of accuracy.
use GenServer
import Ecto.Query, only: [from: 2, group_by: 3, select: 3, where: 3]
alias __MODULE__, as: State
alias Oban.{Job, Notifier, Peer, Repo}
alias Oban.Met.Migration
alias Oban.Met.Values.Gauge
alias Oban.Pro.Engines.Smart
@empty_states %{
"available" => [],
"cancelled" => [],
"completed" => [],
"discarded" => [],
"executing" => [],
"retryable" => [],
"scheduled" => []
}
defstruct [
:conf,
:name,
:queue_timer,
:check_timer,
auto_migrate: true,
checks: @empty_states,
check_counter: 0,
check_interval: :timer.seconds(1),
estimate_limit: 50_000,
function_created?: false,
queues: []
]
@spec child_spec(Keyword.t()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
%{super(opts) | id: name}
end
@spec start_link(Keyword.t()) :: GenServer.on_start()
def start_link(opts) do
conf = Keyword.fetch!(opts, :conf)
opts =
if conf.repo.__adapter__() == Ecto.Adapters.Postgres do
opts
else
opts
|> Keyword.put(:auto_migrate, false)
|> Keyword.put(:estimate_limit, :infinity)
end
state = struct!(State, opts)
GenServer.start_link(__MODULE__, state, name: opts[:name])
end
# Callbacks
@impl GenServer
def init(state) do
Process.flag(:trap_exit, true)
# Used to ensure testing helpers to auto-allow this module for sandbox access.
:telemetry.execute([:oban, :plugin, :init], %{}, %{conf: state.conf, plugin: __MODULE__})
{:ok, schedule_checks(state)}
end
@impl GenServer
def terminate(_reason, %State{} = state) do
if is_reference(state.check_timer), do: Process.cancel_timer(state.check_timer)
:ok
end
@impl GenServer
def handle_info(:checkpoint, %State{conf: conf} = state) do
if Peer.leader?(conf.name) do
state =
state
|> create_estimate_function()
|> cache_queues()
{:ok, checks} = checks(state)
metrics =
for {_key, counts} <- checks,
count <- counts,
do: Map.update!(count, :value, &Gauge.new/1)
payload = %{
metrics: metrics,
name: inspect(conf.name),
node: conf.node,
time: System.system_time(:second)
}
Notifier.notify(conf, :metrics, payload)
{:noreply,
schedule_checks(%{state | check_counter: state.check_counter + 1, checks: checks})}
else
{:noreply, schedule_checks(state)}
end
end
# Scheduling
defp schedule_checks(state) do
timer = Process.send_after(self(), :checkpoint, state.check_interval)
%State{state | check_timer: timer}
end
# Checking
defp create_estimate_function(%{auto_migrate: true, function_created?: false} = state) do
%{conf: %{prefix: prefix}} = state
query = Migration.oban_count_estimate(prefix)
Repo.query!(state.conf, query, [])
%{state | function_created?: true}
end
defp create_estimate_function(state), do: state
defp cache_queues(state) do
if Integer.mod(state.check_counter, 60) == 0 do
source = if state.conf.engine == Smart, do: "oban_producers", else: "oban_jobs"
query = from(p in source, select: p.queue, distinct: true)
%{state | queues: Repo.all(state.conf, query)}
else
state
end
end
defp checks(%{estimate_limit: limit} = state) do
{count_states, guess_states} =
for {state, counts} <- state.checks, reduce: {[], []} do
{count_acc, guess_acc} ->
total = Enum.reduce(counts, 0, &(&2 + &1.value))
if total < limit do
{[state | count_acc], guess_acc}
else
{count_acc, [state | guess_acc]}
end
end
count_query = count_query(count_states)
guess_query = guess_query(guess_states, state.queues, state.conf)
Repo.transaction(state.conf, fn ->
count_counts = Repo.all(state.conf, count_query)
guess_counts = Repo.all(state.conf, guess_query)
(count_counts ++ guess_counts)
|> Enum.group_by(& &1.state)
|> Enum.reduce(@empty_states, fn {state, counts}, acc ->
Map.put(acc, state, counts)
end)
end)
end
defp count_query([]), do: where(Job, [_], false)
defp count_query(states) when is_list(states) do
Job
|> select([j], %{series: :full_count, state: j.state, queue: j.queue, value: count(j.id)})
|> where([j], j.state in ^states)
|> group_by([j], [j.state, j.queue])
end
defp guess_query([], _queues, _conf), do: where(Job, [_], false)
defp guess_query(_states, [], _conf), do: where(Job, [_], false)
defp guess_query(states, queues, conf) when is_list(states) and is_list(queues) do
from(p in fragment("json_array_elements_text(?)", ^queues),
cross_join: x in fragment("json_array_elements_text(?)", ^states),
select: %{
series: :full_count,
state: x.value,
queue: p.value,
value: fragment("?.oban_count_estimate(?, ?)", literal(^conf.prefix), x.value, p.value)
}
)
end
end