Current section
Files
Jump to
Current section
Files
lib/mix/tasks/durable.cleanup.ex
defmodule Mix.Tasks.Durable.Cleanup do
@shortdoc "Deletes old workflow executions"
@moduledoc """
Deletes old workflow executions from the database.
Cascade deletes handle associated step executions, pending inputs, and events.
## Usage
mix durable.cleanup --older-than DURATION [options]
## Options
* `--older-than DURATION` - Required. Delete executions older than this duration.
Supports: `30d` (days), `24h` (hours), `60m` (minutes)
* `--status STATUS` - Only delete executions with this status (default: completed, failed).
Can be specified multiple times.
* `--dry-run` - Show how many records would be deleted without deleting
* `--batch-size N` - Number of records to delete per batch (default: 1000)
* `--name NAME` - The Durable instance name (default: Durable)
## Examples
mix durable.cleanup --older-than 30d
mix durable.cleanup --older-than 24h --status completed --dry-run
mix durable.cleanup --older-than 7d --batch-size 500
"""
use Mix.Task
import Ecto.Query
alias Durable.Config
alias Durable.Mix.Helpers
alias Durable.Repo
alias Durable.Storage.Schemas.WorkflowExecution
@default_statuses [:completed, :failed]
@default_batch_size 1000
@impl Mix.Task
def run(args) do
Helpers.ensure_started_readonly()
{opts, _, _} =
OptionParser.parse(args,
strict: [
older_than: :string,
status: [:string, :keep],
dry_run: :boolean,
batch_size: :integer,
name: :string
]
)
with {:ok, cutoff} <- parse_older_than(opts),
{:ok, statuses} <- parse_statuses(opts) do
durable_name = Helpers.get_durable_name(opts)
config = Config.get(durable_name)
dry_run = Keyword.get(opts, :dry_run, false)
batch_size = Keyword.get(opts, :batch_size, @default_batch_size)
if dry_run do
run_dry(config, cutoff, statuses)
else
run_cleanup(config, cutoff, statuses, batch_size)
end
end
end
defp parse_older_than(opts) do
case Keyword.get(opts, :older_than) do
nil ->
Mix.shell().error("--older-than is required. Example: --older-than 30d")
:error
duration_str ->
parse_duration(duration_str)
end
end
defp parse_duration(str) do
case Regex.run(~r/^(\d+)([dhm])$/, str) do
[_, num_str, unit] ->
num = String.to_integer(num_str)
seconds = duration_to_seconds(num, unit)
cutoff = DateTime.add(DateTime.utc_now(), -seconds, :second)
{:ok, cutoff}
nil ->
Mix.shell().error(
"Invalid duration: #{str}. Use format like 30d (days), 24h (hours), or 60m (minutes)."
)
:error
end
end
defp duration_to_seconds(num, "d"), do: num * 86_400
defp duration_to_seconds(num, "h"), do: num * 3_600
defp duration_to_seconds(num, "m"), do: num * 60
defp parse_statuses(opts) do
case Keyword.get_values(opts, :status) do
[] ->
{:ok, @default_statuses}
status_strings ->
statuses =
Enum.map(status_strings, fn s ->
String.to_existing_atom(s)
end)
{:ok, statuses}
end
rescue
ArgumentError ->
Mix.shell().error("Invalid status provided.")
:error
end
defp run_dry(config, cutoff, statuses) do
count = count_matching(config, cutoff, statuses)
status_str = Enum.map_join(statuses, ", ", &to_string/1)
Mix.shell().info(
"Dry run: #{Helpers.format_number(count)} executions would be deleted " <>
"(status: #{status_str}, older than #{Helpers.format_datetime(cutoff)})."
)
end
defp run_cleanup(config, cutoff, statuses, batch_size) do
total = do_batch_delete(config, cutoff, statuses, batch_size, 0)
Mix.shell().info("Deleted #{Helpers.format_number(total)} workflow executions.")
end
defp do_batch_delete(config, cutoff, statuses, batch_size, acc) do
ids_query =
from(w in WorkflowExecution,
where: w.status in ^statuses and w.inserted_at < ^cutoff,
select: w.id,
limit: ^batch_size
)
delete_query = from(w in WorkflowExecution, where: w.id in subquery(ids_query))
{deleted, _} = Repo.delete_all(config, delete_query)
if deleted > 0 do
do_batch_delete(config, cutoff, statuses, batch_size, acc + deleted)
else
acc
end
end
defp count_matching(config, cutoff, statuses) do
query =
from(w in WorkflowExecution,
where: w.status in ^statuses and w.inserted_at < ^cutoff,
select: count(w.id)
)
Repo.one(config, query)
end
end