Packages

DAG-based job workflows for Oban: dependency ordering, fan-out/synthesis, result passing, retry idempotency, and per-step LLM cost tracking — without Oban Pro.

Current section

Files

Jump to
baton lib baton check.ex
Raw

lib/baton/check.ex

defmodule Baton.Check do
@moduledoc """
Runtime dependency resolution for workflow jobs.
Called at the start of each `perform/1`. Loads the current job's node to get
its dependencies and flags, then resolves the state of each dependency by
joining `workflow_nodes` to `oban_jobs`.
## States and outcomes
| Dep state | Default outcome | With ignore option |
|--------------|------------------------------|---------------------|
| `completed` | proceed | n/a |
| `executing` | snooze (or stale -> cancel) | snooze |
| `available` | snooze | snooze |
| `scheduled` | snooze | snooze |
| `retryable` | snooze | snooze |
| `cancelled` | cancel this job | treat as done |
| `discarded` | cancel this job | treat as done |
| pruned/nil | cancel (job pruned) | n/a |
A dep whose Oban job was pruned comes back from the LEFT JOIN with a nil
state and is treated as pruned. A dep stuck in `executing` past the stale
threshold is treated as failed rather than snoozing forever.
> #### Caveat: the stale threshold must exceed your longest step {: .warning}
>
> The `executing`-too-long check cannot distinguish a genuinely stuck step
> from a healthy long-running one. If a step can legitimately run longer than
> `Baton.Config.stale_executing_threshold_seconds/0` (default 600), raise that
> threshold above the step's timeout — otherwise a running step is cancelled
> and its dependents cascade-fail. See
> `Baton.Config.stale_executing_threshold_seconds/0`.
"""
require Logger
alias Baton.{Config, Node, Nodes}
@type result :: :ok | {:snooze, pos_integer()} | {:cancel, String.t()}
@doc """
Whether all upstream deps for the given job are satisfied.
- `:ok` — proceed with `perform_workflow/1`
- `{:snooze, seconds}` — some deps still pending, recheck later
- `{:cancel, reason}` — a dep failed permanently
"""
@spec deps_satisfied?(Oban.Job.t()) :: result()
def deps_satisfied?(%Oban.Job{id: job_id}) do
case Nodes.for_job(job_id) do
nil ->
# No node — not a workflow job (or its node is gone); nothing to gate.
:ok
%Node{deps: []} ->
:ok
%Node{} = node ->
check_deps(node)
end
end
# ── Private ───────────────────────────────────────────────────────────────
defp check_deps(%Node{workflow_id: workflow_id, deps: deps} = node) do
dep_map =
workflow_id
|> Nodes.dep_states(deps)
|> Map.new(&{&1.name, &1})
stale_threshold = Config.stale_executing_threshold_seconds()
Enum.reduce_while(deps, :ok, fn dep_name, :ok ->
status = classify_dep(Map.get(dep_map, dep_name), stale_threshold)
resolve(status, dep_name, node)
end)
end
defp resolve(:completed, _dep, _node), do: {:cont, :ok}
defp resolve(:pending, _dep, _node), do: {:halt, {:snooze, Config.snooze_seconds()}}
defp resolve({:terminal, :cancelled}, dep, %Node{ignore_cancelled: true}) do
Logger.debug("[Baton.Check] dep '#{dep}' cancelled - ignored per config")
{:cont, :ok}
end
defp resolve({:terminal, :discarded}, dep, %Node{ignore_discarded: true}) do
Logger.debug("[Baton.Check] dep '#{dep}' discarded - ignored per config")
{:cont, :ok}
end
defp resolve({:terminal, :cancelled}, dep, _node),
do: {:halt, {:cancel, "dependency '#{dep}' was cancelled"}}
defp resolve({:terminal, :discarded}, dep, _node),
do: {:halt, {:cancel, "dependency '#{dep}' was discarded"}}
defp resolve({:terminal, :pruned}, dep, _node) do
Logger.warning(
"[Baton.Check] dep '#{dep}' job not found - likely pruned before completing"
)
{:halt, {:cancel, "dependency '#{dep}' was pruned before completing"}}
end
defp resolve({:terminal, :stale}, dep, _node) do
Logger.warning(
"[Baton.Check] dep '#{dep}' stuck in executing past stale threshold - treating as failed"
)
{:halt, {:cancel, "dependency '#{dep}' appears stuck (stale executing state)"}}
end
# A dep with no row at all, or a row whose job was pruned (state nil) -> pruned.
defp classify_dep(nil, _stale), do: {:terminal, :pruned}
defp classify_dep(%{state: nil}, _stale), do: {:terminal, :pruned}
defp classify_dep(%{state: "completed"}, _stale), do: :completed
defp classify_dep(%{state: "cancelled"}, _stale), do: {:terminal, :cancelled}
defp classify_dep(%{state: "discarded"}, _stale), do: {:terminal, :discarded}
defp classify_dep(%{state: "executing", attempted_at: attempted_at}, stale)
when not is_nil(attempted_at) do
if age_seconds(attempted_at) > stale, do: {:terminal, :stale}, else: :pending
end
defp classify_dep(%{state: _other}, _stale), do: :pending
# Oban's attempted_at loads as a DateTime (utc_datetime_usec); tolerate
# NaiveDateTime too for safety across Oban/Ecto versions.
defp age_seconds(%DateTime{} = dt), do: DateTime.diff(DateTime.utc_now(), dt)
defp age_seconds(%NaiveDateTime{} = ndt),
do: DateTime.diff(DateTime.utc_now(), DateTime.from_naive!(ndt, "Etc/UTC"))
end