cover/Elixir.WalletNotifications.JobQueue.html

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
Line Hits Source