├── .gitignore ├── CHANGELOG.md ├── README.md ├── lib ├── fireworks.ex └── fireworks │ ├── channel.ex │ ├── connection.ex │ ├── consumer.ex │ ├── logger.ex │ └── publisher.ex ├── mix.exs ├── mix.lock └── test ├── fireworks_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.6.1 4 | * fixed typo in consumer (PR #7) 5 | 6 | ## v0.6.0 7 | * Configure multiple hosts via config 8 | * Introduced a Fireworks Consumer pattern 9 | 10 | ## v0.5.2 11 | * Merged PRs to update deps and remove warnings 12 | 13 | ## v0.5.1 14 | * Allow global json options to be defined. 15 | 16 | ## v0.5.0 17 | * Remove direct references to Poison. json_library / json_opts should be passed in config 18 | If these options are passed, it will decide / encode using the json lib and options 19 | Otherwise, the message will be transmitted as a plain binary 20 | 21 | ## v0.4.0 22 | * Added `Fireworks.Logger` backend for rabbit logging. 23 | 24 | ## v0.3.4 25 | * Handle :normal shutdown for Task 26 | 27 | ## v0.3.3 28 | * Add poolboy to application list 29 | 30 | ## v0.3.2 31 | * Channel state was not being set on EXIT 32 | 33 | ## v0.3.1 34 | * Fixed issue with spec return types 35 | 36 | ## v0.3.0 37 | * Pooled connections 38 | 39 | ## v0.2.0 40 | * Changed underlying connection and registration scheme 41 | * Stability! 42 | 43 | ## v0.1.0 44 | * Initial Release 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #Fireworks 2 | 3 | Fireworks is a framework for providing simplicity in connecting, configuring and consuming RabbitMQ queues. It is intended to provide automatic exchange -> queue bindings and handle failovers. 4 | 5 | ## Usage 6 | 7 | Fireworks requires information about the main connection to be defined in the application environment 8 | ```elixir 9 | config :fireworks, :connection, 10 | host: "rabbitmq.local", 11 | #hosts: ["rabbitmq1.local", "rabbitmq2.local"], 12 | username: "guest", 13 | password: "guest", 14 | heartbeat: 30 15 | ``` 16 | 17 | This information will be used at application start to establish a connection with the RabbitMQ Node. 18 | 19 | Fireworks consumers are required to be declared through their own modules. 20 | ```elixir 21 | config :my_app, MyApp.WorkQueue, 22 | consumers: 5, 23 | prefetch: 100 24 | ``` 25 | 26 | Each fireworks module requires the following behavior methods to be declared 27 | ```elixir 28 | defmodule MyApp.WorkQueue do 29 | use Fireworks.Channel, otp_app: :my_app 30 | 31 | require Logger 32 | 33 | def config(channel) do 34 | exchange = "my_exchange" 35 | queue = "my_queue" 36 | 37 | Exchange.topic(channel, exchange, durable: true) 38 | 39 | Queue.declare( 40 | channel, 41 | error_queue, 42 | durable: false 43 | ) 44 | 45 | Queue.bind(channel, queue, exchange, routing_key: "#") 46 | queue 47 | end 48 | 49 | def consume(%{} = message, %{delivery_tag: tag}) do 50 | Logger.debug "Process Message: #{inspect message}" 51 | ack tag 52 | end 53 | 54 | def consume(msg, _), do: Logger.error "Invalid Message: #{inspect msg}" 55 | end 56 | ``` 57 | 58 | If a connection to the rabbit node is lost, fireworks will automatically attempt a reconnection to the node. 59 | 60 | Optionally you can specify task timeout, default is 60000 miliseconds. 61 | 62 | ```elixir 63 | defmodule MyApp.WorkQueue do 64 | use Fireworks.Channel, [otp_app: :my_app, task_timeout: 1000] 65 | end 66 | ``` 67 | 68 | 69 | ## Logging 70 | Fireworks has a Logger backend which can be used to send logs to an exchange on rabbit using fireworks publisher. To use this backend you must configure `:logger` 71 | 72 | In your config.exs 73 | ```elixir 74 | config :logger, 75 | backends: [:console, {Fireworks.Logger, otp_app: :my_app, exchange: "logger"}] 76 | ``` 77 | 78 | 79 | ## Contributing 80 | 81 | The easiest way to test and contribute to the fireworks library is to develop and test the features through an example app that is leveraging the Fireworks behaviour. A test suite is in the works for this framework. 82 | -------------------------------------------------------------------------------- /lib/fireworks.ex: -------------------------------------------------------------------------------- 1 | defmodule Fireworks do 2 | use Supervisor 3 | use AMQP 4 | require Logger 5 | 6 | @pool_size 5 7 | @conn_pool_name Fireworks.ConnPool 8 | @pub_pool_name Fireworks.PubPool 9 | 10 | 11 | 12 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 13 | # for more information on OTP Applications 14 | def start(_type, _args) do 15 | start_link() 16 | end 17 | 18 | def start_link do 19 | opts = Application.get_env(:fireworks, :connection) || [] 20 | Supervisor.start_link(__MODULE__, opts, name: Fireworks.Supervisor) 21 | end 22 | 23 | def init(opts) do 24 | 25 | conn_pool_opts = [ 26 | name: {:local, @conn_pool_name}, 27 | worker_module: Fireworks.Connection, 28 | size: opts[:pool_size] || @pool_size, 29 | strategy: :fifo, 30 | max_overflow: 0 31 | ] 32 | 33 | pub_pool_opts = [ 34 | name: {:local, @pub_pool_name}, 35 | worker_module: Fireworks.Publisher, 36 | size: opts[:pool_size] || @pool_size, 37 | max_overflow: 0 38 | ] 39 | children = [ 40 | :poolboy.child_spec(@conn_pool_name, conn_pool_opts, [opts]), 41 | :poolboy.child_spec(@pub_pool_name, pub_pool_opts, @conn_pool_name), 42 | #worker(Fireworks.Server, [name, conn_pool_name, opts]) 43 | ] 44 | 45 | 46 | #supervise children, strategy: :one_for_one 47 | Supervisor.init(children, strategy: :one_for_one) 48 | 49 | end 50 | 51 | def with_conn(pool_name, fun) when is_function(fun, 1) do 52 | case get_conn(pool_name, 0, @pool_size) do 53 | {:ok, conn} -> fun.(conn) 54 | {:error, reason} -> {:error, reason} 55 | end 56 | end 57 | 58 | defp get_conn(pool_name, retry_count, max_retry_count) do 59 | case :poolboy.transaction(pool_name, &GenServer.call(&1, :conn)) do 60 | {:ok, conn} -> {:ok, conn} 61 | {:error, _reason} when retry_count < max_retry_count -> 62 | get_conn(pool_name, retry_count + 1, max_retry_count) 63 | {:error, reason} -> {:error, reason} 64 | end 65 | end 66 | 67 | def publish(exchange, routing_key, payload, options \\ []) do 68 | case get_chan(@pub_pool_name, 0, @pool_size) do 69 | {:ok, chan} -> Basic.publish(chan, exchange, routing_key, payload,options) 70 | {:error, reason} -> {:error, reason} 71 | end 72 | end 73 | 74 | defp get_chan(pool_name, retry_count, max_retry_count) do 75 | case :poolboy.transaction(pool_name, &GenServer.call(&1, :chan)) do 76 | {:ok, chan} -> 77 | IO.puts "Got channel" 78 | {:ok, chan} 79 | {:error, reason} when retry_count < max_retry_count -> 80 | IO.puts "Error getting channel #{inspect reason}" 81 | get_chan(pool_name, retry_count + 1, max_retry_count) 82 | {:error, reason} -> 83 | IO.puts "Error getting channel Done" 84 | {:error, reason} 85 | end 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /lib/fireworks/channel.ex: -------------------------------------------------------------------------------- 1 | defmodule Fireworks.Channel do 2 | @callback config(channel :: AMQP.Channel.t()) :: String.t() 3 | @callback consume(payload :: map, metadata :: map) :: any 4 | 5 | defmacro __using__(opts) do 6 | quote do 7 | alias AMQP.Connection 8 | alias AMQP.Channel 9 | alias AMQP.Exchange 10 | alias AMQP.Queue 11 | alias AMQP.Basic 12 | alias AMQP.Confirm 13 | 14 | unquote(config(opts)) 15 | unquote(server()) 16 | end 17 | end 18 | 19 | defp config(opts) do 20 | quote do 21 | var!(otp_app) = unquote(opts)[:otp_app] || raise "channel expects :otp_app to be given" 22 | var!(task_timeout) = unquote(opts)[:task_timeout] || 60_000 23 | var!(config) = Application.get_env(var!(otp_app), __MODULE__) 24 | end 25 | end 26 | 27 | defp server do 28 | quote do 29 | use GenServer 30 | 31 | @reconnect_after_ms 5_000 32 | @task_timeout var!(task_timeout) 33 | @behaviour Fireworks.Channel 34 | @conn_conf var!(config) 35 | @prefetch_count 10 36 | 37 | def __connection_config__(), do: @conn_conf 38 | 39 | require Logger 40 | 41 | def start_link() do 42 | GenServer.start_link(__MODULE__, __connection_config__(), name: __MODULE__) 43 | end 44 | 45 | def ack(tag) do 46 | GenServer.cast(__MODULE__, {:ack, tag}) 47 | end 48 | 49 | def reject(tag, opts \\ []) do 50 | GenServer.cast(__MODULE__, {:reject, tag, opts}) 51 | end 52 | 53 | def publish(exchange, routing_key, payload, opts \\ []) do 54 | GenServer.cast(__MODULE__, {:publish, exchange, routing_key, payload, opts}) 55 | end 56 | 57 | def init(opts) do 58 | Process.flag(:trap_exit, true) 59 | send(self(), :connect) 60 | 61 | app_json_library = Application.get_env(:fireworks, :json_library) 62 | app_json_opts = Application.get_env(:fireworks, :json_opts) 63 | 64 | json_library = opts[:json_library] || app_json_library || nil 65 | json_opts = opts[:json_opts] || app_json_opts || [] 66 | 67 | {:ok, %{ 68 | channel: nil, 69 | consumer_tag: nil, 70 | tasks: [], 71 | opts: opts, 72 | json_library: json_library, 73 | json_opts: json_opts, 74 | status: :disconnected 75 | }} 76 | end 77 | 78 | def handle_info(:connect, s) do 79 | case Fireworks.with_conn(Fireworks.ConnPool, fn conn -> 80 | {:ok, chan} = Channel.open(conn) 81 | 82 | Process.monitor(chan.pid) 83 | prefetch_count = s.opts[:prefetch] || @prefetch_count 84 | :ok = Basic.qos(chan, prefetch_count: prefetch_count) 85 | queue = config(chan) 86 | {:ok, consumer_tag} = Basic.consume(chan, queue, self()) 87 | {:ok, chan, consumer_tag} 88 | end) do 89 | {:ok, chan, consumer_tag} -> 90 | #Logger.debug "Connected Channel: #{inspect s.opts}" 91 | {:noreply, %{s | channel: chan, consumer_tag: consumer_tag, status: :connected}} 92 | {:error, :disconnected} -> 93 | :timer.send_after(@reconnect_after_ms, :connect) 94 | {:noreply, %{s | channel: nil, consumer_tag: nil, status: :disconnected}} 95 | end 96 | end 97 | 98 | def handle_cast({:ack, tag}, %{state: :disconnected} = s) do 99 | #Basic.ack channel, tag 100 | #The channel died, don't ack 101 | {:noreply, s} 102 | end 103 | 104 | def handle_cast({:ack, tag}, %{channel: channel} = s) do 105 | Basic.ack channel, tag 106 | {:noreply, s} 107 | end 108 | 109 | def handle_cast({:reject, tag, opts}, %{state: :disconnected} = s) do 110 | #Basic.reject channel, tag, opts 111 | {:noreply, s} 112 | end 113 | 114 | def handle_cast({:reject, tag, opts}, %{channel: channel} = s) do 115 | #Logger.debug "Channel Reject Message: #{inspect tag}" 116 | #Logger.debug "Options: #{inspect opts}" 117 | Basic.reject channel, tag, opts 118 | {:noreply, s} 119 | end 120 | 121 | def handle_cast({:publish, exchange, routing_key, payload, opts}, %{} = s) do 122 | #Logger.debug "Channel Publish Message: #{inspect payload}" 123 | #Logger.debug "Options: #{inspect opts}" 124 | Fireworks.publish exchange, routing_key, payload, opts 125 | {:noreply, s} 126 | end 127 | 128 | # Confirmation sent by the broker after registering this process as a consumer 129 | def handle_info({:basic_consume_ok, %{consumer_tag: consumer_tag}}, s) do 130 | #Logger.debug "Consumer Registered: #{inspect consumer_tag}" 131 | {:noreply, s} 132 | end 133 | 134 | # Sent by the broker when the consumer is unexpectedly cancelled (such as after a queue deletion) 135 | def handle_info({:basic_cancel, %{consumer_tag: consumer_tag}}, s) do 136 | #Logger.error "Basic Cancel Called on Channel #{inspect __MODULE__}" 137 | {:stop, :normal, %{s | state: :disconnected}} 138 | end 139 | 140 | # Confirmation sent by the broker to the consumer process after a Basic.cancel 141 | def handle_info({:basic_cancel_ok, %{consumer_tag: consumer_tag}}, s) do 142 | #Logger.error "Basic Cancel OK Called on Channel #{inspect __MODULE__}" 143 | {:stop, :normal, %{s | state: :disconnected}} 144 | end 145 | 146 | def handle_info({:basic_deliver, payload, %{delivery_tag: tag, redelivered: redelivered} = meta}, %{channel: channel} = s) do 147 | # Handle Message Distribution 148 | #Logger.debug "AMQP Delivered Payload: #{inspect payload}" 149 | payload = 150 | case s.json_library do 151 | nil -> payload 152 | _ -> payload 153 | |> s.json_library.decode!(s.json_opts) 154 | end 155 | 156 | task = Task.async(fn -> consume(payload, meta) end) 157 | #Logger.debug "Task: #{inspect task}" 158 | 159 | Process.unlink(task.pid) 160 | timer_ref = :erlang.start_timer(@task_timeout, self(), {:task_timeout, task, tag, redelivered, payload}) 161 | 162 | {:noreply, %{s | tasks: [{task, timer_ref, meta} | s.tasks]}} 163 | end 164 | 165 | def handle_info({task, _}, %{tasks: tasks} = s) when is_reference(task) do 166 | #Logger.debug "Task Finished: #{inspect task}" 167 | #Logger.debug "Tasks: #{inspect tasks}" 168 | {finished_tasks, remaining_tasks} = Enum.split_with(tasks, fn({%{ref: ref}, _, _}) -> ref == task end) 169 | #Logger.debug "Finished Tasks: #{inspect finished_tasks}" 170 | Enum.each(finished_tasks, fn({_, timer_ref, _}) -> 171 | :erlang.cancel_timer(timer_ref) 172 | end) 173 | 174 | {:noreply, %{s | tasks: remaining_tasks}} 175 | end 176 | 177 | def handle_info({:EXIT, pid, reason}, s) do 178 | #Logger.error "Exit Message From: #{inspect pid}, reason: #{inspect reason}" 179 | {:noreply, s} 180 | end 181 | 182 | def handle_info({:DOWN, ref, :process, pid, reason}, %{channel: %{pid: chan_pid}} = s) when pid == chan_pid do 183 | #Logger.debug "Channel Died for Reason: #{inspect reason}" 184 | send(self(), :connect) 185 | {:noreply, %{s | status: :disconnected}} 186 | end 187 | 188 | def handle_info({:DOWN, ref, :process, _, {:timeout, info}}, s) do 189 | #Logger.error "Database timeout" 190 | #Logger.debug "Ref: #{inspect ref}" 191 | {error_tasks, remaining_tasks} = Enum.split_with(s.tasks, fn({%{ref: task_ref}, timer_ref, meta}) -> task_ref == ref end) 192 | Enum.each(error_tasks, fn({task, timer_ref, meta}) -> 193 | :erlang.cancel_timer(timer_ref) 194 | Basic.reject s.channel, meta.delivery_tag, requeue: true 195 | end) 196 | {:noreply, %{s | tasks: remaining_tasks}} 197 | end 198 | 199 | def handle_info({:DOWN, ref, :process, _, :normal}, s) do 200 | {:noreply, s} 201 | end 202 | 203 | def handle_info({:DOWN, ref, :process, _, error}, s) do 204 | #Logger.error "Task handled error error: #{inspect error}" 205 | #Logger.debug "Ref: #{inspect ref}" 206 | {error_tasks, remaining_tasks} = Enum.split_with(s.tasks, fn({%{ref: task_ref}, timer_ref, meta}) -> task_ref == ref end) 207 | Enum.each(error_tasks, fn({task, timer_ref, meta}) -> 208 | :erlang.cancel_timer(timer_ref) 209 | Basic.reject s.channel, meta.delivery_tag, requeue: false 210 | end) 211 | {:noreply, %{s | tasks: remaining_tasks}} 212 | end 213 | 214 | def handle_info({:timeout, timer_ref, {:task_timeout, %{pid: task_pid, ref: task_ref}, tag, redelivered, payload}}, %{channel: channel} = s) do 215 | # TODO Investigate why calls to reject were killing GenServer 216 | #Basic.reject channel, tag, requeue: not redelivered 217 | Process.exit(task_pid, :task_timeout) 218 | {:noreply, s} 219 | end 220 | 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/fireworks/connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Fireworks.Connection do 2 | use GenServer 3 | use AMQP 4 | 5 | require Logger 6 | 7 | @reconnect_after_ms 5_000 8 | 9 | def start_link(opts) do 10 | GenServer.start_link(__MODULE__, opts) 11 | end 12 | 13 | def init([opts]) do 14 | Process.flag(:trap_exit, true) 15 | send(self(), :connect) 16 | {:ok, %{ 17 | conn: nil, 18 | opts: opts, 19 | status: :disconnected 20 | }} 21 | end 22 | 23 | def handle_call(:conn, _from, %{status: :connected, conn: conn} = status) do 24 | {:reply, {:ok, conn}, status} 25 | end 26 | 27 | def handle_call(:conn, _from, %{status: :disconnected} = status) do 28 | {:reply, {:error, :disconnected}, status} 29 | end 30 | 31 | def handle_info(:connect, s) do 32 | opts = connection_options(s.opts) 33 | case Connection.open(opts) do 34 | {:ok, conn} -> 35 | Process.monitor(conn.pid) 36 | {:noreply, %{s | conn: conn, status: :connected}} 37 | {:error, _reason} -> 38 | :timer.send_after(@reconnect_after_ms, :connect) 39 | {:noreply, s} 40 | end 41 | end 42 | 43 | def handle_info({:EXIT, _pid, _reason}, s) do 44 | #Logger.debug "Exit Message From: #{inspect pid}, reason: #{inspect reason}" 45 | {:noreply, s} 46 | end 47 | 48 | def handle_info({:DOWN, _ref, :process, _pid, _reason}, %{status: :connected} = state) do 49 | Logger.error "lost RabbitMQ connection. Attempting to reconnect" 50 | :timer.send_after(@reconnect_after_ms, :connect) 51 | {:noreply, %{state | conn: nil, status: :disconnected}} 52 | end 53 | 54 | def terminate(_reason, %{conn: conn, status: :connected}) do 55 | try do 56 | Connection.close(conn) 57 | catch 58 | _, _ -> :ok 59 | end 60 | end 61 | def terminate(_reason, _state) do 62 | :ok 63 | end 64 | 65 | defp connection_options(opts) do 66 | case Keyword.get(opts, :hosts) do 67 | nil -> opts 68 | hosts when is_list(hosts) -> 69 | host = Enum.random(hosts) 70 | opts |> Keyword.delete(:hosts) |> Keyword.put(:host, host) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/fireworks/consumer.ex: -------------------------------------------------------------------------------- 1 | # TODO how do we handle graceful shutdown? We need to send a Basic.cancel and wait for tasks to finish before actually terminating 2 | defmodule Fireworks.Consumer do 3 | use GenServer 4 | alias AMQP.{Basic,Channel} 5 | require Logger 6 | 7 | @default_opts %{ 8 | prefetch: 5, 9 | reconnect_after_ms: 1_000, 10 | setup_fn: nil, # replaced with default fn in init 11 | task_timeout: 60_000, 12 | } 13 | 14 | def start_link(opts, genserver_opts \\ []) do 15 | GenServer.start_link(__MODULE__, opts, genserver_opts) 16 | end 17 | 18 | def ack(%{consumer: consumer}=meta) do 19 | GenServer.call(consumer, {:ack, meta}) 20 | end 21 | 22 | def reject(%{consumer: consumer}=meta, opts \\ []) do 23 | GenServer.call(consumer, {:reject, meta, opts}) 24 | end 25 | 26 | def init(opts) do 27 | Process.flag(:trap_exit, true) 28 | send(self(), :connect) 29 | opts = @default_opts |> Map.put(:setup_fn, fn(_chan) -> :nothing end) |> Map.merge(opts) 30 | state = %{ 31 | channel: nil, 32 | consumer_tag: nil, 33 | tasks: [], 34 | opts: opts, 35 | status: :disconnected, 36 | } 37 | {:ok, state} 38 | end 39 | 40 | def handle_call({:ack, %{channel: chan, delivery_tag: tag}}, _from, %{channel: chan} = s) do 41 | response = Basic.ack(chan, tag) 42 | {:reply, response, s} 43 | end 44 | def handle_call({:ack, _meta}, _from, s) do 45 | {:reply, {:error, "no longer connected to that channel"}, s} 46 | end 47 | 48 | def handle_call({:reject, %{channel: chan, delivery_tag: tag}, opts}, _from, %{channel: chan} = s) do 49 | response = Basic.reject(chan, tag, opts) 50 | {:reply, response, s} 51 | end 52 | def handle_call({:reject, _meta, _opts}, _from, s) do 53 | {:reply, {:error, "no longer connected to that channel"}, s} 54 | end 55 | 56 | def handle_info(:connect, s) do 57 | Logger.debug("attempting to connect to #{s.opts.queue}") 58 | case attempt_to_connect(s) do 59 | {:ok, chan, consumer_tag} -> 60 | {:noreply, %{s | channel: chan, consumer_tag: consumer_tag, status: :connected}} 61 | {:error, :disconnected} -> 62 | :timer.send_after(s.opts.reconnect_after_ms, :connect) 63 | {:noreply, %{s | channel: nil, consumer_tag: nil, status: :disconnected}} 64 | end 65 | end 66 | 67 | # Confirmation sent by the broker after registering this process as a consumer 68 | def handle_info({:basic_consume_ok, %{consumer_tag: _consumer_tag}}, s) do 69 | {:noreply, s} 70 | end 71 | 72 | # Sent by the broker when the consumer is unexpectedly cancelled (such as after a queue deletion) 73 | def handle_info({:basic_cancel, %{consumer_tag: _consumer_tag}}, s) do 74 | Logger.error("the #{s.opts.queue} consumer was cancelled by the broker (basic_cancel)") 75 | {:stop, :normal, %{s | status: :disconnected, channel: nil}} 76 | end 77 | 78 | # Confirmation sent by the broker to the consumer process after a Basic.cancel 79 | def handle_info({:basic_cancel_ok, %{consumer_tag: _consumer_tag}}, s) do 80 | Logger.error("the #{s.opts.queue} consumer was cancelled by the broker (basic_cancel_ok)") 81 | {:stop, :normal, %{s | status: :disconnected, channel: nil}} 82 | end 83 | 84 | def handle_info({:basic_deliver, payload, %{delivery_tag: tag, redelivered: redelivered} = meta}, %{channel: channel} = s) do 85 | meta = meta |> Map.put(:channel, channel) |> Map.put(:consumer, self()) 86 | task = Task.async(fn -> s.opts.consume_fn.(payload, meta) end) 87 | 88 | Process.unlink(task.pid) 89 | timer_ref = :erlang.start_timer(s.opts.task_timeout, self(), {:task_timeout, task, tag, redelivered, payload}) 90 | 91 | {:noreply, %{s | tasks: [{task, timer_ref, meta} | s.tasks]}} 92 | end 93 | 94 | def handle_info({task, _}, %{tasks: tasks} = s) when is_reference(task) do 95 | {finished_tasks, remaining_tasks} = Enum.split_with(tasks, fn({%{ref: ref}, _, _}) -> ref == task end) 96 | Enum.each(finished_tasks, fn({_, timer_ref, _}) -> 97 | :erlang.cancel_timer(timer_ref) 98 | end) 99 | 100 | {:noreply, %{s | tasks: remaining_tasks}} 101 | end 102 | 103 | def handle_info({:EXIT, pid, reason}, s) do 104 | Logger.error("got EXIT from #{inspect pid} (#{inspect reason})") 105 | {:noreply, s} 106 | end 107 | 108 | def handle_info({:DOWN, _ref, :process, chan_pid, reason}, %{channel: %{pid: chan_pid}} = s) do 109 | Logger.error("channel for #{s.opts.queue} died: #{inspect reason}") 110 | send(self(), :connect) 111 | {:noreply, %{s | status: :disconnected, channel: nil}} 112 | end 113 | 114 | def handle_info({:DOWN, _ref, :process, _, :normal}, s) do 115 | {:noreply, s} 116 | end 117 | 118 | def handle_info({:DOWN, ref, :process, _, _error}, s) do 119 | {error_tasks, remaining_tasks} = Enum.split_with(s.tasks, fn({%{ref: task_ref}, _timer_ref, _meta}) -> task_ref == ref end) 120 | Enum.each(error_tasks, fn({_task, timer_ref, meta}) -> 121 | :erlang.cancel_timer(timer_ref) 122 | # TODO make the `requeue` option configurable? 123 | # TODO maybe this message was already ACK or REJECTED? If so rabbit will close our channel for trying to REJECT again 124 | handle_call({:reject, meta, requeue: false}, :not_a_real_client, s) 125 | end) 126 | {:noreply, %{s | tasks: remaining_tasks}} 127 | end 128 | 129 | def handle_info({:timeout, _timer_ref, {:task_timeout, %{pid: task_pid}, _tag, _redelivered, _payload}}, s) do 130 | Process.exit(task_pid, :task_timeout) 131 | {:noreply, s} 132 | end 133 | 134 | defp attempt_to_connect(state) do 135 | Fireworks.with_conn(Fireworks.ConnPool, fn conn -> 136 | {:ok, chan} = Channel.open(conn) 137 | Process.monitor(chan.pid) 138 | state.opts.setup_fn.(chan) 139 | :ok = Basic.qos(chan, prefetch_count: state.opts.prefetch) 140 | {:ok, consumer_tag} = Basic.consume(chan, state.opts.queue, self()) 141 | {:ok, chan, consumer_tag} 142 | end) 143 | end 144 | end 145 | -------------------------------------------------------------------------------- /lib/fireworks/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Fireworks.Logger do 2 | #use GenEvent 3 | require Logger 4 | 5 | @default_format "$time $metadata[$level] $message\n" 6 | 7 | ### Public API 8 | def init({__MODULE__, opts}) do 9 | {:ok, configure(opts)} 10 | end 11 | 12 | def handle_call({:configure, options}, %{name: name}) do 13 | {:ok, :ok, configure(Keyword.put(options, :otp_name, name))} 14 | end 15 | 16 | def handle_event({_level, gl, _event}, state) when node(gl) != node() do 17 | {:ok, state} 18 | end 19 | 20 | def handle_event({level, _gl, {Logger, msg, ts, md}}, %{level: min_level} = state) do 21 | if is_nil(min_level) or Logger.compare_levels(level, min_level) != :lt do 22 | log_event(level, msg, ts, md, state) 23 | end 24 | {:ok, state} 25 | end 26 | 27 | defp configure(opts) do 28 | IO.inspect opts 29 | name = Keyword.get(opts, :otp_app) || raise "Must supply Fireworks.Logger otp_app" 30 | json_library = Keyword.get(opts, :json_library) 31 | env = Application.get_env(:logger, name, []) 32 | opts = Keyword.merge(env, opts) 33 | Application.put_env(:logger, name, opts) 34 | 35 | level = Keyword.get(opts, :level) 36 | metadata = Keyword.get(opts, :metadata, []) 37 | format = Keyword.get(opts, :format, @default_format) |> Logger.Formatter.compile 38 | exchange = Keyword.get(opts, :exchange, "") 39 | 40 | %{name: name, format: format, level: level, metadata: metadata, exchange: exchange, json_library: json_library} 41 | end 42 | 43 | defp log_event(level, msg, _ts, _md, state) do 44 | IO.puts "Loging: #{inspect to_string(level)}, #{inspect msg}" 45 | 46 | msg = case state.json_library do 47 | nil -> msg 48 | json_library -> %{message: msg, level: level, node: node()} |> json_library.encode! 49 | end 50 | 51 | IO.puts "MSG: #{inspect msg}" 52 | Fireworks.publish(state.exchange, Atom.to_string(level), msg, []) 53 | {:ok, state} 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/fireworks/publisher.ex: -------------------------------------------------------------------------------- 1 | defmodule Fireworks.Publisher do 2 | use GenServer 3 | use AMQP 4 | 5 | @reconnect_after_ms 5_000 6 | 7 | @moduledoc """ 8 | Worker for pooled publishers to RabbitMQ 9 | """ 10 | 11 | @doc """ 12 | Starts the server 13 | """ 14 | def start_link(conn_pool_name) do 15 | GenServer.start_link(__MODULE__, [conn_pool_name]) 16 | end 17 | 18 | @doc false 19 | def init([conn_pool_name]) do 20 | Process.flag(:trap_exit, true) 21 | send(self(), :connect) 22 | {:ok, %{status: :disconnected, chan: nil, conn_pool_name: conn_pool_name}} 23 | end 24 | 25 | def handle_call(:chan, _from, %{status: :connected, chan: chan} = status) do 26 | {:reply, {:ok, chan}, status} 27 | end 28 | 29 | def handle_call(:chan, _from, %{status: :disconnected} = status) do 30 | {:reply, {:error, :disconnected}, status} 31 | end 32 | 33 | def handle_info(:connect, %{status: :disconnected} = state) do 34 | case Fireworks.with_conn(state.conn_pool_name, &Channel.open/1) do 35 | {:ok, chan} -> 36 | Process.monitor(chan.pid) 37 | {:noreply, %{state | chan: chan, status: :connected}} 38 | _ -> 39 | :timer.send_after(@reconnect_after_ms, :connect) 40 | {:noreply, %{state | chan: nil, status: :disconnected}} 41 | end 42 | end 43 | 44 | def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do 45 | :timer.send_after(@reconnect_after_ms, :connect) 46 | {:noreply, %{state | status: :disconnected}} 47 | end 48 | 49 | def terminate(_reason, %{chan: chan, status: :connected}) do 50 | try do 51 | Channel.close(chan) 52 | catch 53 | _, _ -> :ok 54 | end 55 | end 56 | def terminate(_reason, _state) do 57 | :ok 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Fireworks.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :fireworks, 6 | version: "0.7.2", 7 | elixir: "~> 1.0", 8 | deps: deps(), 9 | description: description(), 10 | package: package()] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type `mix help compile.app` for more information 16 | def application do 17 | [applications: [:amqp, :logger, :poolboy], mod: {Fireworks, []}] 18 | end 19 | 20 | # Dependencies can be Hex packages: 21 | # 22 | # {:mydep, "~> 0.3.0"} 23 | # 24 | # Or git/path repositories: 25 | # 26 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 27 | # 28 | # Type `mix help deps` for more examples and options 29 | defp deps do 30 | [ 31 | {:amqp, "~> 3.0"}, 32 | {:poolboy, "~> 1.5.0"} 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | Simple elixir work queue consumption for RabbitMQ 39 | """ 40 | end 41 | 42 | defp package do 43 | [maintainers: ["Eric Witchin"], 44 | licenses: ["Apache 2.0"], 45 | links: %{"Github" => "https://github.com/livehelpnow/fireworks"}] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "amqp": {:hex, :amqp, "3.0.0", "66e8e17561f19ba85bff7df4f77560e8de8ddd53958c5c15ccb2583bb937564f", [:mix], [{:amqp_client, "~> 3.9.1", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "2b0b27223196a511d5dd5a7291ba48366ccb405a0b5ea1b966bf9f80fd17f1b1"}, 3 | "amqp_client": {:hex, :amqp_client, "3.9.8", "031a2335124f8d5dd773e0376a1e2b4d6972adfb97b2584689fb7ec91f2e1b83", [:make, :rebar3], [{:rabbit_common, "3.9.8", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "fdc57592659aad5cb4bc5ae5afcb557681211a1607d919999a9405d0feef5cfa"}, 4 | "credentials_obfuscation": {:hex, :credentials_obfuscation, "2.4.0", "9fb57683b84899ca3546b384e59ab5d3054a9f334eba50d74c82cd0ae82dd6ca", [:rebar3], [], "hexpm", "d28a89830e30698b075de9a4dbe683a20685c6bed1e3b7df744a0c06e6ff200a"}, 5 | "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm", "99cb4128cffcb3227581e5d4d803d5413fa643f4eb96523f77d9e6937d994ceb"}, 6 | "jsx": {:hex, :jsx, "3.1.0", "d12516baa0bb23a59bb35dccaf02a1bd08243fcbb9efe24f2d9d056ccff71268", [:rebar3], [], "hexpm", "0c5cc8fdc11b53cc25cf65ac6705ad39e54ecc56d1c22e4adb8f5a53fb9427f3"}, 7 | "lager": {:hex, :lager, "3.9.2", "4cab289120eb24964e3886bd22323cb5fefe4510c076992a23ad18cf85413d8c", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm", "7f904d9e87a8cb7e66156ed31768d1c8e26eba1d54f4bc85b1aa4ac1f6340c28"}, 8 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, 9 | "rabbit_common": {:hex, :rabbit_common, "3.9.8", "335a724d42502cb98b9de6c20d6548cf9957f3c32615cdcefc126a639020265d", [:make, :rebar3], [{:credentials_obfuscation, "2.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:jsx, "3.1.0", [hex: :jsx, repo: "hexpm", optional: false]}, {:recon, "2.5.1", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm", "4f839f72d6bfb60a88624cd2c32e17ba52e3c18c84573fb80a6680b378f0b536"}, 10 | "recon": {:hex, :recon, "2.5.1", "430ffa60685ac1efdfb1fe4c97b8767c92d0d92e6e7c3e8621559ba77598678a", [:mix, :rebar3], [], "hexpm", "5721c6b6d50122d8f68cccac712caa1231f97894bab779eff5ff0f886cb44648"}, 11 | } 12 | -------------------------------------------------------------------------------- /test/fireworks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule FireworksTest do 2 | use ExUnit.Case 3 | 4 | test "the truth" do 5 | assert 1 + 1 == 2 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------