prop/vendor/oban_pro/lib/oban/pro/producer.ex
Graham McIntire e99bf06eb4
deps: re-vendor oban_pro 1.7.0 (revert hex-repo dep)
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 from 3c988a5f stays — 1.7.0 tables/indexes are
already applied. No code changes.
2026-04-30 09:29:55 -05:00

376 lines
11 KiB
Elixir

require Protocol
defmodule Oban.Pro.Producer do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
alias Oban.Pro.Utils
defmodule ListOrMap do
@moduledoc false
# This is a custom type to allow a graceful transition from the legacy rate limit windows
# structure to the new, flattened map.
@behaviour Ecto.Type
@impl true
def type, do: :any
@impl true
def cast(data) when is_map(data), do: {:ok, data}
def cast([data | _]), do: {:ok, data}
def cast(_data), do: :error
@impl true
def load(data) when is_map(data), do: {:ok, data}
def load([data | _]), do: {:ok, data}
def load([]), do: {:ok, %{}}
def load(_data), do: :error
@impl true
def dump(data) when is_map(data), do: {:ok, data}
def dump([data | _]), do: {:ok, data}
def dump([]), do: {:ok, %{}}
def dump(_data), do: :error
@impl true
def equal?(a, b), do: a == b
@impl true
def embed_as(_data), do: :dump
end
@type t :: %__MODULE__{
uuid: Ecto.UUID.t(),
name: binary(),
node: binary(),
queue: binary(),
meta: map(),
started_at: DateTime.t(),
updated_at: DateTime.t()
}
@type meta :: %__MODULE__.Meta{local_limit: pos_integer(), paused: boolean()}
@primary_key {:uuid, :binary_id, autogenerate: false}
schema "oban_producers" do
field :name, :string
field :node, :string
field :queue, :string
field :started_at, :utc_datetime_usec
field :updated_at, :utc_datetime_usec
field :ack_async, :boolean, virtual: true, default: true
field :ack_tab, :any, virtual: true
field :refresh_interval, :integer, virtual: true, default: :timer.seconds(30)
field :xact_delay, :integer, default: :timer.seconds(1), virtual: true
field :xact_retry, :integer, default: 5, virtual: true
field :xact_timeout, :integer, default: :timer.seconds(30), virtual: true
embeds_one :meta, Meta, on_replace: :update, primary_key: false do
@moduledoc false
field :local_limit, :integer, default: 10
field :paused, :boolean, default: false
field :shutdown_started_at, :utc_datetime_usec
embeds_one :global_limit, GlobalLimit, on_replace: :update, primary_key: false do
@moduledoc false
field :allowed, :integer
field :burst, :boolean, default: false
field :tracked, ListOrMap, default: %{}
embeds_one :partition, Partition, on_replace: :update, primary_key: false do
@moduledoc false
field :fields, {:array, Ecto.Enum}, values: [:args, :meta, :worker]
field :keys, {:array, :string}
end
end
embeds_one :rate_limit, RateLimit, on_replace: :update, primary_key: false do
@moduledoc false
field :algorithm, Ecto.Enum,
values: ~w(sliding_window fixed_window token_bucket)a,
default: :sliding_window
field :allowed, :integer
field :period, :integer
field :window_time, :integer, skip_default_validation: true
field :windows, ListOrMap, default: %{}
embeds_one :partition, Partition, on_replace: :update, primary_key: false do
@moduledoc false
field :fields, {:array, Ecto.Enum}, values: [:args, :meta, :worker]
field :keys, {:array, :string}
end
end
end
end
@spec new(map() | Keyword.t()) :: Changeset.t(t())
def new(params) when is_list(params) or is_map(params) do
params =
params
|> Map.new()
|> Map.put_new_lazy(:uuid, &Oban.Pro.UUIDv7.generate/0)
|> Map.update!(:name, &to_clean_string/1)
|> Map.update!(:node, &to_clean_string/1)
|> Map.update!(:queue, &to_clean_string/1)
%__MODULE__{}
|> cast(params, ~w(name node queue refresh_interval started_at updated_at uuid)a)
|> cast_embed(:meta, required: true, with: &meta_changeset/2)
|> validate_required(~w(name node queue)a)
|> validate_length(:name, min: 1)
|> validate_length(:node, min: 1)
|> validate_length(:queue, min: 1)
|> Utils.enforce_keys(params, __MODULE__)
end
@spec update_meta(t(), atom(), term()) :: Changeset.t()
def update_meta(schema, key, value) do
update_meta(schema, %{key => value})
end
@spec update_meta(t(), map()) :: Changeset.t()
def update_meta(schema, changes) do
meta =
schema.meta
|> meta_changeset(changes)
|> apply_changes()
|> Ecto.embedded_dump(:json)
schema
|> cast(%{meta: meta}, [])
|> cast_embed(:meta, with: &meta_changeset/2)
end
@allowed ~w(local_limit paused shutdown_started_at)a
@doc false
def meta_changeset(schema, params, allowed \\ @allowed) do
params =
params
|> Map.new()
|> Map.delete(:limit)
|> Map.put_new_lazy(:local_limit, fn -> default_local_limit(params, schema) end)
params =
if Map.get(params, :global_limit) do
Map.update!(params, :global_limit, &cast_global_limit/1)
else
params
end
params =
if Map.get(params, :rate_limit) do
Map.update!(params, :rate_limit, &cast_rate_limit/1)
else
params
end
schema
|> cast(params, allowed)
|> cast_embed(:global_limit, with: &global_changeset/2)
|> cast_embed(:rate_limit, with: &rate_changeset/2)
|> validate_required(~w(local_limit)a)
|> validate_number(:local_limit, greater_than: 0)
|> validate_single_partitioner()
|> Utils.enforce_keys(params, schema.__struct__)
end
@doc false
@spec default_local_limit(map(), Ecto.Schema.t()) :: non_neg_integer()
def default_local_limit(params, schema) do
cond do
is_integer(params[:limit]) ->
params[:limit]
is_integer(params[:global_limit]) ->
params[:global_limit]
is_integer(get_in(params, [:global_limit, :allowed])) ->
get_in(params, [:global_limit, :allowed])
true ->
schema.local_limit
end
end
@doc false
@spec validate_single_partitioner(Changeset.t()) :: Changeset.t()
def validate_single_partitioner(changeset) do
case {get_field(changeset, :global_limit), get_field(changeset, :rate_limit)} do
{%{partition: %{}}, %{partition: %{}}} ->
add_error(changeset, :global_limit, "only one limiter may have partitioning")
_ ->
changeset
end
end
@doc false
@spec global_changeset(Ecto.Schema.t(), integer() | map() | Keyword.t()) :: Changeset.t()
def global_changeset(schema, params) do
params = Map.update(params, :partition, nil, &normalize_partition/1)
schema
|> cast(params, ~w(allowed burst)a)
|> cast_embed(:partition, with: &partition_changeset/2)
|> validate_number(:allowed, greater_than: 0)
|> Utils.enforce_keys(params, schema.__struct__)
end
@doc false
@spec rate_changeset(Ecto.Schema.t(), map() | Keyword.t()) :: Changeset.t()
def rate_changeset(schema, params) do
params =
params
|> Map.update(:period, nil, &Utils.cast_period/1)
|> Map.update(:partition, nil, &normalize_partition/1)
|> Map.put_new_lazy(:window_time, fn -> DateTime.to_unix(DateTime.utc_now(), :second) end)
fields = schema.__struct__.__schema__(:fields) -- [:partition]
schema
|> cast(params, fields)
|> cast_embed(:partition, with: &partition_changeset/2)
|> validate_required(~w(allowed period)a)
|> validate_number(:allowed, greater_than: 0)
|> validate_number(:period, greater_than: 0)
|> clear_windows_on_algorithm_change()
|> Utils.enforce_keys(params, schema.__struct__)
end
defp clear_windows_on_algorithm_change(changeset) do
case get_change(changeset, :algorithm) do
nil -> changeset
_new_algorithm -> force_change(changeset, :windows, %{})
end
end
@doc false
@spec partition_changeset(Ecto.Schema.t(), map() | Keyword.t()) :: Changeset.t()
def partition_changeset(schema, params) do
params =
params
|> Map.new()
|> Map.update(:keys, [], &for(key <- &1, do: to_string(key)))
schema
|> cast(params, ~w(fields keys)a)
|> validate_required(~w(fields)a)
|> Utils.enforce_keys(params, schema.__struct__)
end
# Global Limit Helpers
@doc false
@spec cast_global_limit(pos_integer() | list() | map()) :: map()
def cast_global_limit(limit) when is_integer(limit) do
%{allowed: limit}
end
def cast_global_limit(opts), do: cast_rate_limit(opts)
# Rate Limit Helpers
@doc false
@spec cast_rate_limit(list() | map()) :: map()
def cast_rate_limit([[key | _] | _] = opts) when is_binary(key) do
opts
|> Enum.map(&List.to_tuple/1)
|> Map.new()
|> cast_rate_limit()
end
def cast_rate_limit(opts)
when is_map_key(opts, "algorithm") or
is_map_key(opts, "allowed") or
is_map_key(opts, "burst") or
is_map_key(opts, "period") or
is_map_key(opts, "partition") do
Map.new(opts, fn
{"algorithm", algorithm} ->
{:algorithm, String.to_existing_atom(algorithm)}
{"allowed", allowed} ->
{:allowed, allowed}
{"burst", allowed} ->
{:burst, allowed}
{"period", [time, unit]} ->
{:period, {time, unit}}
{"period", period} ->
{:period, period}
{"partition", [["fields", fields]]} ->
{:partition, fields: fields}
{"partition", [["fields", fields], ["keys", keys]]} ->
{:partition, fields: fields, keys: keys}
{"partition", field} when is_binary(field) ->
{:partition, fields: [field]}
end)
end
def cast_rate_limit(opts), do: opts
def normalize_partition(partition) do
cond do
is_nil(partition) ->
nil
is_map(partition) ->
partition
Keyword.keyword?(partition) and Keyword.has_key?(partition, :fields) ->
partition
true ->
partition
|> List.wrap()
|> Enum.reduce([fields: [], keys: []], fn
{field, keys}, opts when field in ~w(args meta)a ->
opts
|> Keyword.put(:keys, List.wrap(keys))
|> Keyword.update!(:fields, &[field | &1])
field, opts ->
Keyword.update!(opts, :fields, &[field | &1])
end)
end
end
# Helpers
defp to_clean_string(value) when is_reference(value) do
inspect(value)
end
defp to_clean_string(value) do
value
|> to_string()
|> String.trim_leading("Elixir.")
end
end
encoder = if Code.ensure_loaded?(JSON.Encoder), do: JSON.Encoder, else: Jason.Encoder
Protocol.derive(encoder, Oban.Pro.Producer, except: [:__meta__])
Protocol.derive(encoder, Oban.Pro.Producer.Meta.GlobalLimit)
Protocol.derive(encoder, Oban.Pro.Producer.Meta.GlobalLimit.Partition)
Protocol.derive(encoder, Oban.Pro.Producer.Meta.RateLimit)
Protocol.derive(encoder, Oban.Pro.Producer.Meta.RateLimit.Partition)