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