├── config ├── dev.exs ├── prod.exs ├── test.exs └── config.exs ├── .tool-versions ├── .formatter.exs ├── .gitignore ├── compose.yaml ├── .travis.yml ├── test ├── lib │ └── coney │ │ ├── coney_test.exs │ │ ├── consumer │ │ └── consumer_server_test.exs │ │ ├── rabbit_connection_test.exs │ │ └── connection_server_test.exs ├── support │ ├── fake_consumer.ex │ └── other_fake_consumer.ex └── test_helper.exs ├── lib └── coney │ ├── consumer_supervisor.ex │ ├── application.ex │ ├── healthcheck │ ├── connection_registry.ex │ └── status_checker.ex │ ├── execution_task.ex │ ├── consumer.ex │ ├── coney.ex │ ├── application_supervisor.ex │ ├── consumer_executor.ex │ ├── consumer_server.ex │ ├── rabbit_connection.ex │ └── connection_server.ex ├── .github └── workflows │ ├── hex.yml │ └── ci.yaml ├── LICENSE.txt ├── mix.exs ├── CHANGELOG.md ├── mix.lock ├── README.md └── .credo.exs /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.1 2 | elixir 1.15.7-otp-26 -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | line_length: 100 4 | ] 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /log 6 | /.fetch 7 | erl_crash.dump 8 | *.ez 9 | .idea 10 | *.iml 11 | .DS_Store 12 | .elixir_ls/ 13 | .history 14 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | rabbitmq: 3 | # "management" version is not required, but it makes 4 | # manual testing easier 5 | image: "rabbitmq:3.12-management-alpine" 6 | ports: 7 | - "5672:5672" 8 | - "15672:15672" 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.10.4 4 | - 1.11.4 5 | - 1.12.3 6 | - 1.13.4 7 | otp_release: 8 | - 22.1 9 | - 23.3.1 10 | - 24.3.1 11 | matrix: 12 | exclude: 13 | - elixir: 1.11.4 14 | otp_release: 24.3.1 15 | - elixir: 1.10.4 16 | otp_release: 23.3.1 17 | - elixir: 1.10.4 18 | otp_release: 24.3.1 19 | services: 20 | - rabbitmq 21 | addons: 22 | apt: 23 | packages: 24 | - rabbitmq-server 25 | -------------------------------------------------------------------------------- /test/lib/coney/coney_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConeyTest do 2 | use ExUnit.Case 3 | use AMQP 4 | 5 | describe "publish/2" do 6 | test "consumes a message" do 7 | {:ok, connection} = AMQP.Connection.open(Application.get_env(:coney, :settings)[:url]) 8 | 9 | {:ok, channel} = AMQP.Channel.open(connection) 10 | 11 | assert :ok == AMQP.Basic.publish(channel, "exchange", "queue", "message", mandatory: true) 12 | 13 | refute 0 == AMQP.Queue.consumer_count(channel, "queue") 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/coney/consumer_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConsumerSupervisor do 2 | @moduledoc """ 3 | Supervisor for all ConsumerServer of the application. 4 | """ 5 | use Supervisor 6 | 7 | alias Coney.ConsumerServer 8 | 9 | def start_link([consumers]) do 10 | Supervisor.start_link(__MODULE__, [consumers], name: __MODULE__) 11 | end 12 | 13 | @impl Supervisor 14 | def init([consumers]) do 15 | children = Enum.map(consumers, fn consumer -> {ConsumerServer, [consumer]} end) 16 | 17 | Supervisor.init(children, strategy: :one_for_one) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/coney/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.Application do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | children = 6 | [ 7 | Coney.HealthCheck.StatusChecker 8 | ] ++ app_supervisor() 9 | 10 | opts = [strategy: :one_for_one, name: Coney.Supervisor] 11 | Supervisor.start_link(children, opts) 12 | end 13 | 14 | defp app_supervisor do 15 | if auto_start?() do 16 | [Coney.ApplicationSupervisor] 17 | else 18 | [] 19 | end 20 | end 21 | 22 | defp auto_start? do 23 | Application.get_env(:coney, :auto_start, true) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/support/fake_consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.FakeConsumer do 2 | @behaviour Coney.Consumer 3 | 4 | def connection do 5 | %{ 6 | prefetch_count: 10, 7 | queue: "queue" 8 | } 9 | end 10 | 11 | def parse(payload, _meta) do 12 | payload 13 | end 14 | 15 | def process(payload, _meta) do 16 | case payload do 17 | :ok -> :ok 18 | :reject -> :reject 19 | :reply -> {:reply, :data} 20 | :exception -> raise "Exception happen" 21 | _other -> :ok 22 | end 23 | end 24 | 25 | def error_happened(_exception, _payload, _meta) do 26 | :ok 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/support/other_fake_consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.OtherFakeConsumer do 2 | @behaviour Coney.Consumer 3 | 4 | def connection do 5 | %{ 6 | prefetch_count: 10, 7 | queue: "queue" 8 | } 9 | end 10 | 11 | def parse(payload, _meta) do 12 | payload 13 | end 14 | 15 | def process(payload, _meta) do 16 | case payload do 17 | :ok -> :ok 18 | :reject -> :reject 19 | :reply -> {:reply, :data} 20 | :exception -> raise "Exception happen" 21 | _other -> :ok 22 | end 23 | end 24 | 25 | def error_happened(_exception, _payload, _meta) do 26 | :ok 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/coney/healthcheck/connection_registry.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.HealthCheck.ConnectionRegistry do 2 | def init do 3 | :ets.new(__MODULE__, [:set, :named_table, :public, read_concurrency: true]) 4 | end 5 | 6 | def associate(pid) do 7 | :ets.insert(__MODULE__, {pid, :pending}) 8 | end 9 | 10 | def connected(pid) do 11 | :ets.update_element(__MODULE__, pid, {2, :connected}) 12 | end 13 | 14 | def disconnected(pid) do 15 | :ets.update_element(__MODULE__, pid, {2, :disconnected}) 16 | end 17 | 18 | def terminated(pid) do 19 | :ets.delete(__MODULE__, pid) 20 | end 21 | 22 | def status do 23 | :ets.tab2list(__MODULE__) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule AMQP.ConnectionCheck do 4 | @max_retry 10 5 | 6 | def do_check(retry \\ 0) do 7 | case AMQP.Connection.open() do 8 | {:ok, conn} -> 9 | :ok = AMQP.Connection.close(conn) 10 | 11 | {:error, reason} -> 12 | message = "Cannot connect to RabbitMQ(amqp://localhost:5672): #{inspect(reason)}" 13 | 14 | if retry < @max_retry do 15 | IO.puts("#{message} (retrying...)") 16 | :timer.sleep(1000) 17 | do_check(retry + 1) 18 | else 19 | Mix.raise(message) 20 | end 21 | end 22 | end 23 | end 24 | 25 | AMQP.ConnectionCheck.do_check() 26 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :coney, 4 | pool_size: 1, 5 | auto_start: true, 6 | settings: %{ 7 | url: "amqp://guest:guest@localhost", 8 | timeout: 1000 9 | }, 10 | workers: [ 11 | Coney.FakeConsumer, 12 | Coney.OtherFakeConsumer 13 | ], 14 | topology: %{ 15 | exchanges: [{:topic, "exchange", durable: false}], 16 | queues: %{ 17 | "queue" => %{ 18 | options: [ 19 | durable: false 20 | ], 21 | bindings: [ 22 | [exchange: "exchange", options: [routing_key: "queue"]] 23 | ] 24 | } 25 | } 26 | } 27 | 28 | config :logger, level: :info 29 | 30 | import_config "#{config_env()}.exs" 31 | -------------------------------------------------------------------------------- /lib/coney/execution_task.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ExecutionTask do 2 | defstruct consumer: nil, 3 | settings: %{}, 4 | chan: nil, 5 | payload: nil, 6 | tag: nil, 7 | meta: %{} 8 | 9 | def build(%{worker: consumer, connection: settings}, chan, payload, tag, meta) do 10 | %__MODULE__{ 11 | consumer: consumer, 12 | settings: settings, 13 | chan: chan, 14 | payload: payload, 15 | tag: tag, 16 | meta: meta 17 | } 18 | end 19 | 20 | def build(consumer, chan, payload, tag, meta) do 21 | %__MODULE__{ 22 | consumer: consumer, 23 | settings: consumer.connection(), 24 | chan: chan, 25 | payload: payload, 26 | tag: tag, 27 | meta: meta 28 | } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/coney/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.Consumer do 2 | @type stacktrace :: [] 3 | @callback connection() :: map() 4 | @callback parse(message :: binary(), meta :: map()) :: any 5 | @callback process(payload :: any, meta :: map()) :: 6 | :ok | :reject | :redeliver | {:reply, binary()} 7 | @callback error_happened(exception :: struct(), message :: binary(), meta :: map()) :: 8 | :ok | :reject | :redeliver | {:reply, binary()} 9 | @callback error_happened( 10 | exception :: struct(), 11 | stacktrace :: stacktrace(), 12 | message :: binary(), 13 | meta :: map() 14 | ) :: 15 | :ok | :reject | :redeliver | {:reply, binary()} 16 | 17 | @optional_callbacks connection: 0, error_happened: 3, error_happened: 4 18 | end 19 | -------------------------------------------------------------------------------- /lib/coney/healthcheck/status_checker.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.HealthCheck.StatusChecker do 2 | use GenServer 3 | 4 | alias Coney.HealthCheck.ConnectionRegistry 5 | 6 | @interval 500 7 | 8 | def start_link do 9 | GenServer.start_link(__MODULE__, []) 10 | end 11 | 12 | def child_spec(_args) do 13 | %{ 14 | id: __MODULE__, 15 | start: {__MODULE__, :start_link, []} 16 | } 17 | end 18 | 19 | def init(_args) do 20 | ConnectionRegistry.init() 21 | 22 | {:ok, @interval, @interval} 23 | end 24 | 25 | def handle_info(:timeout, interval) do 26 | ConnectionRegistry.status() 27 | |> Enum.each(fn {pid, _} -> 28 | unless Process.alive?(pid) do 29 | ConnectionRegistry.terminated(pid) 30 | end 31 | end) 32 | 33 | {:noreply, interval, interval} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/hex.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - '*' 5 | 6 | jobs: 7 | publish: 8 | runs-on: ubuntu-latest 9 | name: Publish 10 | strategy: 11 | matrix: 12 | otp: ['26'] 13 | elixir: ['1.15.7'] 14 | env: 15 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: erlef/setup-beam@v1 19 | with: 20 | otp-version: ${{matrix.otp}} 21 | elixir-version: ${{matrix.elixir}} 22 | - name: Restore dependencies cache 23 | uses: actions/cache@v4 24 | with: 25 | path: deps 26 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-release-deps-${{ hashFiles('**/mix.lock') }} 27 | restore-keys: | 28 | ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-release-deps-${{ hashFiles('**/mix.lock') }} 29 | ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-release-deps 30 | - run: mix deps.get 31 | - run: mix hex.publish --yes 32 | -------------------------------------------------------------------------------- /lib/coney/coney.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney do 2 | alias Coney.{ConnectionServer, HealthCheck.ConnectionRegistry} 3 | 4 | @spec publish(String.t(), binary()) :: :published | {:error, :no_connected_servers} 5 | def publish(exchange_name, message) do 6 | ConnectionServer.publish(exchange_name, message) 7 | end 8 | 9 | @spec publish(String.t(), String.t(), binary()) :: :published | {:error, :no_connected_servers} 10 | def publish(exchange_name, routing_key, message) do 11 | ConnectionServer.publish(exchange_name, routing_key, message) 12 | end 13 | 14 | @spec publish_async(String.t(), binary()) :: :ok 15 | def publish_async(exchange_name, message) do 16 | ConnectionServer.publish_async(exchange_name, message) 17 | end 18 | 19 | @spec publish_async(String.t(), String.t(), binary()) :: :ok 20 | def publish_async(exchange_name, routing_key, message) do 21 | ConnectionServer.publish_async(exchange_name, routing_key, message) 22 | end 23 | 24 | @spec status() :: list({pid(), :pending | :connected | :disconnected}) 25 | def status do 26 | ConnectionRegistry.status() 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 llxff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Coney.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :coney, 7 | version: "3.1.3", 8 | elixir: ">= 1.12.0", 9 | build_embedded: Mix.env() == :prod, 10 | start_permanent: Mix.env() == :prod, 11 | description: description(), 12 | package: package(), 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | deps: deps() 15 | ] 16 | end 17 | 18 | def application do 19 | [ 20 | extra_applications: [:logger], 21 | mod: {Coney.Application, []} 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:amqp, "~> 3.3"}, 28 | # Dev deps 29 | {:ex_doc, "~> 0.34", only: :dev, runtime: false}, 30 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false} 31 | ] 32 | end 33 | 34 | defp description do 35 | """ 36 | Consumer server for RabbitMQ. 37 | """ 38 | end 39 | 40 | defp elixirc_paths(env) when env in [:test, :dev], do: ["lib", "test/support"] 41 | 42 | defp elixirc_paths(_), do: ["lib"] 43 | 44 | defp package do 45 | [ 46 | name: :coney, 47 | files: ["lib", "mix.exs", "README.md", "LICENSE.txt"], 48 | maintainers: ["Yolo Group"], 49 | licenses: ["MIT"], 50 | links: %{"GitHub" => "https://github.com/coingaming/coney"} 51 | ] 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/coney/application_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ApplicationSupervisor do 2 | @moduledoc """ 3 | Supervisor responsible of `ConnectionServer` and `ConsumerSupervisor`. 4 | 5 | Main entry point of the application. 6 | """ 7 | 8 | use Supervisor 9 | 10 | alias Coney.{ConsumerSupervisor, ConnectionServer} 11 | 12 | def start_link(consumers) do 13 | Supervisor.start_link(__MODULE__, [consumers], name: __MODULE__) 14 | end 15 | 16 | def child_spec(_args) do 17 | %{ 18 | id: __MODULE__, 19 | start: {__MODULE__, :start_link, [Application.get_env(:coney, :workers, [])]} 20 | } 21 | end 22 | 23 | @impl Supervisor 24 | def init([consumers]) do 25 | settings = settings() 26 | 27 | {enabled?, settings} = Keyword.pop!(settings, :enabled) 28 | 29 | children = 30 | if enabled? do 31 | [ 32 | {ConnectionServer, [settings]}, 33 | {ConsumerSupervisor, [consumers]} 34 | ] 35 | else 36 | [] 37 | end 38 | 39 | Supervisor.init(children, strategy: :one_for_one) 40 | end 41 | 42 | def settings do 43 | [ 44 | adapter: Application.get_env(:coney, :adapter, Coney.RabbitConnection), 45 | enabled: Application.get_env(:coney, :enabled, true), 46 | settings: get_config(:settings, :settings), 47 | topology: get_config(:topology, :topology, %{exchanges: [], queues: []}) 48 | ] 49 | end 50 | 51 | defp get_config(key, callback, default \\ false) do 52 | config = Application.get_env(:coney, key) 53 | 54 | cond do 55 | is_map(config) -> 56 | config 57 | 58 | is_nil(config) -> 59 | default 60 | 61 | is_atom(config) -> 62 | apply(config, callback, []) 63 | 64 | true -> 65 | raise "Please, specify #{Atom.to_string(key)} via config file or module" 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | MIX_ENV: test 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | test: 17 | # Set up a RabbitMQ instance 18 | services: 19 | rabbitmq: 20 | image: "rabbitmq:alpine" 21 | ports: 22 | - "5672:5672" 23 | 24 | runs-on: ubuntu-latest 25 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 26 | strategy: 27 | matrix: 28 | # TODO: Add all the OTP and Elixir versions we plan to support 29 | otp: ['26.1'] 30 | elixir: ['1.15.7'] 31 | steps: 32 | - name: Set up Elixir 33 | uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{matrix.otp}} 36 | elixir-version: ${{matrix.elixir}} 37 | 38 | - name: Checkout code 39 | uses: actions/checkout@v4 40 | 41 | - name: Cache deps 42 | id: cache-deps 43 | uses: actions/cache@v4 44 | env: 45 | cache-name: cache-elixir-deps 46 | with: 47 | path: deps 48 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 49 | restore-keys: | 50 | ${{ runner.os }}-mix-${{ env.cache-name }}- 51 | 52 | - name: Cache compiled build 53 | id: cache-build 54 | uses: actions/cache@v4 55 | env: 56 | cache-name: cache-compiled-build 57 | with: 58 | path: _build 59 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 60 | restore-keys: | 61 | ${{ runner.os }}-mix-${{ env.cache-name }}- 62 | ${{ runner.os }}-mix- 63 | 64 | - name: Install dependencies 65 | run: mix deps.get 66 | 67 | - name: Compiles without warnings 68 | run: mix compile --warnings-as-errors 69 | 70 | - name: Check Formatting 71 | run: mix format --check-formatted 72 | 73 | # TODO: Enable credo later 74 | # - name: Run credo 75 | # run: mix credo 76 | 77 | - name: Run tests 78 | run: mix test 79 | -------------------------------------------------------------------------------- /lib/coney/consumer_executor.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConsumerExecutor do 2 | @moduledoc """ 3 | Module responsible for processing a rabbit message and send the response 4 | back to `ConnectionServer`. Started (and monitored) by `ConsumerServer`. 5 | """ 6 | require Logger 7 | 8 | alias Coney.{ConnectionServer, ExecutionTask} 9 | 10 | def consume(%ExecutionTask{consumer: consumer, payload: payload, meta: meta} = task) do 11 | payload 12 | |> consumer.parse(meta) 13 | |> consumer.process(meta) 14 | |> handle_result(task) 15 | rescue 16 | exception -> 17 | cond do 18 | function_exported?(consumer, :error_happened, 3) -> 19 | exception 20 | |> consumer.error_happened(payload, meta) 21 | |> handle_result(task) 22 | 23 | function_exported?(consumer, :error_happened, 4) -> 24 | exception 25 | |> consumer.error_happened(__STACKTRACE__, payload, meta) 26 | |> handle_result(task) 27 | 28 | true -> 29 | log_error(consumer, exception) 30 | reject(task) 31 | end 32 | end 33 | 34 | defp handle_result(:ok, task), do: ack(task) 35 | 36 | defp handle_result(:reject, task), do: reject(task) 37 | 38 | defp handle_result(:redeliver, task), do: redeliver(task) 39 | 40 | defp handle_result({:reply, response}, task), do: reply(task, response) 41 | 42 | defp ack(%ExecutionTask{tag: tag, chan: chan}) do 43 | ConnectionServer.confirm(chan, tag) 44 | end 45 | 46 | defp reply(%ExecutionTask{settings: %{respond_to: exchange_name}} = task, response) do 47 | ack(task) 48 | send_message(exchange_name, response) 49 | end 50 | 51 | defp redeliver(%ExecutionTask{tag: tag, chan: chan}) do 52 | ConnectionServer.reject(chan, tag, true) 53 | end 54 | 55 | defp reject(%ExecutionTask{tag: tag, chan: chan}) do 56 | ConnectionServer.reject(chan, tag, false) 57 | end 58 | 59 | defp send_message(exchange, {routing_key, response}) do 60 | ConnectionServer.publish(exchange, routing_key, response) 61 | end 62 | 63 | defp send_message(exchange, response) do 64 | send_message(exchange, {"", response}) 65 | end 66 | 67 | defp log_error(consumer, exception) do 68 | Logger.error( 69 | "#{consumer} (#{inspect(self())}) unhandled exception, message will be rejected: #{inspect(exception)}" 70 | ) 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/coney/consumer_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConsumerServer do 2 | @moduledoc """ 3 | GenServer for handling RabbitMQ messages. Spawns and monitors one task per message 4 | and forwards the response to `ConnectionServer`. 5 | """ 6 | 7 | use GenServer 8 | 9 | alias Coney.{ConnectionServer, ConsumerExecutor, ExecutionTask} 10 | 11 | require Logger 12 | 13 | def child_spec([consumer]) do 14 | %{ 15 | id: consumer, 16 | start: {__MODULE__, :start_link, [[consumer]]} 17 | } 18 | end 19 | 20 | def start_link([consumer]) do 21 | GenServer.start_link(__MODULE__, [consumer], name: consumer) 22 | end 23 | 24 | @impl GenServer 25 | def init([consumer]) do 26 | chan = ConnectionServer.subscribe(consumer) 27 | 28 | Logger.info("[Coney] - Started consumer #{inspect(consumer)}") 29 | 30 | {:ok, %{consumer: consumer, chan: chan, tasks: %{}}} 31 | end 32 | 33 | @impl GenServer 34 | def handle_info({:basic_consume_ok, %{consumer_tag: _consumer_tag}}, state) do 35 | {:noreply, state} 36 | end 37 | 38 | def handle_info({:basic_cancel, %{consumer_tag: _consumer_tag}}, state) do 39 | {:stop, :normal, state} 40 | end 41 | 42 | def handle_info({:basic_cancel_ok, %{consumer_tag: _consumer_tag}}, state) do 43 | {:noreply, state} 44 | end 45 | 46 | def handle_info( 47 | { 48 | :basic_deliver, 49 | payload, 50 | %{delivery_tag: tag} = meta 51 | }, 52 | %{consumer: consumer, chan: chan} = state 53 | ) do 54 | task = ExecutionTask.build(consumer, chan, payload, tag, meta) 55 | 56 | {_pid, ref} = spawn_monitor(ConsumerExecutor, :consume, [task]) 57 | 58 | state = put_in(state.tasks[ref], %{chan: chan, tag: tag}) 59 | 60 | {:noreply, state} 61 | end 62 | 63 | # Received after the task completed successfully 64 | def handle_info({:DOWN, ref, _, _, :normal = _reason}, state) do 65 | {_task, state} = pop_in(state.tasks[ref]) 66 | Process.demonitor(ref, [:flush]) 67 | 68 | {:noreply, state} 69 | end 70 | 71 | # Received if the task terminate abnormally 72 | def handle_info({:DOWN, ref, _, _, reason}, state) do 73 | Logger.error("[#{__MODULE__}] Error processing message with reason: #{inspect(reason)}") 74 | {task, state} = pop_in(state.tasks[ref]) 75 | # Reject message 76 | reject(task) 77 | 78 | Process.demonitor(ref, [:flush]) 79 | {:noreply, state} 80 | end 81 | 82 | defp reject(%{chan: chan, tag: tag}), do: ConnectionServer.reject(chan, tag, false) 83 | defp reject(_task), do: :ok 84 | end 85 | -------------------------------------------------------------------------------- /test/lib/coney/consumer/consumer_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ConsumerServerTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Coney.ConsumerServer 5 | 6 | setup do 7 | ref = Coney.ConnectionServer.subscribe(Coney.FakeConsumer) 8 | 9 | [ 10 | args: [Coney.FakeConsumer], 11 | state: %{consumer: Coney.FakeConsumer, tasks: %{}, chan: ref} 12 | ] 13 | end 14 | 15 | test "initial state", %{args: args, state: state} do 16 | assert {:ok, initial_state} = ConsumerServer.init(args) 17 | assert initial_state.consumer == state.consumer 18 | assert initial_state.tasks |> Map.equal?(Map.new()) 19 | assert initial_state.chan |> is_reference() 20 | end 21 | 22 | test ":basic_consume_ok", %{state: state} do 23 | assert {:noreply, ^state} = 24 | ConsumerServer.handle_info({:basic_consume_ok, %{consumer_tag: nil}}, state) 25 | end 26 | 27 | test ":basic_cancel", %{state: state} do 28 | assert {:stop, :normal, ^state} = 29 | ConsumerServer.handle_info({:basic_cancel, %{consumer_tag: nil}}, state) 30 | end 31 | 32 | test ":basic_cancel_ok", %{state: state} do 33 | assert {:noreply, ^state} = 34 | ConsumerServer.handle_info({:basic_cancel_ok, %{consumer_tag: nil}}, state) 35 | end 36 | 37 | test ":basic_deliver", %{state: state} do 38 | message = 39 | {:basic_deliver, :ok, %{delivery_tag: :tag, redelivered: :redelivered, routing_key: ""}} 40 | 41 | {:noreply, updated_state} = ConsumerServer.handle_info(message, state) 42 | 43 | assert updated_state.consumer == state.consumer 44 | assert updated_state.chan == state.chan 45 | end 46 | 47 | describe "handle_info/2" do 48 | setup do 49 | %{state: %{consumer: Coney.FakeConsumer, tasks: Map.new(), chan: :erlang.make_ref()}} 50 | end 51 | 52 | test "demonitors a task once it completes successfully", %{state: state} do 53 | task_ref = :erlang.make_ref() 54 | state = put_in(state, [:tasks, task_ref], 1) 55 | 56 | refute state[:tasks] |> Map.equal?(Map.new()) 57 | 58 | down_msg = {:DOWN, task_ref, :dont_care, :dont_care, :normal} 59 | 60 | assert {:noreply, new_state} = ConsumerServer.handle_info(down_msg, state) 61 | assert new_state[:tasks] |> Map.equal?(Map.new()) 62 | end 63 | 64 | test "demonitors a task and rejects message if it terminates abruptly", %{state: state} do 65 | task_ref = :erlang.make_ref() 66 | 67 | state = put_in(state, [:tasks, task_ref], 1) 68 | 69 | down_msg = {:DOWN, task_ref, :dont_care, :dont_care, :error} 70 | 71 | assert {:noreply, new_state} = ConsumerServer.handle_info(down_msg, state) 72 | 73 | assert new_state[:tasks] |> Map.equal?(Map.new()) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/lib/coney/rabbit_connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule RabbitConnectionTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias AMQP.{Connection, Channel, Basic, Queue} 5 | alias Coney.RabbitConnection 6 | 7 | describe "RabbitConnection.subscribe/3" do 8 | setup do 9 | {:ok, conn} = Connection.open() 10 | {:ok, chan} = Channel.open(conn) 11 | 12 | on_exit(fn -> 13 | Channel.close(chan) 14 | Connection.close(conn) 15 | end) 16 | 17 | Queue.declare(chan, "generic_queue", []) 18 | 19 | %{chan: chan, conn: conn} 20 | end 21 | 22 | test "declares direct exchange", %{conn: conn} do 23 | topology = %{exchanges: [{:direct, "direct_exchange"}], queues: []} 24 | 25 | assert :ok = RabbitConnection.init_topology(conn, topology) 26 | end 27 | 28 | test "declares fanout exchange", %{conn: conn} do 29 | topology = %{exchanges: [{:fanout, "fanout_exchange"}], queues: []} 30 | 31 | assert :ok = RabbitConnection.init_topology(conn, topology) 32 | end 33 | 34 | test "declares topic exchange", %{conn: conn} do 35 | topology = %{exchanges: [{:topic, "topic_exchange"}], queues: []} 36 | 37 | assert :ok = RabbitConnection.init_topology(conn, topology) 38 | end 39 | 40 | test "declares queue with default exchange binding", %{conn: conn, chan: chan} do 41 | queue = {"queue", %{options: [], bindings: [[exchange: "test_exchange"]]}} 42 | topology = %{exchanges: [{:direct, "test_exchange"}], queues: [queue]} 43 | 44 | assert :ok = RabbitConnection.init_topology(conn, topology) 45 | assert {:ok, _consumer} = Basic.consume(chan, "queue", nil) 46 | end 47 | 48 | test "declares queue with default exchange with :default option", %{conn: conn, chan: chan} do 49 | queue = {"queue", %{options: [], bindings: [[exchange: :default]]}} 50 | topology = %{exchanges: [], queues: [queue]} 51 | 52 | assert :ok = RabbitConnection.init_topology(conn, topology) 53 | assert {:ok, _consumer} = Basic.consume(chan, "queue", nil) 54 | end 55 | 56 | test "declares queue with arguments and binds to exchange with routing key", %{ 57 | conn: conn, 58 | chan: chan 59 | } do 60 | queue = 61 | {"dlx_queue", 62 | %{ 63 | options: [arguments: [{"x-dead-letter-exchange", :longstr, "dlx_exchange"}]], 64 | bindings: [[exchange: "test_exchange", options: [routing_key: "test.route"]]] 65 | }} 66 | 67 | topology = %{ 68 | exchanges: [{:topic, "dlx_exchange"}, {:direct, "test_exchange"}], 69 | queues: [queue] 70 | } 71 | 72 | assert :ok = RabbitConnection.init_topology(conn, topology) 73 | assert {:ok, _consumer} = Basic.consume(chan, "dlx_queue", nil) 74 | end 75 | 76 | test "subscribes to a queue", %{chan: chan} do 77 | consumer = %{ 78 | connection: %{ 79 | prefetch_count: 10, 80 | queue: "generic_queue" 81 | } 82 | } 83 | 84 | assert {:ok, _} = RabbitConnection.subscribe(chan, nil, consumer) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/coney/rabbit_connection.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.RabbitConnection do 2 | use AMQP 3 | 4 | require Logger 5 | 6 | def open(%{url: url, timeout: timeout} = settings) do 7 | case connect(url) do 8 | {:ok, conn} -> 9 | Logger.debug("#{__MODULE__} (#{inspect(self())}) connected to #{url}") 10 | conn 11 | 12 | {:error, error} -> 13 | Logger.error( 14 | "#{__MODULE__} (#{inspect(self())}) connection to #{url} refused: #{inspect(error)}" 15 | ) 16 | 17 | :timer.sleep(timeout) 18 | open(settings) 19 | end 20 | end 21 | 22 | def close(conn) do 23 | Connection.close(conn) 24 | end 25 | 26 | defp connect(url) do 27 | url 28 | |> choose_server() 29 | |> Connection.open() 30 | end 31 | 32 | defp choose_server(url) when is_binary(url), do: url 33 | defp choose_server(urls) when is_list(urls), do: Enum.random(urls) 34 | 35 | def create_channel(conn) do 36 | {:ok, chan} = Channel.open(conn) 37 | chan 38 | end 39 | 40 | def close_channel(chan) do 41 | Channel.close(chan) 42 | end 43 | 44 | def subscribe(chan, consumer_pid, consumer) do 45 | connection = consumer.connection 46 | consumer_tag = Map.get(connection, :consumer_tag, "") 47 | 48 | Basic.qos(chan, prefetch_count: connection.prefetch_count) 49 | 50 | {:ok, _consumer_tag} = 51 | Basic.consume(chan, connection.queue, consumer_pid, consumer_tag: consumer_tag) 52 | end 53 | 54 | def publish(conn, exchange_name, routing_key, message) do 55 | chan = create_channel(conn) 56 | 57 | try do 58 | Basic.publish(chan, exchange_name, routing_key, message) 59 | after 60 | Channel.close(chan) 61 | end 62 | end 63 | 64 | def confirm(channel, tag) do 65 | Basic.ack(channel, tag) 66 | end 67 | 68 | def reject(channel, tag, opts) do 69 | Basic.reject(channel, tag, opts) 70 | end 71 | 72 | def init_topology(conn, %{exchanges: exchanges, queues: queues}) do 73 | channel = create_channel(conn) 74 | 75 | Enum.each(exchanges, &declare_exchange(channel, &1)) 76 | Enum.each(queues, &declare_queue(channel, &1)) 77 | 78 | Channel.close(channel) 79 | end 80 | 81 | def init_topology(conn, %{queues: _queues} = params) do 82 | init_topology(conn, Map.put(params, :exchanges, [])) 83 | end 84 | 85 | def init_topology(conn, %{exchanges: _exchanges} = params) do 86 | init_topology(conn, Map.put(params, :queues, [])) 87 | end 88 | 89 | def init_topology(conn, params) do 90 | init_topology(conn, Map.merge(params, %{exchanges: [], queues: []})) 91 | end 92 | 93 | defp declare_queue(channel, {name, %{options: opts, bindings: bindings}}) do 94 | Queue.declare(channel, name, opts) 95 | Enum.each(bindings, &create_binding(channel, name, &1)) 96 | 97 | name 98 | end 99 | 100 | defp declare_queue(channel, {name, _}) do 101 | declare_queue(channel, %{name: name, options: [], bindings: []}) 102 | end 103 | 104 | defp create_binding(_channel, _queue, []) do 105 | :ok 106 | end 107 | 108 | defp create_binding(channel, queue, binding_opts) do 109 | exchange = Keyword.get(binding_opts, :exchange, :default) 110 | opts = Keyword.get(binding_opts, :options, []) 111 | create_binding(channel, queue, exchange, opts) 112 | end 113 | 114 | defp create_binding(_channel, _queue, :default, _opts) do 115 | :ok 116 | end 117 | 118 | defp create_binding(channel, queue, exchange, opts) do 119 | Queue.bind(channel, queue, exchange, opts) 120 | end 121 | 122 | defp declare_exchange(_, {_, ""}), do: :default_exchange 123 | 124 | defp declare_exchange(_, {_, "", _}), do: :default_exchange 125 | 126 | defp declare_exchange(_, :default), do: :default_exchange 127 | 128 | defp declare_exchange(channel, {type, name}) do 129 | declare_exchange(channel, {type, name, []}) 130 | end 131 | 132 | defp declare_exchange(channel, {type, name, params}) do 133 | Exchange.declare(channel, name, type, params) 134 | name 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/lib/coney/connection_server_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConnectionServerTest do 2 | use ExUnit.Case 3 | 4 | alias Coney.ConnectionServer 5 | alias Coney.RabbitConnection 6 | 7 | setup do 8 | settings = %{url: "amqp://guest:guest@localhost:5672", timeout: 1_000} 9 | topology = Map.new() 10 | %{init_args: %{adapter: RabbitConnection, settings: settings, topology: topology}} 11 | end 12 | 13 | describe "init/1" do 14 | test "starts with default settings", %{init_args: init_args} do 15 | %{settings: settings, adapter: adapter, topology: topology} = init_args 16 | 17 | assert {:ok, state, {:continue, nil}} = ConnectionServer.init([adapter, settings, topology]) 18 | 19 | assert state.channels |> Map.equal?(Map.new()) 20 | assert state.adapter == adapter 21 | assert state.settings == settings 22 | assert state.topology == topology 23 | end 24 | 25 | test "registers itself in the connection registry", %{init_args: init_args} do 26 | %{settings: settings, adapter: adapter, topology: topology} = init_args 27 | 28 | assert {:ok, _state, {:continue, nil}} = ConnectionServer.init([adapter, settings, topology]) 29 | 30 | status = Coney.HealthCheck.ConnectionRegistry.status() |> Map.new() 31 | 32 | assert Map.get(status, self(), :connected) 33 | end 34 | end 35 | 36 | describe "handle_continue/2" do 37 | test "sets the connection in the state", %{init_args: init_args} do 38 | %{settings: settings, adapter: adapter, topology: topology} = init_args 39 | assert {:ok, state, {:continue, nil}} = ConnectionServer.init([adapter, settings, topology]) 40 | 41 | assert is_nil(state.amqp_conn) 42 | 43 | assert {:noreply, new_state} = ConnectionServer.handle_continue(nil, state) 44 | 45 | refute is_nil(new_state.amqp_conn) 46 | end 47 | end 48 | 49 | describe "handle_info/2" do 50 | test "reconnects channels when receives a connection lost message", %{init_args: init_args} do 51 | %{settings: settings, adapter: adapter, topology: topology} = init_args 52 | # Init 53 | assert {:ok, state, {:continue, nil}} = ConnectionServer.init([adapter, settings, topology]) 54 | 55 | # Open connection 56 | assert {:noreply, state} = ConnectionServer.handle_continue(nil, state) 57 | 58 | # Subscribe a channel 59 | assert {:reply, channel_ref, connected_state} = 60 | ConnectionServer.handle_call( 61 | {:subscribe, Coney.FakeConsumer}, 62 | {self(), :erlang.make_ref()}, 63 | state 64 | ) 65 | 66 | channel_info = Map.get(connected_state.channels, channel_ref) 67 | 68 | # Connection lost 69 | down_msg = {:DOWN, :erlang.make_ref(), :process, self(), :connection_lost} 70 | 71 | assert {:noreply, reconnect_state} = ConnectionServer.handle_info(down_msg, connected_state) 72 | 73 | new_channel_info = Map.get(reconnect_state.channels, channel_ref) 74 | 75 | {_pid, consumer, old_channel} = channel_info 76 | {_other_pid, ^consumer, new_channel} = new_channel_info 77 | 78 | refute old_channel == new_channel 79 | end 80 | end 81 | 82 | describe "handle_call/3" do 83 | test "subscribes a consumer and returns a channel reference", %{init_args: init_args} do 84 | %{settings: settings, adapter: adapter, topology: topology} = init_args 85 | # Init 86 | assert {:ok, state, {:continue, nil}} = ConnectionServer.init([adapter, settings, topology]) 87 | 88 | # Open connection 89 | assert {:noreply, state} = ConnectionServer.handle_continue(nil, state) 90 | 91 | # Subscribe a channel 92 | assert {:reply, channel_ref, new_state} = 93 | ConnectionServer.handle_call( 94 | {:subscribe, Coney.FakeConsumer}, 95 | {self(), :erlang.make_ref()}, 96 | state 97 | ) 98 | 99 | assert is_reference(channel_ref) 100 | 101 | pid = self() 102 | 103 | assert {^pid, Coney.FakeConsumer, _} = Map.get(new_state.channels, channel_ref) 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.1.3] (2025-01-03) 2 | 3 | ### Enhancements 4 | - Add asynchronous message publishing. 5 | 6 | ## [3.1.2] (2024-08-16) 7 | 8 | ### Bug fixes 9 | - Fix missing specification for `ConsumerServer` unique id 10 | 11 | ### Internal 12 | - Prefix modules with `Coney.` to avoid conflicts 13 | 14 | ## [3.1.1] (2024-08-16) - BROKEN 15 | 16 | ### Bug fixes 17 | - Fix missing `:name` when starting `ConsumerServer` 18 | 19 | ## [3.1.0] (2024-08-14) - BROKEN 20 | 21 | ### Enhancements 22 | - Add `:enabled` config value 23 | - `:adapter` config value is now optional and defaults to `Coney.RabbitConnection` 24 | 25 | ## [3.0.2] (2024-08-12) - BROKEN 26 | 27 | ### Bug fixes 28 | - Fix incorrect termination order where the connection to RabbitMQ was closed before the channels. 29 | - Implemented monitoring for spawned messages to improve tracebility. 30 | 31 | ## [3.0.1] 32 | 33 | - Updated dependencies (`amqp`) #16 34 | 35 | ## [3.0.0] 36 | 37 | ### Changes 38 | 39 | - Introduce RabbitMQ `topology` configuration and setup. Coney now starts up in two phases, first it sets up the topology (queues/exchanges) and then starts consuming from the queues. This allows more complex RabbitMQ setups like retry queues, etc. 40 | - Remove pooling for clusters as this should be handled on cluster side instead. 41 | - New option `consumer_tag` for worker `connection` settings. 42 | 43 | ### Enhancements 44 | 45 | - `auto_start` option allow you choose how you want to start Coney. Use `false` value if you want to add `Coney.ApplicationSupervisor` to your supervisor. `true` (default value) means that Coney will run on application start. 46 | - Settings module. You can speficfy a module name under `coney.settings` section and define `settings/0` function, which should return connection configuration. 47 | 48 | ## [2.2.1] 49 | 50 | ### Bug fixes 51 | 52 | - Fixed bug when connection server's pid remained in the list of connections after death 53 | 54 | ## [2.2.0] 55 | 56 | ### Enhancements 57 | 58 | - New Coney module with `publish/2`, `publish/3`, `status/0` methods 59 | 60 | ## [2.1.1] 61 | 62 | ### Bug fixes 63 | 64 | - Fixed bug with logging of consumer start if worker is defined with map 65 | 66 | ## [2.1.0] 67 | 68 | ### Enhancements 69 | 70 | - Error logs for connection errors 71 | - Error log for unhandled exceptions if `error_handler` is missing 72 | - Debug log after connection was established 73 | - Debug log after consumer was started 74 | - `error_happened/4` callback added 75 | 76 | ## [2.0.2] 77 | 78 | ### Enhancements 79 | 80 | - Added `:default` option for `connection.exchange` 81 | 82 | ## [2.0.0] 83 | 84 | ### Enhancements 85 | 86 | - Channel per each publish message. 87 | 88 | ### Changes 89 | 90 | - Change value of `respond_to` field in connection specification to string with exchange name 91 | - No need to add Coney to your application supervisor tree 92 | - Consumers should be described in `worker` config parameter 93 | 94 | ## [1.0.0] 95 | 96 | ### Enhancements 97 | 98 | - `amqp` updated to version 1.0 99 | - `poison` removed from dependencies 100 | - Added `Coney.Consumer` behaviour 101 | - Added option `pool_size` - number of RabbitMQ connections. 102 | 103 | ### Changes 104 | - Changed format of consumer `process/2` and `error_happened/3` functions 105 | - `error_happened/3` marked as optional 106 | - `Coney.AMQPConnection` removed from configs 107 | - Logging removed 108 | 109 | ## [0.4.3] 110 | 111 | ### Enhancements 112 | 113 | - Allow to define several RabbitMQ hosts for connection (will be used random host from list) 114 | 115 | ```elixir 116 | # config/config.exs 117 | 118 | config :coney, Coney.AMQPConnection, [ 119 | settings: %{ 120 | url: ["amqp://guest:guest@localhost", "amqp://guest:guest@other_host"] 121 | } 122 | ] 123 | ``` 124 | 125 | ## [0.4.2] 126 | 127 | ### Enhancements 128 | 129 | - `{:reject, reason}` return value: 130 | Reject message without redelivery. 131 | 132 | ```elixir 133 | defmodule MyConsumer do 134 | def connection do 135 | #... 136 | end 137 | 138 | def parse(payload) do 139 | String.to_integer(payload) 140 | end 141 | 142 | def process(number) do 143 | if number <= 10 do 144 | {:ok, "Work done"} 145 | else 146 | {:reject, "Number should be less than 10"} 147 | end 148 | end 149 | end 150 | ``` 151 | ### Bug fixes 152 | 153 | - Fix warnings about undefined behaviour function publish/2, publish/3 154 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "amqp": {:hex, :amqp, "3.3.0", "056d9f4bac96c3ab5a904b321e70e78b91ba594766a1fc2f32afd9c016d9f43b", [:mix], [{:amqp_client, "~> 3.9", [hex: :amqp_client, repo: "hexpm", optional: false]}], "hexpm", "8d3ae139d2646c630d674a1b8d68c7f85134f9e8b2a1c3dd5621616994b10a8b"}, 3 | "amqp_client": {:hex, :amqp_client, "3.12.14", "2b677bc3f2e2234ba7517042b25d72071a79735042e91f9116bd3c176854b622", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:rabbit_common, "3.12.14", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm", "5f70b6c3b1a739790080da4fddc94a867e99f033c4b1edc20d6ff8b8fb4bd160"}, 4 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 5 | "credentials_obfuscation": {:hex, :credentials_obfuscation, "3.4.0", "34e18b126b3aefd6e8143776fbe1ceceea6792307c99ac5ee8687911f048cfd7", [:rebar3], [], "hexpm", "738ace0ed5545d2710d3f7383906fc6f6b582d019036e5269c4dbd85dbced566"}, 6 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 8 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 9 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 15 | "rabbit_common": {:hex, :rabbit_common, "3.12.14", "466123ee7346a3cdac078c0c302bcd36da4523e8acd678c1b992f7b4df1f7914", [:make, :rebar3], [{:credentials_obfuscation, "3.4.0", [hex: :credentials_obfuscation, repo: "hexpm", optional: false]}, {:recon, "2.5.3", [hex: :recon, repo: "hexpm", optional: false]}, {:thoas, "1.0.0", [hex: :thoas, repo: "hexpm", optional: false]}], "hexpm", "70c31a51f7401cc0204ddef2745d98680c2e0df67e3b0c9e198916881fde3293"}, 16 | "recon": {:hex, :recon, "2.5.3", "739107b9050ea683c30e96de050bc59248fd27ec147696f79a8797ff9fa17153", [:mix, :rebar3], [], "hexpm", "6c6683f46fd4a1dfd98404b9f78dcabc7fcd8826613a89dcb984727a8c3099d7"}, 17 | "thoas": {:hex, :thoas, "1.0.0", "567c03902920827a18a89f05b79a37b5bf93553154b883e0131801600cf02ce0", [:rebar3], [], "hexpm", "fc763185b932ecb32a554fb735ee03c3b6b1b31366077a2427d2a97f3bd26735"}, 18 | } 19 | -------------------------------------------------------------------------------- /lib/coney/connection_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Coney.ConnectionServer do 2 | @moduledoc """ 3 | Handles connections between `ConsumerServer` and the RabbitMQ instance(s). 4 | 5 | This module abstracts away the connection status of RabbitMQ. Instead, when 6 | a new `ConsumerServer` is started, it requests `ConnectionServer` to open a channel. 7 | ConnectionServer opens a real amqp channel, keeps a reference to it in its state and 8 | returns an erlang reference to `ConsumerServer`. When `ConsumerServer` replies (ack/reject) 9 | an incoming RabbitMQ message it sends the erlang reference to ConnectionServer and then 10 | ConnectionServer looks up the real channel. 11 | 12 | ConnectionServer can handle RabbitMQ disconnects independently of ConsumerServer. 13 | When connection is lost and then regained, ConnectionServer simply updates its 14 | map of {erlang_ref, AMQP.Connection}, ConsumerServer keeps using the same erlang_ref. 15 | """ 16 | use GenServer 17 | 18 | require Logger 19 | 20 | alias Coney.HealthCheck.ConnectionRegistry 21 | 22 | defmodule State do 23 | defstruct [:adapter, :settings, :amqp_conn, :topology, :channels] 24 | end 25 | 26 | def start_link([[adapter: adapter, settings: settings, topology: topology]]) do 27 | GenServer.start_link(__MODULE__, [adapter, settings, topology], name: __MODULE__) 28 | end 29 | 30 | @impl GenServer 31 | def init([adapter, settings, topology]) do 32 | ConnectionRegistry.associate(self()) 33 | 34 | {:ok, %State{adapter: adapter, settings: settings, topology: topology, channels: Map.new()}, 35 | {:continue, nil}} 36 | end 37 | 38 | @impl true 39 | def handle_continue(_continue_arg, state) do 40 | {:noreply, rabbitmq_connect(state)} 41 | end 42 | 43 | @spec confirm(reference(), any()) :: :confirmed 44 | def confirm(channel_ref, tag) do 45 | GenServer.call(__MODULE__, {:confirm, channel_ref, tag}) 46 | end 47 | 48 | @spec reject(reference(), any(), boolean()) :: :rejected 49 | def reject(channel_ref, tag, requeue) do 50 | GenServer.call(__MODULE__, {:reject, channel_ref, tag, requeue}) 51 | end 52 | 53 | @spec publish(String.t(), any()) :: :published 54 | def publish(exchange_name, message) do 55 | GenServer.call(__MODULE__, {:publish, exchange_name, message}) 56 | end 57 | 58 | @spec publish(String.t(), String.t(), any()) :: :published 59 | def publish(exchange_name, routing_key, message) do 60 | GenServer.call(__MODULE__, {:publish, exchange_name, routing_key, message}) 61 | end 62 | 63 | @spec publish_async(String.t(), any()) :: :ok 64 | def publish_async(exchange_name, message) do 65 | GenServer.cast(__MODULE__, {:publish, exchange_name, message}) 66 | end 67 | 68 | @spec publish_async(String.t(), String.t(), any()) :: :ok 69 | def publish_async(exchange_name, routing_key, message) do 70 | GenServer.cast(__MODULE__, {:publish, exchange_name, routing_key, message}) 71 | end 72 | 73 | @spec subscribe(any()) :: reference() 74 | def subscribe(consumer) do 75 | GenServer.call(__MODULE__, {:subscribe, consumer}) 76 | end 77 | 78 | @impl GenServer 79 | def handle_info({:DOWN, _, :process, _pid, reason}, state) do 80 | ConnectionRegistry.disconnected(self()) 81 | Logger.error("#{__MODULE__} (#{inspect(self())}) connection lost: #{inspect(reason)}") 82 | {:noreply, state |> rabbitmq_connect() |> update_channels()} 83 | end 84 | 85 | @impl GenServer 86 | def terminate(_reason, %State{amqp_conn: conn, adapter: adapter, channels: channels} = _state) do 87 | Logger.info("[Coney] - Terminating #{inspect(conn)}") 88 | close_channels(channels, adapter) 89 | :ok = adapter.close(conn) 90 | ConnectionRegistry.terminated(self()) 91 | end 92 | 93 | @impl GenServer 94 | def handle_call( 95 | {:confirm, channel_ref, tag}, 96 | _from, 97 | %State{adapter: adapter, channels: channels} = state 98 | ) do 99 | channel = channel_from_ref(channels, channel_ref) 100 | adapter.confirm(channel, tag) 101 | 102 | {:reply, :confirmed, state} 103 | end 104 | 105 | def handle_call( 106 | {:subscribe, consumer}, 107 | {consumer_pid, _tag}, 108 | %State{amqp_conn: conn, adapter: adapter, channels: channels} = state 109 | ) do 110 | channel = adapter.create_channel(conn) 111 | channel_ref = :erlang.make_ref() 112 | 113 | adapter.subscribe(channel, consumer_pid, consumer) 114 | 115 | new_channels = Map.put(channels, channel_ref, {consumer_pid, consumer, channel}) 116 | 117 | Logger.debug("#{inspect(consumer)} (#{inspect(consumer_pid)}) started") 118 | {:reply, channel_ref, %State{state | channels: new_channels}} 119 | end 120 | 121 | def handle_call( 122 | {:reject, channel_ref, tag, requeue}, 123 | _from, 124 | %State{adapter: adapter, channels: channels} = state 125 | ) do 126 | channel = channel_from_ref(channels, channel_ref) 127 | adapter.reject(channel, tag, requeue: requeue) 128 | 129 | {:reply, :rejected, state} 130 | end 131 | 132 | def handle_call( 133 | {:publish, exchange_name, message}, 134 | _from, 135 | %State{adapter: adapter, amqp_conn: conn} = state 136 | ) do 137 | adapter.publish(conn, exchange_name, "", message) 138 | 139 | {:reply, :published, state} 140 | end 141 | 142 | def handle_call({:publish, exchange_name, routing_key, message}, _from, state) do 143 | state.adapter.publish(state.amqp_conn, exchange_name, routing_key, message) 144 | 145 | {:reply, :published, state} 146 | end 147 | 148 | @impl GenServer 149 | def handle_cast({:publish, exchange_name, message}, %State{} = state) do 150 | state.adapter.publish(state.amqp_conn, exchange_name, "", message) 151 | 152 | {:noreply, state} 153 | end 154 | 155 | def handle_cast({:publish, exchange_name, routing_key, message}, %State{} = state) do 156 | state.adapter.publish(state.amqp_conn, exchange_name, routing_key, message) 157 | 158 | {:noreply, state} 159 | end 160 | 161 | defp rabbitmq_connect( 162 | %State{ 163 | adapter: adapter, 164 | settings: settings, 165 | topology: topology 166 | } = state 167 | ) do 168 | conn = adapter.open(settings) 169 | Process.monitor(conn.pid) 170 | adapter.init_topology(conn, topology) 171 | 172 | ConnectionRegistry.connected(self()) 173 | 174 | %State{state | amqp_conn: conn} 175 | end 176 | 177 | defp channel_from_ref(channels, channel_ref) do 178 | {_consumer_pid, _consumer, channel} = Map.fetch!(channels, channel_ref) 179 | 180 | channel 181 | end 182 | 183 | defp update_channels(%State{amqp_conn: conn, adapter: adapter, channels: channels} = state) do 184 | new_channels = 185 | Map.new(channels, fn {channel_ref, {consumer_pid, consumer, _dead_channel}} -> 186 | new_channel = adapter.create_channel(conn) 187 | adapter.subscribe(new_channel, consumer_pid, consumer) 188 | 189 | {channel_ref, {consumer_pid, consumer, new_channel}} 190 | end) 191 | 192 | Logger.info("[Coney] - Connection re-restablished for #{inspect(conn)}") 193 | 194 | %State{state | channels: new_channels} 195 | end 196 | 197 | defp close_channels(channels, adapter) do 198 | Enum.each(channels, fn {_channel_ref, {_consumer_pid, _consumer, channel}} -> 199 | adapter.close_channel(channel) 200 | end) 201 | 202 | Logger.info("[Coney] - Closed #{map_size(channels)} channels") 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Coney 2 | 3 | [![Hex.pm Version](https://img.shields.io/hexpm/v/coney)](https://hex.pm/packages/coney) 4 | ![Build Status](https://github.com/coingaming/coney/actions/workflows/ci.yaml/badge.svg) 5 | 6 | Consumer server for RabbitMQ with message publishing functionality. 7 | 8 | ## Table of Contents 9 | 10 | - [Coney](#Coney) 11 | - [Table of Contents](#Table-of-Contents) 12 | - [Installation](#Installation) 13 | - [Setup a consumer server](#Setup-a-consumer-server) 14 | - [Configure consumers](#Configure-consumers) 15 | - [Rescuing exceptions](#Rescuing-exceptions) 16 | - [error_happened/3](#errorhappened3) 17 | - [error_happened/4](#errorhappened4) 18 | - [.process/2 and .error_happened return format](#process2-and-errorhappened-return-format) 19 | - [Reply description](#Reply-description) 20 | - [The default exchange](#The-default-exchange) 21 | - [Publish message](#Publish-message) 22 | - [Publish message asynchronously](#Publish-message-asynchronously) 23 | - [Checking connections](#Checking-connections) 24 | - [Contributing](#Contributing) 25 | - [License](#License) 26 | 27 | ## Installation 28 | 29 | Add Coney as a dependency in your `mix.exs` file. 30 | 31 | ```elixir 32 | def deps do 33 | [{:coney, "~> 3.0"}] 34 | end 35 | ``` 36 | 37 | After you are done, run `mix deps.get` in your shell to fetch and compile Coney. 38 | 39 | ## Setup a consumer server 40 | 41 | Default config: 42 | 43 | ```elixir 44 | # config/config.exs 45 | config :coney, 46 | auto_start: true, 47 | settings: %{ 48 | url: "amqp://guest:guest@localhost", # or ["amqp://guest:guest@localhost", "amqp://guest:guest@other_host"] 49 | timeout: 1000 50 | } 51 | ``` 52 | 53 | If you need to create exchanges or queues before starting the consumer, you can define your RabbitMQ topology as follows: 54 | ```elixir 55 | config :coney, 56 | topology: %{ 57 | exchanges: [{:topic, "my_exchange", durable: true}], 58 | queues: %{ 59 | "my_queue" => %{ 60 | options: [ 61 | durable: true, 62 | arguments: [ 63 | {"x-dead-letter-exchange", :longstr, "dlx_exchange"}, 64 | {"x-message-ttl", :signedint, 60000} 65 | ] 66 | ], 67 | bindings: [ 68 | [exchange: "my_exchange", options: [routing_key: "my_queue"]] 69 | ] 70 | } 71 | } 72 | } 73 | ``` 74 | 75 | Also, you can create a confuguration module (if you want to retreive settings from Consul or something else): 76 | 77 | ```elixir 78 | # config/config.exs 79 | config :coney, 80 | auto_start: true, 81 | settings: RabbitConfig, 82 | topology: RabbitConfig 83 | ``` 84 | 85 | ```elixir 86 | defmodule RabbitConfig do 87 | def settings do 88 | %{ 89 | url: "amqp://guest:guest@localhost", 90 | timeout: 1000 91 | } 92 | end 93 | 94 | def topology do 95 | %{ 96 | exchanges: [{:topic, "my_exchange", durable: true}], 97 | queues: %{ 98 | "my_queue" => %{ 99 | options: [ 100 | durable: true, 101 | arguments: [ 102 | {"x-dead-letter-exchange", :longstr, "exchange"}, 103 | {"x-message-ttl", :signedint, 60000} 104 | ] 105 | ], 106 | bindings: [ 107 | [exchange: "my_exchange", options: [routing_key: "my_queue"]] 108 | ] 109 | } 110 | } 111 | } 112 | end 113 | end 114 | ``` 115 | 116 | If you don't want to automatically start Coney and want to control it's start, you can set `auto_start` to `false` and add Coney supervisor into yours: 117 | 118 | 119 | ```elixir 120 | # config/config.exs 121 | config :coney, auto_start: false 122 | ``` 123 | 124 | ```elixir 125 | 126 | defmodule YourApplication do 127 | use Application 128 | 129 | def start(_type, _args) do 130 | Supervisor.start_link([Coney.ApplicationSupervisor], [strategy: :one_for_one]) 131 | end 132 | end 133 | ``` 134 | 135 | If you want to disable Coney altogether (useful for testing config) set `enabled: false` 136 | ```elixir 137 | # config/config.exs 138 | config :coney, enabled: false, settings: %{}, topology: %{} 139 | ``` 140 | 141 | ## Configure consumers 142 | 143 | ```elixir 144 | # config/queues.exs 145 | 146 | config :coney, 147 | workers: [ 148 | MyApplication.MyConsumer 149 | ] 150 | # also you can define mapping like this and skip it in consumer module: 151 | workers: [ 152 | %{ 153 | connection: %{ 154 | prefetch_count: 10, 155 | queue: "my_queue" 156 | }, 157 | worker: MyApplication.MyConsumer 158 | } 159 | ] 160 | ``` 161 | 162 | ```elixir 163 | # web/consumers/my_consumer.ex 164 | 165 | defmodule MyApplication.MyConsumer do 166 | @behaviour Coney.Consumer 167 | 168 | def connection do 169 | %{ 170 | prefetch_count: 10, 171 | queue: "my_queue", 172 | consumer_tag: "MyApp - MyConsumer" # optional 173 | } 174 | end 175 | 176 | def parse(payload, _meta) do 177 | String.to_integer(payload) 178 | end 179 | 180 | def process(number, _meta) do 181 | if number <= 10 do 182 | :ok 183 | else 184 | :reject 185 | end 186 | end 187 | 188 | # Be careful here, if call of `error_happened` will raise an exception, 189 | # message will be not handled properly and may be left unacked in a queue 190 | def error_happened(exception, payload, _meta) do 191 | IO.puts "Exception raised with #{ payload }" 192 | :redeliver 193 | end 194 | end 195 | ``` 196 | 197 | ### Rescuing exceptions 198 | 199 | If exception was happened during calls of `parse` or `process` functions, by default Coney will reject this message. If you want to add additional functionality in order to handle exception in a special manner, you can implement one of `error_happened/3` or `error_happened/4` callbacks. But be careful, if call of `error_happened` will raise an exception, message will be not handled properly and may be left unacked in a queue. 200 | 201 | #### error_happened/3 202 | 203 | This callback receives `exception`, original `payload` and `meta` as parameters. Response format is the same as in [process callback](#process2-and-error_happened-return-format). 204 | 205 | #### error_happened/4 206 | 207 | This callback receives `exception`, `stacktrace`, original `payload` and `meta` as parameters. Response format is the same as in [process callback](#process2-and-error_happened-return-format). 208 | 209 | ### .process/2 and .error_happened return format 210 | 211 | 1. `:ok` - ack message. 212 | 1. `:reject` - reject message. 213 | 1. `:redeliver` - return message to the queue. 214 | 1. `{:reply, binary}` - response will be published to reply exchange. 215 | 216 | ### Reply description 217 | 218 | To use `{:reply, binary}` you should add response exchange in `connection`: 219 | 220 | ```elixir 221 | # web/consumers/my_consumer.ex 222 | 223 | def connection do 224 | %{ 225 | # ... 226 | respond_to: "response_exchange" 227 | } 228 | end 229 | ``` 230 | 231 | Response will be published to `"response_exchange"` exchange. 232 | 233 | ### The default exchange 234 | 235 | To use the default exchange you may declare exchange as `:default`: 236 | 237 | ```elixir 238 | %{ 239 | exchanges: [:default], 240 | } 241 | ``` 242 | The following format is also acceptable: 243 | 244 | ```elixir 245 | %{ 246 | exchanges: [{:direct, ""}] 247 | } 248 | ``` 249 | 250 | Or you can just skip it in the `exchanges` settings and setup the queue in the consumer's settings: 251 | 252 | ```elixir 253 | 254 | %{ 255 | prefetch_count: 10, 256 | queue: "my_queue" 257 | } 258 | 259 | ``` 260 | 261 | ## Publish message 262 | 263 | ```elixir 264 | Coney.publish("exchange", "message") 265 | 266 | # or 267 | 268 | Coney.publish("exchange", "routing_key", "message") 269 | ``` 270 | 271 | ## Publish message asynchronously 272 | 273 | ```elixir 274 | Coney.publish_async("exchange", "message") 275 | 276 | # or 277 | 278 | Coney.publish_async("exchange", "routing_key", "message") 279 | ``` 280 | 281 | ## Checking connections 282 | 283 | You can use`Coney.status/0` if you need to get information about RabbitMQ connections: 284 | 285 | ``` 286 | iex> Coney.status() 287 | [{#PID<0.972.0>, :connected}] 288 | ``` 289 | 290 | Result is a list of tuples, where first element in tuple is a pid of running connection server and second element describes connection status. 291 | 292 | Connection status can be: 293 | 294 | - `:pending` - when coney just started 295 | - `:connected` - when RabbitMQ connection has been established and all consumers have been started 296 | - `:disconnected` - when coney lost connection to RabbitMQ 297 | 298 | ## Contributing 299 | 300 | Bug reports and pull requests are welcome on GitHub at https://github.com/coingaming/coney. 301 | 302 | ### Running test suite locally 303 | 1. Start the RabbitMQ instance via `docker compose up`. 304 | 2. Run `mix test`. 305 | 306 | ## Architecture 307 | ```mermaid 308 | graph TD; 309 | A[ApplicationSupervisor - Supervisor] --> B[ConsumerSupervisor - Supervisor]; 310 | A --> C[ConnectionServer - GenServer]; 311 | B -- supervises many --> D[ConsumerServer - GenServer]; 312 | D -- monitors --> E[ConsumerExecutor]; 313 | E -- sends messages to --> C; 314 | D -- opens AMQP conns via --> C; 315 | ``` 316 | 317 | ## License 318 | 319 | The library is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 320 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: %{ 68 | enabled: [ 69 | # 70 | ## Consistency Checks 71 | # 72 | {Credo.Check.Consistency.ExceptionNames, []}, 73 | {Credo.Check.Consistency.LineEndings, []}, 74 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 75 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 76 | {Credo.Check.Consistency.SpaceInParentheses, []}, 77 | {Credo.Check.Consistency.TabsOrSpaces, []}, 78 | 79 | # 80 | ## Design Checks 81 | # 82 | # You can customize the priority of any check 83 | # Priority values are: `low, normal, high, higher` 84 | # 85 | {Credo.Check.Design.AliasUsage, 86 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 87 | {Credo.Check.Design.TagFIXME, []}, 88 | # You can also customize the exit_status of each check. 89 | # If you don't want TODO comments to cause `mix credo` to fail, just 90 | # set this value to 0 (zero). 91 | # 92 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 93 | 94 | # 95 | ## Readability Checks 96 | # 97 | {Credo.Check.Readability.AliasOrder, []}, 98 | {Credo.Check.Readability.FunctionNames, []}, 99 | {Credo.Check.Readability.LargeNumbers, []}, 100 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 101 | {Credo.Check.Readability.ModuleAttributeNames, []}, 102 | {Credo.Check.Readability.ModuleDoc, []}, 103 | {Credo.Check.Readability.ModuleNames, []}, 104 | {Credo.Check.Readability.ParenthesesInCondition, []}, 105 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 106 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 107 | {Credo.Check.Readability.PredicateFunctionNames, []}, 108 | {Credo.Check.Readability.PreferImplicitTry, []}, 109 | {Credo.Check.Readability.RedundantBlankLines, []}, 110 | {Credo.Check.Readability.Semicolons, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.StringSigils, []}, 113 | {Credo.Check.Readability.TrailingBlankLine, []}, 114 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 115 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 116 | {Credo.Check.Readability.VariableNames, []}, 117 | {Credo.Check.Readability.WithSingleClause, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.Apply, []}, 123 | {Credo.Check.Refactor.CondStatements, []}, 124 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 125 | {Credo.Check.Refactor.FilterCount, []}, 126 | {Credo.Check.Refactor.FilterFilter, []}, 127 | {Credo.Check.Refactor.FunctionArity, []}, 128 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 129 | {Credo.Check.Refactor.MapJoin, []}, 130 | {Credo.Check.Refactor.MatchInCondition, []}, 131 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 132 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 133 | {Credo.Check.Refactor.Nesting, []}, 134 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 135 | {Credo.Check.Refactor.RejectReject, []}, 136 | {Credo.Check.Refactor.UnlessWithElse, []}, 137 | {Credo.Check.Refactor.WithClauses, []}, 138 | 139 | # 140 | ## Warnings 141 | # 142 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 143 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 144 | {Credo.Check.Warning.Dbg, []}, 145 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 146 | {Credo.Check.Warning.IExPry, []}, 147 | {Credo.Check.Warning.IoInspect, []}, 148 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 149 | {Credo.Check.Warning.OperationOnSameValues, []}, 150 | {Credo.Check.Warning.OperationWithConstantResult, []}, 151 | {Credo.Check.Warning.RaiseInsideRescue, []}, 152 | {Credo.Check.Warning.SpecWithStruct, []}, 153 | {Credo.Check.Warning.UnsafeExec, []}, 154 | {Credo.Check.Warning.UnusedEnumOperation, []}, 155 | {Credo.Check.Warning.UnusedFileOperation, []}, 156 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 157 | {Credo.Check.Warning.UnusedListOperation, []}, 158 | {Credo.Check.Warning.UnusedPathOperation, []}, 159 | {Credo.Check.Warning.UnusedRegexOperation, []}, 160 | {Credo.Check.Warning.UnusedStringOperation, []}, 161 | {Credo.Check.Warning.UnusedTupleOperation, []}, 162 | {Credo.Check.Warning.WrongTestFileExtension, []} 163 | ], 164 | disabled: [ 165 | # 166 | # Checks scheduled for next check update (opt-in for now) 167 | {Credo.Check.Refactor.UtcNowTruncate, []}, 168 | 169 | # 170 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 171 | # and be sure to use `mix credo --strict` to see low priority checks) 172 | # 173 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 174 | {Credo.Check.Consistency.UnusedVariableNames, []}, 175 | {Credo.Check.Design.DuplicatedCode, []}, 176 | {Credo.Check.Design.SkipTestWithoutComment, []}, 177 | {Credo.Check.Readability.AliasAs, []}, 178 | {Credo.Check.Readability.BlockPipe, []}, 179 | {Credo.Check.Readability.ImplTrue, []}, 180 | {Credo.Check.Readability.MultiAlias, []}, 181 | {Credo.Check.Readability.NestedFunctionCalls, []}, 182 | {Credo.Check.Readability.OneArityFunctionInPipe, []}, 183 | {Credo.Check.Readability.OnePipePerLine, []}, 184 | {Credo.Check.Readability.SeparateAliasRequire, []}, 185 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 186 | {Credo.Check.Readability.SinglePipe, []}, 187 | {Credo.Check.Readability.Specs, []}, 188 | {Credo.Check.Readability.StrictModuleLayout, []}, 189 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 190 | {Credo.Check.Refactor.ABCSize, []}, 191 | {Credo.Check.Refactor.AppendSingleItem, []}, 192 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 193 | {Credo.Check.Refactor.FilterReject, []}, 194 | {Credo.Check.Refactor.IoPuts, []}, 195 | {Credo.Check.Refactor.MapMap, []}, 196 | {Credo.Check.Refactor.ModuleDependencies, []}, 197 | {Credo.Check.Refactor.NegatedIsNil, []}, 198 | {Credo.Check.Refactor.PassAsyncInTestCases, []}, 199 | {Credo.Check.Refactor.PipeChainStart, []}, 200 | {Credo.Check.Refactor.RejectFilter, []}, 201 | {Credo.Check.Refactor.VariableRebinding, []}, 202 | {Credo.Check.Warning.LazyLogging, []}, 203 | {Credo.Check.Warning.LeakyEnvironment, []}, 204 | {Credo.Check.Warning.MapGetUnsafePass, []}, 205 | {Credo.Check.Warning.MixEnv, []}, 206 | {Credo.Check.Warning.UnsafeToAtom, []} 207 | 208 | # {Credo.Check.Refactor.MapInto, []}, 209 | 210 | # 211 | # Custom checks can be created using `mix credo.gen.check`. 212 | # 213 | ] 214 | } 215 | } 216 | ] 217 | } 218 | --------------------------------------------------------------------------------