| 1 |
|
defmodule WalletNotifications.JobQueue do |
| 2 |
|
@moduledoc """ |
| 3 |
|
Lightweight in-process job queue for notification workers. |
| 4 |
|
|
| 5 |
|
Mirrors the semantics of `WalletSettlement.JobQueue` (see that module's docs) |
| 6 |
|
scoped to notification queues (:notifications_high, :notifications_low). |
| 7 |
|
|
| 8 |
|
In production, replace with Oban. Queue topology: |
| 9 |
|
|
| 10 |
|
| Queue | Priority | Max Concurrency | Max Attempts | |
| 11 |
|
|---------------------|----------|-----------------|--------------| |
| 12 |
|
| :notifications_high | 1 | 10 | 3 | |
| 13 |
|
| :notifications_low | 3 | 30 | 5 | |
| 14 |
|
""" |
| 15 |
|
use GenServer |
| 16 |
|
|
| 17 |
|
@table :wallet_notification_jobs |
| 18 |
|
|
| 19 |
|
@queue_config %{ |
| 20 |
|
notifications_high: %{priority: 1, max_concurrency: 10, max_attempts: 3}, |
| 21 |
|
notifications_low: %{priority: 3, max_concurrency: 30, max_attempts: 5} |
| 22 |
|
} |
| 23 |
|
|
| 24 |
:-( |
defstruct [:job_id, :queue, :worker, :args, :status, :attempts, :max_attempts, |
| 25 |
|
:error, :enqueued_at, :completed_at] |
| 26 |
|
|
| 27 |
:-( |
def start_link(opts), do: GenServer.start_link(__MODULE__, opts, name: __MODULE__) |
| 28 |
|
|
| 29 |
|
@doc "Enqueues a notification job. Returns `{:ok, job_id}`." |
| 30 |
|
@spec enqueue(queue :: atom(), worker :: module(), args :: map()) :: {:ok, String.t()} |
| 31 |
:-( |
def enqueue(queue, worker, args \\ %{}) do |
| 32 |
33 |
GenServer.call(__MODULE__, {:enqueue, queue, worker, args}) |
| 33 |
|
end |
| 34 |
|
|
| 35 |
|
@doc "Lists jobs for a queue, optionally filtered by status." |
| 36 |
|
@spec list_jobs(atom(), atom() | nil) :: [map()] |
| 37 |
:-( |
def list_jobs(queue, status \\ nil) do |
| 38 |
3 |
GenServer.call(__MODULE__, {:list_jobs, queue, status}) |
| 39 |
|
end |
| 40 |
|
|
| 41 |
|
@doc "Returns a job by ID." |
| 42 |
|
@spec get_job(String.t()) :: {:ok, map()} | {:error, :not_found} |
| 43 |
:-( |
def get_job(job_id), do: GenServer.call(__MODULE__, {:get_job, job_id}) |
| 44 |
|
|
| 45 |
|
@doc "Drains all available jobs in a queue synchronously. For test use." |
| 46 |
|
@spec drain_queue(atom()) :: %{completed: integer(), failed: integer(), discarded: integer()} |
| 47 |
|
def drain_queue(queue) do |
| 48 |
2 |
GenServer.call(__MODULE__, {:drain_queue, queue}, 30_000) |
| 49 |
|
end |
| 50 |
|
|
| 51 |
43 |
def reset, do: GenServer.call(__MODULE__, :reset) |
| 52 |
|
|
| 53 |
|
@impl true |
| 54 |
|
def init(_) do |
| 55 |
:-( |
:ets.new(@table, [:set, :protected, :named_table]) |
| 56 |
|
{:ok, %{}} |
| 57 |
|
end |
| 58 |
|
|
| 59 |
|
@impl true |
| 60 |
|
def handle_call({:enqueue, queue, worker, args}, _from, state) do |
| 61 |
33 |
cfg = Map.get(@queue_config, queue, %{max_attempts: 3}) |
| 62 |
33 |
job_id = WalletSharedKernel.TypedId.generate("jbn") |
| 63 |
33 |
job = %__MODULE__{ |
| 64 |
|
job_id: job_id, |
| 65 |
|
queue: queue, |
| 66 |
|
worker: worker, |
| 67 |
|
args: args, |
| 68 |
|
status: :available, |
| 69 |
|
attempts: 0, |
| 70 |
33 |
max_attempts: cfg.max_attempts, |
| 71 |
|
enqueued_at: DateTime.utc_now() |
| 72 |
|
} |
| 73 |
33 |
:ets.insert(@table, {job_id, job}) |
| 74 |
33 |
{:reply, {:ok, job_id}, state} |
| 75 |
|
end |
| 76 |
|
|
| 77 |
|
@impl true |
| 78 |
|
def handle_call({:list_jobs, queue, nil}, _from, state) do |
| 79 |
:-( |
jobs = :ets.tab2list(@table) |> Enum.map(fn {_, j} -> j end) |> Enum.filter(&(&1.queue == queue)) |
| 80 |
:-( |
{:reply, jobs, state} |
| 81 |
|
end |
| 82 |
|
|
| 83 |
|
@impl true |
| 84 |
|
def handle_call({:list_jobs, queue, status}, _from, state) do |
| 85 |
3 |
jobs = :ets.tab2list(@table) |> Enum.map(fn {_, j} -> j end) |
| 86 |
3 |
|> Enum.filter(&(&1.queue == queue and &1.status == status)) |
| 87 |
3 |
{:reply, jobs, state} |
| 88 |
|
end |
| 89 |
|
|
| 90 |
|
@impl true |
| 91 |
|
def handle_call({:get_job, job_id}, _from, state) do |
| 92 |
:-( |
result = case :ets.lookup(@table, job_id) do |
| 93 |
:-( |
[{_, j}] -> {:ok, j} |
| 94 |
:-( |
[] -> {:error, :not_found} |
| 95 |
|
end |
| 96 |
:-( |
{:reply, result, state} |
| 97 |
|
end |
| 98 |
|
|
| 99 |
|
@impl true |
| 100 |
|
def handle_call({:drain_queue, queue}, _from, state) do |
| 101 |
2 |
available = :ets.tab2list(@table) |> Enum.map(fn {_, j} -> j end) |
| 102 |
3 |
|> Enum.filter(&(&1.queue == queue and &1.status == :available)) |
| 103 |
|
|
| 104 |
2 |
results = Enum.reduce(available, %{completed: 0, failed: 0, discarded: 0}, fn job, acc -> |
| 105 |
3 |
job = %{job | status: :executing, attempts: job.attempts + 1} |
| 106 |
3 |
:ets.insert(@table, {job.job_id, job}) |
| 107 |
|
|
| 108 |
3 |
case apply(job.worker, :perform, [job.args]) do |
| 109 |
|
:ok -> |
| 110 |
2 |
done = %{job | status: :completed, completed_at: DateTime.utc_now()} |
| 111 |
2 |
:ets.insert(@table, {done.job_id, done}) |
| 112 |
2 |
%{acc | completed: acc.completed + 1} |
| 113 |
|
|
| 114 |
|
{:error, reason} -> |
| 115 |
1 |
if job.attempts >= job.max_attempts do |
| 116 |
:-( |
discarded = %{job | status: :discarded, error: inspect(reason)} |
| 117 |
:-( |
:ets.insert(@table, {discarded.job_id, discarded}) |
| 118 |
:-( |
%{acc | discarded: acc.discarded + 1} |
| 119 |
|
else |
| 120 |
1 |
retry = %{job | status: :available, error: inspect(reason)} |
| 121 |
1 |
:ets.insert(@table, {retry.job_id, retry}) |
| 122 |
1 |
%{acc | failed: acc.failed + 1} |
| 123 |
|
end |
| 124 |
|
end |
| 125 |
|
end) |
| 126 |
2 |
{:reply, results, state} |
| 127 |
|
end |
| 128 |
|
|
| 129 |
|
@impl true |
| 130 |
|
def handle_call(:reset, _from, state) do |
| 131 |
43 |
:ets.delete_all_objects(@table) |
| 132 |
43 |
{:reply, :ok, state} |
| 133 |
|
end |
| 134 |
|
end |