Packages
oban
2.3.4
2.23.0
2.22.1
2.22.0
2.21.1
2.21.0
2.20.3
2.20.2
2.20.1
2.20.0
2.19.4
2.19.3
2.19.2
2.19.1
2.19.0
2.18.3
2.18.2
2.18.1
2.18.0
2.17.12
2.17.11
2.17.10
2.17.9
2.17.8
2.17.7
2.17.6
2.17.5
2.17.4
2.17.3
2.17.2
2.17.1
2.17.0
2.16.3
2.16.2
2.16.1
2.16.0
2.15.4
2.15.3
2.15.2
2.15.1
2.15.0
2.14.2
2.14.1
2.14.0
2.13.6
2.13.5
2.13.4
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.3
2.11.2
2.11.1
2.11.0
2.10.1
2.10.0
retired
2.9.2
2.9.1
2.9.0
2.8.0
2.7.2
2.7.1
2.7.0
2.6.1
2.6.0
2.5.0
2.4.3
2.4.2
2.4.1
2.4.0
2.3.4
2.3.3
2.3.2
2.3.1
2.3.0
2.2.0
2.1.0
2.0.0
2.0.0-rc.3
2.0.0-rc.2
2.0.0-rc.1
2.0.0-rc.0
1.2.0
1.1.0
1.0.0
1.0.0-rc.2
1.0.0-rc.1
0.12.1
0.12.0
0.11.1
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.1
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
Robust job processing, backed by modern PostgreSQL, SQLite3, and MySQL.
Current section
Files
Jump to
Current section
Files
lib/oban/queue/producer.ex
defmodule Oban.Queue.Producer do
@moduledoc false
use GenServer
import Oban.Breaker, only: [open_circuit: 1, trip_errors: 0, trip_circuit: 3]
alias Oban.{Breaker, Config, Notifier, Query, Registry, Repo, Telemetry}
alias Oban.Queue.Executor
@type option ::
{:name, module()}
| {:conf, Config.t()}
| {:foreman, GenServer.name()}
| {:limit, pos_integer()}
| {:queue, binary()}
defmodule State do
@moduledoc false
@enforce_keys [:conf, :foreman, :limit, :nonce, :queue]
defstruct [
:conf,
:cooldown_ref,
:dispatched_at,
:foreman,
:limit,
:name,
:nonce,
:queue,
:reset_timer,
:started_at,
:timer,
circuit: :enabled,
dispatch_cooldown: 5,
paused: false,
poll_interval: :timer.seconds(1),
running: %{}
]
end
@spec start_link([option()]) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@spec check(GenServer.name()) :: Oban.queue_state()
def check(producer) do
GenServer.call(producer, :check)
end
@spec pause(GenServer.name()) :: :ok
def pause(producer) do
GenServer.call(producer, :pause)
end
# Callbacks
@impl GenServer
def init(opts) do
Process.flag(:trap_exit, true)
opts =
opts
|> Keyword.put(:nonce, nonce())
|> Keyword.put(:started_at, DateTime.utc_now())
{:ok, struct!(State, opts), {:continue, :start}}
end
@impl GenServer
def handle_continue(:start, state) do
state =
state
|> start_listener()
|> schedule_poll()
{:noreply, state}
end
@impl GenServer
def terminate(_reason, %State{timer: timer}) do
if is_reference(timer), do: Process.cancel_timer(timer)
:ok
end
@impl GenServer
def handle_info({ref, _val}, %State{running: running} = state) do
Process.demonitor(ref, [:flush])
dispatch(%{state | running: Map.delete(running, ref)})
end
def handle_info({:DOWN, ref, :process, _pid, :shutdown}, %State{running: running} = state) do
Process.demonitor(ref, [:flush])
dispatch(%{state | running: Map.delete(running, ref)})
end
# This message is only received when the job's task doesn't exit cleanly. This should be rare,
# but it can happen when nested processes crash.
def handle_info({:DOWN, ref, :process, _pid, {reason, stack}}, %State{} = state) do
%State{foreman: foreman, running: running} = state
{{exec, _pid}, running} = Map.pop(running, ref)
# Without this we may crash the producer if there are any db errors. Alternatively, we would
# block the producer while awaiting a retry.
Task.Supervisor.async_nolink(foreman, fn ->
Breaker.with_retry(fn ->
%{exec | kind: :error, error: reason, stacktrace: stack, state: :failure}
|> Executor.record_finished()
|> Executor.report_finished()
end)
end)
dispatch(%{state | running: running})
end
def handle_info(:dispatch, %State{} = state) do
dispatch(state)
end
def handle_info({:notification, :insert, payload}, %State{queue: queue} = state) do
case payload do
%{"queue" => ^queue} -> dispatch(state)
_ -> {:noreply, state}
end
end
def handle_info({:notification, :signal, payload}, %State{queue: queue} = state) do
state =
case payload do
%{"action" => "pause", "queue" => ^queue} ->
%{state | paused: true}
%{"action" => "resume", "queue" => ^queue} ->
%{state | paused: false}
%{"action" => "scale", "queue" => ^queue, "limit" => limit} ->
%{state | limit: limit}
%{"action" => "pkill", "job_id" => jid} ->
for {ref, {exec, pid}} <- state.running, exec.job.id == jid do
pkill(ref, exec.job, pid, state)
end
state
_ ->
state
end
dispatch(state)
end
def handle_info(:poll, %State{conf: conf} = state) do
Repo.checkout(conf, fn ->
state
|> schedule_poll()
|> deschedule()
|> dispatch()
end)
end
def handle_info(:reset_circuit, state) do
{:noreply, open_circuit(state)}
end
def handle_info(_message, state) do
{:noreply, state}
end
@impl GenServer
def handle_call(:check, _from, %State{conf: conf, running: running} = state) do
running_ids = for {_ref, {exec, _pid}} <- running, do: exec.job.id
args =
state
|> Map.take([:limit, :nonce, :paused, :queue, :started_at])
|> Map.put(:node, conf.node)
|> Map.put(:running, running_ids)
{:reply, args, state}
end
def handle_call(:pause, _from, state) do
{:reply, :ok, %{state | paused: true}}
end
# Start Handlers
defp nonce(size \\ 8) when size > 0 do
size
|> :crypto.strong_rand_bytes()
|> Base.hex_encode32(case: :lower)
|> String.slice(0..(size - 1))
end
defp start_listener(%State{conf: conf} = state) do
conf.name
|> Registry.whereis(Notifier)
|> Notifier.listen([:insert, :signal])
state
end
defp schedule_poll(%State{conf: conf} = state) do
%{state | timer: Process.send_after(self(), :poll, conf.poll_interval)}
end
# Killing
defp pkill(ref, job, pid, state) do
%State{conf: conf, foreman: foreman, running: running} = state
case DynamicSupervisor.terminate_child(foreman, pid) do
:ok ->
Query.cancel_job(conf, job)
state
{:error, :not_found} ->
Process.demonitor(ref, [:flush])
%{state | running: Map.delete(running, ref)}
end
end
# Dispatching
defp deschedule(%State{circuit: :disabled} = state) do
state
end
defp deschedule(%State{conf: conf, queue: queue} = state) do
Telemetry.span(
:producer,
fn -> Query.stage_scheduled_jobs(conf, queue) end,
%{action: :deschedule, queue: queue}
)
state
rescue
exception in trip_errors() -> trip_circuit(exception, __STACKTRACE__, state)
end
defp dispatch(%State{paused: true} = state) do
{:noreply, state}
end
defp dispatch(%State{circuit: :disabled} = state) do
{:noreply, state}
end
defp dispatch(%State{limit: limit, running: running} = state) when map_size(running) >= limit do
{:noreply, state}
end
defp dispatch(%State{} = state) do
cond do
dispatch_now?(state) ->
%State{conf: conf, limit: limit, nonce: nonce, queue: queue, running: running} = state
running =
Telemetry.span(
:producer,
fn ->
conf
|> fetch_jobs(queue, nonce, limit - map_size(running))
|> start_jobs(state)
|> Map.merge(running)
end,
%{action: :dispatch, queue: queue}
)
{:noreply, %{state | cooldown_ref: nil, dispatched_at: system_now(), running: running}}
cooldown_available?(state) ->
dispatch_after = system_now() - state.dispatched_at + state.dispatch_cooldown
cooldown_ref = Process.send_after(self(), :dispatch, dispatch_after)
{:noreply, %{state | cooldown_ref: cooldown_ref}}
true ->
{:noreply, state}
end
rescue
exception in trip_errors() -> {:noreply, trip_circuit(exception, __STACKTRACE__, state)}
end
defp dispatch_now?(%State{dispatched_at: nil}), do: true
defp dispatch_now?(%State{} = state) do
system_now() > state.dispatched_at + state.dispatch_cooldown
end
defp cooldown_available?(%State{cooldown_ref: ref}), do: is_nil(ref)
defp fetch_jobs(conf, queue, nonce, count) do
{:ok, jobs} = Query.fetch_available_jobs(conf, queue, nonce, count)
jobs
end
defp start_jobs(jobs, %State{conf: conf, foreman: foreman}) do
for job <- jobs, into: %{} do
exec = Executor.new(conf, job)
%{pid: pid, ref: ref} = Task.Supervisor.async_nolink(foreman, Executor, :call, [exec])
{ref, {exec, pid}}
end
end
defp system_now, do: System.monotonic_time(:millisecond)
end