Vendor oban_web 2.12.1 and oban_met 1.1.0 (taken from the towerops-web2 vendor tree), bump oban to ~> 2.21, and mount the Oban dashboard at /admin/oban behind the existing require_admin on_mount hook. Adds an admin-only "Oban" nav link and extends the Dockerfile to copy vendor/ before deps.get so production builds pick up the path dependencies.
216 lines
5.5 KiB
Elixir
216 lines
5.5 KiB
Elixir
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
|
|
|
|
require Logger
|
|
|
|
@empty_states %{
|
|
"available" => [],
|
|
"cancelled" => [],
|
|
"completed" => [],
|
|
"discarded" => [],
|
|
"executing" => [],
|
|
"retryable" => [],
|
|
"scheduled" => [],
|
|
"suspended" => []
|
|
}
|
|
|
|
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{} = 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
|
|
|
|
def handle_info(message, state) do
|
|
Logger.warning(
|
|
message: "Received unexpected message: #{inspect(message)}",
|
|
source: :oban_met,
|
|
module: __MODULE__
|
|
)
|
|
|
|
{:noreply, state}
|
|
end
|
|
|
|
# Scheduling
|
|
|
|
defp schedule_checks(state) do
|
|
timer = Process.send_after(self(), :checkpoint, state.check_interval)
|
|
|
|
%{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
|