Packages

Elixir messaging library using RabbitMQ, providing easy-to-use modules for message publishing, consuming, and retry handling

Current section

Files

Jump to
hare_mq lib publisher publisher.ex
Raw

lib/publisher/publisher.ex

defmodule HareMq.Publisher do
defmodule Behaviour do
@callback publish_message(map() | binary()) :: :ok | {:error, reason :: :blocked | :closing}
end
@moduledoc """
Macro that injects a full RabbitMQ publisher GenServer into the calling module.
## Usage
defmodule MyApp.Producer do
use HareMq.Publisher,
routing_key: "my_key", # required
exchange: "my_exchange" # optional; nil publishes to the default exchange
end
## Options
| Option | Required | Description |
|---|---|---|
| `routing_key` | yes | AMQP routing key |
| `exchange` | no | Exchange name. Declared durable on connect. |
| `unique` | no | Deduplication config: `[period: ttl_ms_or_infinity, keys: [:field]]` |
| `connection_name` | no | Named connection for multi-vhost use (default `{:global, HareMq.Connection}`) |
| `dedup_cache_name` | no | Named dedup cache (default `{:global, HareMq.DedupCache}`) |
## Return values of `publish_message/1`
- `:ok` — message accepted by the broker.
- `{:error, :not_connected}` — no active channel yet.
- `{:error, {:encoding_failed, reason}}` — map could not be JSON-encoded.
- `{:ok, :duplicate}` — deduplication prevented publishing (message already seen).
"""
defmacro __using__(options) do
quote location: :keep, generated: true do
require Logger
use GenServer
@opts unquote(options)
@behaviour HareMq.Publisher.Behaviour
@before_compile unquote(__MODULE__)
@connection_name @opts[:connection_name] || {:global, HareMq.Connection}
@dedup_cache_name @opts[:dedup_cache_name] || {:global, HareMq.DedupCache}
if(is_nil(@opts[:routing_key])) do
raise "routing_key can not be empty"
end
@config [
routing_key: @opts[:routing_key],
exchange: @opts[:exchange]
]
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: {:global, __MODULE__})
|> HareMq.CodeFlow.successful_start()
end
def init(_) do
Process.flag(:trap_exit, true)
send(self(), :connect)
{:ok, nil}
end
def get_channel do
case GenServer.call({:global, __MODULE__}, :get_channel) do
nil -> {:error, :not_connected}
%AMQP.Channel{} = conn -> {:ok, conn}
end
end
def handle_call(:get_channel, _, %AMQP.Channel{} = conn) do
{:reply, conn, conn}
end
def handle_call(:get_channel, _, state) do
{:reply, state, state}
end
def handle_info(:connect, state) do
case HareMq.Connection.get_connection(@connection_name) do
{:ok, conn} ->
# Get notifications when the connection goes down
Process.monitor(conn.pid)
case AMQP.Channel.open(conn) do
{:ok, chan} ->
Process.monitor(chan.pid)
exchange = @config[:exchange]
if exchange do
AMQP.Exchange.declare(chan, exchange, :topic, durable: true)
end
Process.put(:__hare_mq_connect_attempt__, 0)
:telemetry.execute(
[:hare_mq, :publisher, :connected],
%{system_time: System.system_time()},
%{publisher: __MODULE__, exchange: @config[:exchange], routing_key: @config[:routing_key]}
)
{:noreply, chan}
_ ->
attempt = (Process.get(:__hare_mq_connect_attempt__) || 0) + 1
Process.put(:__hare_mq_connect_attempt__, attempt)
delay = backoff_delay(attempt)
Logger.error("[publisher] Failed to open channel. Reconnecting in #{delay}ms (attempt #{attempt})...")
Process.send_after(self(), :connect, delay)
{:noreply, state}
end
{:error, _} ->
attempt = (Process.get(:__hare_mq_connect_attempt__) || 0) + 1
Process.put(:__hare_mq_connect_attempt__, attempt)
delay = backoff_delay(attempt)
Logger.error("[publisher] Failed to connect. Reconnecting in #{delay}ms (attempt #{attempt})...")
Process.send_after(self(), :connect, delay)
{:noreply, nil}
end
end
def handle_info({:DOWN, _, :process, _pid, reason}, state) do
Logger.error("worker #{__MODULE__} was DOWN")
{:stop, {:connection_lost, reason}, state}
end
def handle_info({:EXIT, _pid, reason}, state) do
{:stop, {:connection_lost, reason}, state}
end
def handle_info(reason, state) do
{:stop, {:connection_lost, reason}, state}
end
def terminate(reason, state) do
Logger.debug(
"worker #{__MODULE__} was terminated #{inspect(reason)} with state #{inspect(state)}"
)
close_chan(state)
end
defp close_chan(%AMQP.Channel{} = channel) do
if Process.alive?(channel.pid) do
AMQP.Channel.close(channel)
end
end
defp close_chan(_), do: :ok
defp reconnect_interval,
do:
(Application.get_env(:hare_mq, :configuration) || [])[:reconnect_interval_ms] ||
10_000
defp backoff_delay(attempt) do
base = reconnect_interval()
delay = trunc(base * :math.pow(2, min(attempt - 1, 5)))
jitter = :rand.uniform(max(base, 1))
min(delay + jitter, 60_000)
end
end
end
defmacro __before_compile__(_env) do
quote location: :keep, generated: true do
defp publish(message) when is_map(message) do
case get_channel() do
{:error, :not_connected} ->
:telemetry.execute(
[:hare_mq, :publisher, :message, :not_connected],
%{system_time: System.system_time()},
%{publisher: __MODULE__, exchange: @config[:exchange], routing_key: @config[:routing_key]}
)
{:error, :not_connected}
{:ok, channel} ->
case Jason.encode(message) do
{:ok, encoded_message} ->
result =
AMQP.Basic.publish(
channel,
@config[:exchange],
@config[:routing_key],
encoded_message,
persistent: true,
content_type: "application/json"
)
:telemetry.execute(
[:hare_mq, :publisher, :message, :published],
%{system_time: System.system_time()},
%{publisher: __MODULE__, exchange: @config[:exchange], routing_key: @config[:routing_key]}
)
result
{:error, reason} ->
{:error, {:encoding_failed, reason}}
end
end
end
defp publish(message) when is_binary(message) do
case get_channel() do
{:error, :not_connected} ->
:telemetry.execute(
[:hare_mq, :publisher, :message, :not_connected],
%{system_time: System.system_time()},
%{publisher: __MODULE__, exchange: @config[:exchange], routing_key: @config[:routing_key]}
)
{:error, :not_connected}
{:ok, channel} ->
result =
AMQP.Basic.publish(
channel,
@config[:exchange],
@config[:routing_key],
message,
persistent: true
)
:telemetry.execute(
[:hare_mq, :publisher, :message, :published],
%{system_time: System.system_time()},
%{publisher: __MODULE__, exchange: @config[:exchange], routing_key: @config[:routing_key]}
)
result
end
end
@doc """
Func for publishing messages.
## Examples
defmodule MyPublisher do
use HareMq.Publisher,
exchange: "my_exchange",
routing_key: "my_routing_key"
unique: [
period: :infinity,
keys: [:project_id]
]
def publish() do
message = %{key: "value"}
HareMq.Publisher.publish_message(message)
end
end
"""
def publish_message(message) do
unique = Keyword.get(@opts, :unique, [])
deduplication_ttl = Keyword.get(unique, :period, nil)
deduplication_keys = Keyword.get(unique, :keys, [])
if(deduplication_ttl) do
unless(HareMq.DedupCache.is_dup?(message, deduplication_keys, @dedup_cache_name)) do
HareMq.DedupCache.add(message, deduplication_ttl, deduplication_keys, @dedup_cache_name)
publish(message)
else
{:ok, :duplicate}
end
else
publish(message)
end
end
end
end
end