Current section
Files
Jump to
Current section
Files
lib/publisher.ex
defmodule GenRMQ.Publisher do
@moduledoc """
A behaviour module for implementing the RabbitMQ publisher
"""
use GenServer
use AMQP
require Logger
alias GenRMQ.Queue
# list of fields permitted in message metadata at top level
@metadata_fields ~w(
mandatory
immediate
content_type
content_encoding
persistent
priority
correlation_id
reply_to
expiration
message_id
timestamp
type
user_id
app_id
cluster_id
)a
##############################################################################
# GenRMQ.Publisher callbacks
##############################################################################
@doc """
Invoked to provide publisher configuration
## Return values
### Mandatory:
`uri` - RabbitMQ uri
`exchange` - name or `{type, name}` of the target exchange. If it does not exist, it will be created.
For valid exchange types see `GenRMQ.Binding`.
### Optional:
`app_id` - publishing application ID. By default it is `:gen_rmq`.
`enable_confirmations` - activates publishing confirmations on the channel. Confirmations are disabled by default.
`max_confirmation_wait_time` - maximum time in milliseconds to wait for a confirmation. By default it is 5_000 (5s).
## Examples:
```
def init() do
[
exchange: "gen_rmq_exchange",
uri: "amqp://guest:guest@localhost:5672"
app_id: :my_app_id,
enable_confirmations: true,
max_confirmation_wait_time: 5_000
]
end
```
"""
@callback init() :: [
exchange: GenRMQ.Binding.exchange(),
uri: String.t(),
app_id: atom,
enable_confirmations: boolean,
max_confirmation_wait_time: integer
]
##############################################################################
# GenRMQ.Publisher API
##############################################################################
@doc """
Starts `GenRMQ.Publisher` with given callback module linked to the current
process
`module`- callback module implementing `GenRMQ.Publisher` behaviour
## Options
* `:name` - used for name registration
## Return values
If the publisher is successfully created and initialized, this function returns
`{:ok, pid}`, where `pid` is the PID of the publisher. If a process with the
specified publisher name already exists, this function returns
`{:error, {:already_started, pid}}` with the PID of that process.
## Examples:
```
GenRMQ.Publisher.start_link(TestPublisher, name: :publisher)
```
"""
@spec start_link(module :: module(), options :: Keyword.t()) :: {:ok, pid} | {:error, term}
def start_link(module, options \\ []) do
GenServer.start_link(__MODULE__, %{module: module}, options)
end
@doc """
Publishes given message
`publisher` - name or PID of the publisher
`message` - raw payload to deliver
`routing_key` - optional routing key to set for given message
`metadata` - optional metadata to set for given message. Keys that \
are not allowed in metadata are moved under the `:headers` \
field. Do not include a `:headers` field here: it will be \
created automatically with all non-standard keys that you have \
provided. For a full list of options see `AMQP.Basic.publish/5`
## Examples:
```
GenRMQ.Publisher.publish(TestPublisher, "{\"msg\": \"hello\"})
```
"""
@spec publish(
publisher :: atom | pid,
message :: String.t(),
routing_key :: String.t(),
metadata :: Keyword.t()
) :: :ok | {:ok, :confirmed} | {:error, reason :: :blocked | :closing | :confirmation_timeout}
def publish(publisher, message, routing_key \\ "", metadata \\ []) do
GenServer.call(publisher, {:publish, message, routing_key, metadata})
end
@doc """
Get the number of active consumers on the provided queue. If a nonexistent
queue is provided, an error will be raised.
`publisher` - name or PID of the publisher
`queue` - name of the queue
"""
@spec consumer_count(publisher :: atom | pid, queue :: String.t()) :: integer() | no_return()
def consumer_count(publisher, queue) do
GenServer.call(publisher, {:consumer_count, queue})
end
@doc """
Return whether the provided queue is empty or not. If a nonexistent
queue is provided, an error will be raised.
`publisher` - name or PID of the publisher
`queue` - name of the queue
"""
@spec empty?(publisher :: atom | pid, queue :: String.t()) :: boolean() | no_return()
def empty?(publisher, queue) do
GenServer.call(publisher, {:empty?, queue})
end
@doc """
Get the number of messages currently ready for delivery in the provided queue.
If a nonexistent queue is provided, an error will be raised.
`publisher` - name or PID of the publisher
`queue` - name of the queue
"""
@spec message_count(publisher :: atom | pid, queue :: String.t()) :: integer() | no_return()
def message_count(publisher, queue) do
GenServer.call(publisher, {:message_count, queue})
end
@doc """
Drop all message from the provided queue. If a nonexistent
queue is provided, an error will be raised.
`publisher` - name or PID of the publisher
`queue` - name of the queue
"""
@spec purge(publisher :: atom | pid, queue :: String.t()) :: {:ok, map} | Basic.error()
def purge(publisher, queue) do
GenServer.call(publisher, {:purge, queue})
end
@doc """
Get the message count and consumer count for a particular queue. If a nonexistent
queue is provided, an error will be raised.
`publisher` - name or PID of the publisher
`queue` - name of the queue
"""
@spec status(publisher :: atom | pid, queue :: String.t()) :: {:ok, map} | Basic.error()
def status(publisher, queue) do
GenServer.call(publisher, {:status, queue})
end
##############################################################################
# GenServer callbacks
##############################################################################
@doc false
@impl GenServer
def init(%{module: module} = initial_state) do
Process.flag(:trap_exit, true)
config = apply(module, :init, [])
state = Map.merge(initial_state, %{config: config})
send(self(), :init)
{:ok, state}
end
@doc false
@impl GenServer
def handle_call({:publish, msg, key, metadata}, _from, %{channel: channel, config: config} = state) do
metadata = config |> base_metadata() |> merge_metadata(metadata)
start_time = System.monotonic_time()
exchange = config[:exchange]
emit_publish_start_event(start_time, exchange, msg)
publish_result = Basic.publish(channel, GenRMQ.Binding.exchange_name(exchange), key, msg, metadata)
case publish_result do
:ok -> emit_publish_stop_event(start_time, exchange, msg)
{kind, reason} -> emit_publish_error_event(start_time, exchange, msg, kind, reason)
end
confirmation_result = wait_for_confirmation(channel, config)
{:reply, publish_result(publish_result, confirmation_result), state}
end
@doc false
@impl GenServer
def handle_call({:consumer_count, queue}, _from, %{channel: channel} = state) do
result = Queue.consumer_count(channel, queue)
{:reply, result, state}
end
@doc false
@impl GenServer
def handle_call({:empty?, queue}, _from, %{channel: channel} = state) do
result = Queue.empty?(channel, queue)
{:reply, result, state}
end
@doc false
@impl GenServer
def handle_call({:message_count, queue}, _from, %{channel: channel} = state) do
result = Queue.message_count(channel, queue)
{:reply, result, state}
end
@doc false
@impl GenServer
def handle_call({:purge, queue}, _from, %{channel: channel} = state) do
result = Queue.purge(channel, queue)
{:reply, result, state}
end
@doc false
@impl GenServer
def handle_call({:status, queue}, _from, %{channel: channel} = state) do
result = Queue.status(channel, queue)
{:reply, result, state}
end
@doc false
@impl GenServer
def handle_info(:init, %{module: module, config: config}) do
Logger.info("[#{module}]: Setting up publisher connection and configuration")
{:ok, state} = setup_publisher(%{module: module, config: config})
{:noreply, state}
end
@doc false
@impl GenServer
def handle_info({:DOWN, _ref, :process, _pid, reason}, %{module: module, config: config}) do
Logger.info("[#{module}]: RabbitMQ connection is down! Reason: #{inspect(reason)}")
emit_connection_down_event(module, reason)
{:ok, state} = setup_publisher(%{module: module, config: config})
{:noreply, state}
end
@doc false
@impl GenServer
def terminate(reason, %{module: module, conn: conn, channel: channel}) do
Logger.debug("[#{module}]: Terminating publisher, reason: #{inspect(reason)}")
Channel.close(channel)
Connection.close(conn)
end
##############################################################################
# Helpers
##############################################################################
defp setup_publisher(%{module: module, config: config} = state) do
start_time = System.monotonic_time()
exchange = config[:exchange]
emit_connection_start_event(start_time, exchange)
{:ok, conn} = connect(state)
{:ok, channel} = Channel.open(conn)
GenRMQ.Binding.declare_exchange(channel, exchange)
with_confirmations = Keyword.get(config, :enable_confirmations, false)
:ok = activate_confirmations(channel, with_confirmations)
emit_connection_stop_event(start_time, exchange)
{:ok, %{channel: channel, module: module, config: config, conn: conn}}
end
defp emit_connection_down_event(module, reason) do
start_time = System.monotonic_time()
measurements = %{time: start_time}
metadata = %{module: module, reason: reason}
:telemetry.execute([:gen_rmq, :publisher, :connection, :down], measurements, metadata)
end
defp emit_connection_start_event(start_time, exchange) do
measurements = %{time: start_time}
metadata = %{exchange: exchange}
:telemetry.execute([:gen_rmq, :publisher, :connection, :start], measurements, metadata)
end
defp emit_connection_stop_event(start_time, exchange) do
stop_time = System.monotonic_time()
measurements = %{time: stop_time, duration: stop_time - start_time}
metadata = %{exchange: exchange}
:telemetry.execute([:gen_rmq, :publisher, :connection, :stop], measurements, metadata)
end
defp emit_publish_start_event(start_time, exchange, message) do
measurements = %{time: start_time}
metadata = %{exchange: exchange, message: message}
:telemetry.execute([:gen_rmq, :publisher, :message, :start], measurements, metadata)
end
defp emit_publish_stop_event(start_time, exchange, message) do
stop_time = System.monotonic_time()
measurements = %{time: stop_time, duration: stop_time - start_time}
metadata = %{exchange: exchange, message: message}
:telemetry.execute([:gen_rmq, :publisher, :message, :stop], measurements, metadata)
end
defp emit_publish_error_event(start_time, exchange, message, kind, reason) do
stop_time = System.monotonic_time()
measurements = %{time: stop_time, duration: stop_time - start_time}
metadata = %{exchange: exchange, message: message, kind: kind, reason: reason}
:telemetry.execute([:gen_rmq, :publisher, :message, :error], measurements, metadata)
end
defp activate_confirmations(_, false), do: :ok
defp activate_confirmations(channel, true), do: AMQP.Confirm.select(channel)
defp wait_for_confirmation(channel, config) do
with_confirmations = Keyword.get(config, :enable_confirmations, false)
max_wait_time = config |> Keyword.get(:max_confirmation_wait_time, 5_000)
wait_for_confirmation(channel, with_confirmations, max_wait_time)
end
defp wait_for_confirmation(_, false, _), do: :confirmation_disabled
defp wait_for_confirmation(channel, true, max_wait_time), do: AMQP.Confirm.wait_for_confirms(channel, max_wait_time)
defp publish_result(:ok, :confirmation_disabled), do: :ok
defp publish_result(:ok, true = _confirmed), do: {:ok, :confirmed}
defp publish_result(:ok, :timeout), do: {:error, :confirmation_timeout}
defp publish_result(error, _), do: error
defp connect(%{module: module, config: config} = state) do
case Connection.open(config[:uri]) do
{:ok, conn} ->
Process.monitor(conn.pid)
{:ok, conn}
{:error, e} ->
Logger.error("[#{module}]: Failed to connect to RabbitMQ, reason: #{inspect(e)}")
:timer.sleep(5000)
connect(state)
end
end
# Put standard metadata fields to top level, everything else into headers
defp merge_metadata(base, custom) do
{metadata, headers} = custom |> Keyword.split(@metadata_fields)
# take "base" fields and put them into metadata top-level
base
# put custom values, override base values on conflict
|> Keyword.merge(metadata)
# put all custom fields in the headers
|> Keyword.merge(headers: headers)
end
defp base_metadata(config) do
[
timestamp: DateTime.to_unix(DateTime.utc_now(), :millisecond),
app_id: config |> app_id() |> Atom.to_string(),
content_type: "application/json"
]
end
defp app_id(config) do
config[:app_id] || :gen_rmq
end
##############################################################################
##############################################################################
##############################################################################
end