Previous commit (3c988a5f) switched oban_pro to the licensed hex repo at deploy time. Reverting that — keep all three Pro packages vendored so prod image builds don't depend on oban.pro reachability or auth on every CI run. oban_pro 1.7.0 dropped into vendor/oban_pro via `mix hex.package fetch oban_pro 1.7.0 --repo=oban --unpack`. mix.exs goes back to `path: "vendor/oban_pro"` (matching oban_met / oban_web, which were already vendored — and stay vendored since the licensed repo only has older versions of those: 0.1.11 / 2.10.6 vs the 1.1.0 / 2.12.1 we vendor). Schema migration from3c988a5fstays — 1.7.0 tables/indexes are already applied. No code changes.
183 lines
5 KiB
Elixir
183 lines
5 KiB
Elixir
defmodule Oban.Pro.Utils do
|
|
@moduledoc false
|
|
|
|
alias Ecto.Changeset
|
|
|
|
@spec encode64(term(), [:compressed | :deterministic]) :: String.t()
|
|
def encode64(term, opts \\ [:compressed]) do
|
|
term
|
|
|> :erlang.term_to_binary(opts)
|
|
|> Base.encode64(padding: false)
|
|
end
|
|
|
|
@spec decode64(binary(), [:safe | :used]) :: term()
|
|
def decode64(bin, opts \\ [:safe]) do
|
|
bin
|
|
|> Base.decode64!(padding: false)
|
|
|> :erlang.binary_to_term(opts)
|
|
end
|
|
|
|
@spec hash64(iodata()) :: String.t()
|
|
def hash64(iodata) when is_binary(iodata) or is_list(iodata) do
|
|
:blake2s
|
|
|> :crypto.hash(iodata)
|
|
|> Base.encode64(padding: false)
|
|
end
|
|
|
|
@spec persistent_cache(tuple(), fun()) :: any()
|
|
def persistent_cache(key, fun) when is_function(fun, 0) do
|
|
case :persistent_term.get(key, nil) do
|
|
nil -> tap(fun.(), &:persistent_term.put(key, &1))
|
|
val -> val
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Check if any legacy suspended jobs exist (scheduled + on_hold pattern).
|
|
|
|
Result is cached in persistent_term per prefix and only checked once per application lifetime.
|
|
"""
|
|
@spec has_legacy_suspended?(Oban.Config.t()) :: boolean()
|
|
def has_legacy_suspended?(conf) do
|
|
import Ecto.Query
|
|
|
|
persistent_cache({__MODULE__, :legacy_suspended, conf.prefix}, fn ->
|
|
query =
|
|
from(job in Oban.Job,
|
|
where: fragment("? \\? 'workflow_id'", job.meta),
|
|
where: fragment("? ->> 'workflow_id' IS NOT NULL", job.meta),
|
|
where: job.state == "scheduled",
|
|
where: fragment("? ->> 'on_hold' = 'true'", job.meta),
|
|
select: true,
|
|
limit: 1
|
|
)
|
|
|
|
Oban.Repo.exists?(conf, query)
|
|
end)
|
|
end
|
|
|
|
@spec validate_opts(list(), fun()) :: :ok | {:error, term()}
|
|
def validate_opts(opts, validator) do
|
|
Enum.reduce_while(opts, :ok, fn opt, acc ->
|
|
case validator.(opt) do
|
|
{:error, _reason} = error -> {:halt, error}
|
|
_ -> {:cont, acc}
|
|
end
|
|
end)
|
|
end
|
|
|
|
@spec cast_period(pos_integer() | {atom(), pos_integer()}) :: pos_integer()
|
|
def cast_period({value, unit}) do
|
|
unit = to_string(unit)
|
|
|
|
cond do
|
|
unit in ~w(second seconds) -> value
|
|
unit in ~w(minute minutes) -> value * 60
|
|
unit in ~w(hour hours) -> value * 60 * 60
|
|
unit in ~w(day days) -> value * 24 * 60 * 60
|
|
true -> unit
|
|
end
|
|
end
|
|
|
|
def cast_period(period), do: period
|
|
|
|
@spec enforce_keys(Changeset.t(), map(), module()) :: Changeset.t()
|
|
def enforce_keys(changeset, params, schema) do
|
|
fields =
|
|
[:fields, :virtual_fields]
|
|
|> Enum.flat_map(&schema.__schema__/1)
|
|
|> Enum.map(&to_string/1)
|
|
|
|
Enum.reduce(params, changeset, fn {key, _val}, acc ->
|
|
if to_string(key) in fields do
|
|
acc
|
|
else
|
|
Changeset.add_error(acc, :base, "unknown field #{key} provided")
|
|
end
|
|
end)
|
|
end
|
|
|
|
@spec take_keys(Changeset.t(), atom(), list()) :: [any()]
|
|
def take_keys(changeset, field, keys) do
|
|
changeset
|
|
|> Changeset.get_field(field)
|
|
|> take_keys(keys)
|
|
end
|
|
|
|
@spec take_keys(map(), [atom()]) :: [any()]
|
|
def take_keys(map, _keys) when map_size(map) == 0, do: []
|
|
|
|
def take_keys(map, []) when is_map(map), do: take_keys(map, Map.keys(map))
|
|
|
|
def take_keys(map, keys) when is_map(map) and is_list(keys) do
|
|
map = Map.new(map, fn {key, val} -> {to_string(key), val} end)
|
|
|
|
keys
|
|
|> Enum.map(&to_string/1)
|
|
|> Enum.sort()
|
|
|> Enum.map(fn key ->
|
|
val = map |> Map.get(key) |> to_hash_val()
|
|
|
|
[key, val]
|
|
end)
|
|
end
|
|
|
|
if Application.compile_env(:oban_pro, [__MODULE__, :safe_hash], false) do
|
|
defp to_hash_val(val) when is_binary(val), do: val
|
|
defp to_hash_val(val) when is_number(val), do: to_string(val)
|
|
defp to_hash_val(val) when is_atom(val), do: to_string(val)
|
|
defp to_hash_val(val), do: inspect(val)
|
|
else
|
|
defp to_hash_val(val), do: val |> :erlang.phash2() |> Integer.to_string()
|
|
end
|
|
|
|
def normalize_by(by) do
|
|
by
|
|
|> List.wrap()
|
|
|> Enum.map(fn
|
|
{key, val} -> [key, val |> List.wrap() |> Enum.sort()]
|
|
field -> field
|
|
end)
|
|
end
|
|
|
|
@spec to_exception(Changeset.t()) :: Exception.t()
|
|
def to_exception(changeset) do
|
|
changeset
|
|
|> to_translated_errors()
|
|
|> Enum.reverse()
|
|
|> Enum.map(fn {field, message} -> ArgumentError.exception("#{field} #{message}") end)
|
|
|> List.first()
|
|
end
|
|
|
|
@spec to_translated_errors(Changeset.t()) :: Keyword.t()
|
|
def to_translated_errors(changeset) do
|
|
changeset
|
|
|> Changeset.traverse_errors(&translate_errors/1)
|
|
|> Enum.map(&extract_errors/1)
|
|
|> List.flatten()
|
|
end
|
|
|
|
## Conversions
|
|
|
|
@spec maybe_to_atom(atom() | String.t()) :: atom()
|
|
def maybe_to_atom(key) when is_binary(key), do: String.to_existing_atom(key)
|
|
def maybe_to_atom(key), do: key
|
|
|
|
# Helpers
|
|
|
|
defp translate_errors({msg, opt}) do
|
|
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
|
|
opt
|
|
|> Keyword.get(String.to_existing_atom(key), key)
|
|
|> to_string()
|
|
end)
|
|
end
|
|
|
|
defp extract_errors({_key, val}) when is_map(val) do
|
|
val
|
|
|> Map.to_list()
|
|
|> Enum.map(&extract_errors/1)
|
|
end
|
|
|
|
defp extract_errors({key, [message | _]}), do: {key, message}
|
|
end
|