Packages

A durable, resumable workflow engine for Elixir

Current section

Files

Jump to
durable lib mix tasks durable.pending.ex
Raw

lib/mix/tasks/durable.pending.ex

defmodule Mix.Tasks.Durable.Pending do
@shortdoc "Lists pending inputs and events blocking workflows"
@moduledoc """
Lists outstanding pending inputs and pending events across all
workflows. Use this to answer "what is waiting on me?" without
clicking through the dashboard.
## Usage
mix durable.pending [options]
## Options
* `--inputs` - Only list pending inputs
* `--events` - Only list pending events
* `--limit N` - Max rows per section (default: 50)
* `--expiring-in HOURS` - Only show rows that time out within N hours
* `--json` - Emit JSON instead of tables
* `--name NAME` - The Durable instance name (default: Durable)
"""
use Mix.Task
alias Durable.Mix.Helpers
alias Durable.Repo
alias Durable.Storage.Schemas.{PendingEvent, PendingInput, WorkflowExecution}
import Ecto.Query
@impl Mix.Task
def run(args) do
Helpers.ensure_started_readonly()
{opts, _, _} =
OptionParser.parse(args,
strict: [
inputs: :boolean,
events: :boolean,
limit: :integer,
expiring_in: :integer,
json: :boolean,
name: :string
]
)
durable_name = Helpers.get_durable_name(opts)
only_inputs = Keyword.get(opts, :inputs, false)
only_events = Keyword.get(opts, :events, false)
show_inputs = only_inputs or not only_events
show_events = only_events or not only_inputs
inputs = if show_inputs, do: fetch_inputs(durable_name, opts), else: []
events = if show_events, do: fetch_events(durable_name, opts), else: []
if Keyword.get(opts, :json, false) do
emit_json(%{pending_inputs: inputs, pending_events: events})
else
emit_text(inputs, show_inputs, events, show_events)
end
end
defp fetch_inputs(durable_name, opts) do
config = Durable.Config.get(durable_name)
limit = Keyword.get(opts, :limit, 50)
cutoff = expiring_cutoff(opts)
base =
from(p in PendingInput,
join: w in WorkflowExecution,
on: p.workflow_id == w.id,
where: p.status == :pending,
order_by: [asc: p.timeout_at, asc: p.inserted_at],
limit: ^limit,
select: %{
workflow_id: p.workflow_id,
workflow_name: w.workflow_name,
name: p.input_name,
type: p.input_type,
step: p.step_name,
timeout_at: p.timeout_at,
inserted_at: p.inserted_at
}
)
base
|> apply_expiring(cutoff)
|> then(&Repo.all(config, &1))
end
defp fetch_events(durable_name, opts) do
config = Durable.Config.get(durable_name)
limit = Keyword.get(opts, :limit, 50)
cutoff = expiring_cutoff(opts)
base =
from(p in PendingEvent,
join: w in WorkflowExecution,
on: p.workflow_id == w.id,
where: p.status == :pending,
order_by: [asc: p.timeout_at, asc: p.inserted_at],
limit: ^limit,
select: %{
workflow_id: p.workflow_id,
workflow_name: w.workflow_name,
name: p.event_name,
wait_type: p.wait_type,
timeout_at: p.timeout_at,
inserted_at: p.inserted_at
}
)
base
|> apply_expiring(cutoff)
|> then(&Repo.all(config, &1))
end
defp expiring_cutoff(opts) do
case Keyword.get(opts, :expiring_in) do
nil -> nil
hours -> DateTime.add(DateTime.utc_now(), hours * 3600, :second)
end
end
defp apply_expiring(query, nil), do: query
defp apply_expiring(query, cutoff) do
from(p in query, where: not is_nil(p.timeout_at) and p.timeout_at <= ^cutoff)
end
defp emit_json(payload), do: Mix.shell().info(Jason.encode!(payload, pretty: true))
defp emit_text(inputs, true, events, true) do
print_inputs(inputs)
Mix.shell().info("")
print_events(events)
end
defp emit_text(inputs, true, _events, false), do: print_inputs(inputs)
defp emit_text(_inputs, false, events, true), do: print_events(events)
defp emit_text(_, _, _, _), do: :ok
defp print_inputs([]) do
Mix.shell().info("Pending inputs")
Mix.shell().info(" (none)")
end
defp print_inputs(inputs) do
Mix.shell().info("Pending inputs (#{length(inputs)})")
rows =
Enum.map(inputs, fn row ->
[
Helpers.truncate_id(row.workflow_id),
row.workflow_name,
row.name,
to_string(row.type),
row.step,
format_timeout(row.timeout_at),
Helpers.format_seconds(age_seconds(row.inserted_at))
]
end)
headers = ["Workflow", "Name", "Input", "Type", "Step", "Timeout", "Age"]
Helpers.format_table(rows, headers)
|> Enum.each(fn line -> Mix.shell().info(" #{line}") end)
end
defp print_events([]) do
Mix.shell().info("Pending events")
Mix.shell().info(" (none)")
end
defp print_events(events) do
Mix.shell().info("Pending events (#{length(events)})")
rows =
Enum.map(events, fn row ->
[
Helpers.truncate_id(row.workflow_id),
row.workflow_name,
row.name,
to_string(row.wait_type),
format_timeout(row.timeout_at),
Helpers.format_seconds(age_seconds(row.inserted_at))
]
end)
headers = ["Workflow", "Name", "Event", "Wait", "Timeout", "Age"]
Helpers.format_table(rows, headers)
|> Enum.each(fn line -> Mix.shell().info(" #{line}") end)
end
defp format_timeout(nil), do: "—"
# Show relative "in 2h" for future timeouts, "overdue 5m" for past ones —
# fastest way to spot rows that should already have been handled.
defp format_timeout(dt) do
diff = DateTime.diff(dt, DateTime.utc_now(), :second)
if diff > 0 do
"in #{Helpers.format_seconds(diff)}"
else
"overdue #{Helpers.format_seconds(-diff)}"
end
end
defp age_seconds(inserted_at) do
DateTime.diff(DateTime.utc_now(), inserted_at, :second)
end
end