Current section
Files
Jump to
Current section
Files
lib/subscribex.ex
defmodule Subscribex do
@moduledoc """
Subscribex is an abstraction for creating workers that subscribe to
a RabbitMQ queue, and perform some kind of work based on those messages.
Some things to remember:
1. If your function crashes, the exception will be caught, and Subscribex
will ack the message to prevent the message from being resent multiple
times (potentially crashing the application)
2. If your function does not crash, Subscribex will automatically ack if
your function returns {:ok, :ack}
3. If your function does not crash but needs to hand off processing to a
background job, return {:ok, :manual}, and YOUR APP will be responsible
for acking the message (using AMQP.Basic.ack/2)
3. You must define 4 functions in your module:
1. exchange/0, which returns the name of the exchange your queue is
bound to
2. queue/0, which returns the name of the queue you're subscribing to.
3. routing_key/0, which returns the name of the routing key for your
queue/exchange combination.
4. handle_payload/3, which takes a map from the parsed JSON, the
delivery tag (for use with AMQP.Basic.ack), and the AMQP channel
your subscriber is using (also for use with AMQP.Basic.ack)
"""
@type payload :: term
@type body :: String.t
@callback marshal(body) :: {:ok, payload} | {:error, term}
@callback exchange() :: String.t
@callback queue() :: String.t
@callback routing_key() :: String.t
@callback handle_payload(payload) :: term
@callback handle_payload(payload, delivery_tag, channel)
:: {:ok, :ack} |
{:ok, :manual}
@type delivery_tag :: any
@type channel :: %AMQP.Channel{}
use GenServer
require Logger
defmodule State do
defstruct channel: nil,
connection: nil,
host: nil,
module: nil,
monitor: nil
end
@reconnect_interval :timer.seconds(30)
def start_link(host, callback_module) do
GenServer.start_link(__MODULE__, [host, callback_module], [])
end
def init([host, callback_module]) do
Logger.debug("Starting subscriber: #{inspect callback_module}")
{:ok, connection, channel, monitor} = setup(host, callback_module)
state = %State{channel: channel,
connection: connection,
host: host,
module: callback_module,
monitor: monitor}
Logger.debug("Subscribex created")
{:ok, state}
end
def handle_info({:basic_consume_ok, %{consumer_tag: _consumer_tag}}, state) do
{:noreply, state}
end
def handle_info({:basic_cancel, %{consumer_tag: _consumer_tag}}, state) do
{:noreply, state}
end
def handle_info({:basic_deliver, body, %{delivery_tag: tag, redelivered: _redelivered}}, state) do
Logger.debug("Basic Deliver")
case apply(state.module, :marshal, [body]) do
{:ok, payload} ->
Logger.debug("Successfully decoded basic deliver")
delegate(payload, tag, state)
{:error, reason} ->
error_message = "Parsing payload: #{body} failed because: #{inspect reason}"
Logger.error(error_message)
end
{:noreply, state}
end
def handle_info({:DOWN, monitor, :process, _pid, _reason},
%State{monitor: monitor, host: host, module: callback_module} = state) do
Logger.warn("Rabbit connection died. Trying to restart")
{:ok, connection, channel, monitor} = setup(host, callback_module)
Logger.info("Rabbit connection reestablished.")
state = %{state | connection: connection, channel: channel, monitor: monitor}
{:noreply, state}
end
def handle_info(message, state) do
Logger.warn("Received unknown message: " <> inspect(message))
{:noreply, state}
end
defp delegate(payload, tag, state) do
try do
Logger.debug("Delegating to: #{inspect state.module}, :handle_payload, [#{inspect payload}]")
response =
if apply(state.module, :auto_ack?, []) do
apply(state.module, :handle_payload, [payload])
{:ok, :ack}
else
apply state.module, :handle_payload, [payload, tag, state.channel]
end
Logger.debug("RESPONSE: #{inspect response}")
handle_response(response, tag, state.channel)
rescue
error ->
Logger.error(inspect error)
AMQP.Basic.ack(state.channel, tag)
end
end
defp handle_response({:ok, :ack}, delivery_tag, channel) do
AMQP.Basic.ack(channel, delivery_tag)
end
defp handle_response({:ok, :manual}, _delivery_tag, _channel) do
end
defp setup(host, callback_module) do
{:ok, connection} = connect(host)
{:ok, channel} = AMQP.Channel.open(connection)
monitor = Process.monitor(connection.pid)
queue = apply(callback_module, :queue, [])
exchange = apply(callback_module, :exchange, [])
routing_key = apply(callback_module, :routing_key, [])
AMQP.Basic.qos(channel, prefetch_count: 10)
Logger.debug("Declaring queue: #{queue}")
AMQP.Queue.declare(channel, queue, durable: true)
AMQP.Exchange.topic(channel, exchange)
AMQP.Queue.bind(channel, queue, exchange, [routing_key: routing_key])
{:ok, _consumer_tag} = AMQP.Basic.consume(channel, queue)
{:ok, connection, channel, monitor}
end
defp connect(host) do
case AMQP.Connection.open(host) do
{:ok, connection} -> {:ok, connection}
{:error, _} ->
:timer.sleep(@reconnect_interval)
connect(host)
end
end
defmacro __using__(_arg) do
quote do
@behaviour Subscribex
use AMQP
require Subscribex
import Subscribex
def handle_payload(payload), do: raise "undefined callback handle_payload/1"
def handle_payload(payload, delivery_tag, channel), do: raise "undefined callback handle_payload/3"
def deserialize(body), do: Poison.decode(body)
def auto_ack?, do: true
defoverridable [marshal: 1]
defoverridable [auto_ack?: 0]
defoverridable [handle_payload: 1]
defoverridable [handle_payload: 3]
end
end
defmacro routing_key(routing_key) do
quote do
def routing_key, do: unquote(routing_key)
end
end
defmacro queue(queue_name) do
quote do
def queue, do: unquote(queue_name)
end
end
defmacro exchange(exchange_name) do
quote do
def exchange, do: unquote(exchange_name)
end
end
defmacro manual_ack! do
quote do
def auto_ack?, do: false
end
end
end