prop/vendor/oban_pro/lib/oban/pro/partition.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

195 lines
5.9 KiB
Elixir

defmodule Oban.Pro.Partition do
@moduledoc false
@behaviour Oban.Pro.Handler
import Ecto.Query
alias Ecto.Changeset
alias Oban.{Config, Job, Repo}
alias Oban.Pro.{Producer, Queue, Utils}
@keys_cache_ttl Application.compile_env(:oban_pro, [__MODULE__, :keys_cache_ttl], 3_000)
@keys_limit_mlt Application.compile_env(:oban_pro, [__MODULE__, :keys_limit_multiplier], 3)
@conf_tab Module.concat(__MODULE__, Conf)
@keys_tab Module.concat(__MODULE__, Keys)
@impl Oban.Pro.Handler
def on_start do
:ets.new(@conf_tab, [:set, :named_table, :public, read_concurrency: true])
:ets.new(@keys_tab, [:set, :named_table, :public, read_concurrency: true])
# Clear the cache with a timeout, as not all queues are guaranteed to be running on a given
# node and the cache won't clear naturally.
:timer.apply_interval(@keys_cache_ttl, __MODULE__, :__clear_cache__, [@keys_cache_ttl])
:telemetry.attach(
"oban.pro.partition",
[:oban, :engine, :put_meta, :stop],
&__MODULE__.handle_event/4,
nil
)
end
@impl Oban.Pro.Handler
def on_stop do
:telemetry.detach("oban.pro.partition")
end
def __clear_cache__(ttl) do
now = System.monotonic_time(:millisecond)
:ets.select_delete(@keys_tab, [
{{:_, :_, :"$3"}, [{:>=, {:-, now, :"$3"}, ttl}], [true]}
])
end
def handle_event(_event, _measure, %{conf: conf, key: key, meta: meta}, nil) do
# Bust the partition cache on any change to a partitionable limit. This is rare enough an
# event that it's better to be safe.
if key in ~w(global_limit rate_limit)a do
:ets.delete(@conf_tab, {conf.name, meta.queue})
end
end
@spec get_key(Job.t() | Job.changeset(), any()) :: any()
def get_key(changeset, default \\ nil)
def get_key(%{changes: %{meta: %{partition_key: key}}}, _default), do: key
def get_key(%{meta: %{"partition_key" => key}}, _default), do: key
def get_key(_changeset, default), do: default
@spec with_partition_meta(Job.changeset(), Config.t()) :: Job.changeset()
def with_partition_meta(changeset, conf) do
queue = Changeset.get_field(changeset, :queue, "default")
case config_for_queue(conf, queue) do
:missing ->
changeset
partition ->
part_meta = %{partition: true, partition_key: gen_key(changeset, partition)}
meta =
changeset
|> Changeset.get_change(:meta, %{})
|> Map.merge(part_meta)
Changeset.put_change(changeset, :meta, meta)
end
end
@spec gen_key(Job.changeset(), %{fields: [atom()], keys: [atom()]}) :: String.t()
def gen_key(changeset, %{fields: fields, keys: keys}) do
fields
|> Enum.sort()
|> Enum.map(fn
:args -> Utils.take_keys(changeset, :args, keys)
:meta -> Utils.take_keys(changeset, :meta, keys)
:worker -> Changeset.get_field(changeset, :worker)
end)
|> Utils.hash64()
end
@spec available_keys(Config.t(), String.t(), pos_integer()) :: [String.t()]
def available_keys(conf, queue, limit) do
timed_cache(conf.name, queue, fn ->
query_limit = @keys_limit_mlt * limit
subquery =
Job
|> where([j], j.state == "available" and j.queue == ^queue)
|> where([j], fragment("meta \\? 'partition_key'"))
|> group_by([j], fragment("meta->>'partition_key'"))
|> order_by([j], asc: min(j.priority), asc: min(j.scheduled_at))
|> select([j], %{partition_key: fragment("meta->>'partition_key'")})
|> limit(^query_limit)
partitioned_keys =
subquery
|> subquery()
|> select([s], s.partition_key)
|> then(&Repo.all(conf, &1))
["none" | partitioned_keys]
end)
end
defp timed_cache(name, queue, fun) do
cache_key = {name, queue}
now = System.monotonic_time(:millisecond)
case :ets.lookup(@keys_tab, cache_key) do
[{^cache_key, keys, timestamp}] when now - timestamp < @keys_cache_ttl ->
keys
_ ->
keys = fun.()
:ets.insert(@keys_tab, {cache_key, keys, now})
keys
end
end
@doc """
We can't guarantee that the queue is running locally, running on a current node, or running at
all. This will check the following locations:
- From a locally running producer
- From another node's producer in the database
- From a DynamicQueues entry in the database
"""
@spec config_for_queue(Config.t(), String.t()) :: :missing | %{fields: list(), keys: list()}
def config_for_queue(conf, queue) do
# Partitioning isn't used for draining and there's no point in hitting the database just to
# find out there's no config available.
if Process.get(:oban_draining) do
:missing
else
case :ets.lookup(@conf_tab, {conf.name, queue}) do
[{_key, value}] ->
value
[] ->
value =
with :missing <- reg_config(conf, queue),
:missing <- pro_config(conf, queue),
do: dyn_config(conf, queue)
:ets.insert(@conf_tab, {{conf.name, queue}, value})
value
end
end
end
defp reg_config(conf, queue) do
case Registry.meta(Oban.Registry, {conf.name, {:producer, queue}}) do
{:ok, %{meta: %{global_limit: %{partition: %{} = partition}}}} -> partition
{:ok, %{meta: %{rate_limit: %{partition: %{} = partition}}}} -> partition
_other -> :missing
end
end
defp pro_config(conf, queue) do
query =
Producer
|> where(queue: ^queue)
|> order_by(desc: :started_at)
|> limit(1)
case Repo.one(conf, query) do
%{meta: %{global_limit: %{partition: %{} = partition}}} -> partition
%{meta: %{rate_limit: %{partition: %{} = partition}}} -> partition
_other -> :missing
end
end
defp dyn_config(conf, queue) do
case Repo.get_by(conf, Queue, name: queue) do
%{opts: %{global_limit: %{partition: %{} = partition}}} -> partition
%{opts: %{rate_limit: %{partition: %{} = partition}}} -> partition
_other -> :missing
end
end
end