├── .coveralls.yml ├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── examples └── echo_consumer.ex ├── lib ├── clients │ ├── adapter.ex │ ├── fake_rabbitmq.ex │ └── rabbitmq.ex ├── consumer.ex ├── ex_rabbit_pool.ex ├── pool_supervisor.ex └── worker │ ├── rabbit_connection.ex │ └── setup_queue.ex ├── mix.exs ├── mix.lock └── test ├── integration ├── api_test.exs ├── consumer_test.exs ├── rabbit_connection_test.exs └── setup_queue_test.exs ├── test_helper.exs └── worker └── rabbit_connection_test.exs /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | repo_token: GphQgtlmwH0Dy1VRB15h1acSL5DpTvuKB 3 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | /log 6 | /.fetch 7 | erl_crash.dump 8 | *.ez 9 | *.beam 10 | /config/*.secret.exs 11 | *~ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: elixir 3 | install: true 4 | sudo: true 5 | before_install: 6 | - docker run --detach --rm --hostname bugs-bunny --name roger_rabbit -p 5672 rabbitmq:3.7.7 7 | - export EX_RABBIT_POOL_PORT=$(docker inspect --format '{{ (index (index .NetworkSettings.Ports "5672/tcp") 0).HostPort }}' roger_rabbit ) 8 | - until curl --silent -XGET --fail http://127.0.0.1:${EX_RABBIT_POOL_PORT} &> /dev/null ; do printf '.'; sleep 1; done 9 | elixir: 10 | - '1.8' 11 | before_script: 12 | - "mix local.hex --force" 13 | - "mix local.rebar --force" 14 | - "mix deps.get" 15 | - "mix compile" 16 | after_success: 17 | - MIX_ENV=test mix coveralls.travis 18 | script: 19 | - "mix test" 20 | otp_release: 21 | - '21.0.9' 22 | cache: 23 | directories: 24 | - _build 25 | - deps 26 | services: 27 | - docker 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `ex_rabbit_pool` 2 | 3 | A RabbitMQ connection pooling library written in Elixir 4 | 5 | ## Is this lib still maintained? 6 | 7 | Yes!, but this is not yet another amqp framework, the whole purpose of this lib is to implement just the pooling side of rabbitmq connections which is now **feature complete**, we still need to finish some TODOs and improvements inside the lib but we are not planning to add any new features to it, but if you think there is something left in this regards please don't hesitate to create a ticket so we can discuss it. 8 | 9 | ## Installation 10 | 11 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 12 | by adding `ex_rabbit_pool` to your list of dependencies in `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:ex_rabbit_pool, "~> 1.0.3"} 18 | ] 19 | end 20 | ``` 21 | 22 | - [![Coverage Status](https://coveralls.io/repos/github/esl/ex_rabbit_pool/badge.svg?branch=master)](https://coveralls.io/github/esl/ex_rabbit_pool?branch=master) 23 | 24 | - [![Build Status](https://travis-ci.com/esl/ex_rabbit_pool.svg?branch=master)](https://travis-ci.com/esl/ex_rabbit_pool) 25 | 26 | - [HexDocs](https://hexdocs.pm/ex_rabbit_pool) 27 | 28 | - [Hex.pm](https://hex.pm/packages/ex_rabbit_pool) 29 | 30 | ## General Overview 31 | 32 | - `ex_rabbit_pool` creates a pool or many pools of connections to RabbitMQ, we don't care about isolating access to each 33 | worker that's why we use a pool purely in order to spread load (pool config strategy :fifo) 34 | 35 | - each connection worker traps exits and links the connection process to it 36 | 37 | - each connection worker creates a pool of channels and links them to it 38 | 39 | - when a client checks out a channel out of the pool the connection worker monitors that client to return the channel into it in case of a crash 40 | 41 | - in case you don't want to pool channels, you can disable this feature 42 | by setting the `channels` number to 0, then you can create channels on demand 43 | 44 | ## High Level Architecture 45 | 46 | When starting a connection worker : 47 | 48 | - We start a pool of multiplexed channels to RabbitMQ 49 | - Store the channel pool to the connection workers state (we can move this later to ets). 50 | 51 | Then: 52 | 53 | - The connection worker traps exists of RabbitMQ channels - which means that : 54 | - If a channel crashes, the connection worker is going to be able to start another channel 55 | - If a connection to RabbitMQ crashes we are going to be able to restart that connection, remove all crashed channels and then restart them with a new connection; 56 | 57 | Also: 58 | 59 | - We are able to easily: 60 | - Monitor clients accessing channels, 61 | - Queue and dequeue channels from the pool in order to make them accessible to one client at a time reducing the potential for race conditions. 62 | 63 | ## Setup RabbitMQ with docker 64 | 65 | ```bash 66 | # pull RabbitMQ image from docker 67 | $> docker pull rabbitmq:3.7.7-management 68 | # run docker in background 69 | # name the container 70 | # remove container if already exists 71 | # attach default port between the container and your laptop 72 | # attach default management port between the container and your laptop 73 | # start rabbitmq with management console 74 | $> docker run --detach --rm --hostname bugs-bunny --name roger_rabbit -p 5672:5672 -p 15672:15672 rabbitmq:3.7.7-management 75 | # if you need to stop the container 76 | $> docker stop roger_rabbit 77 | # if you need to remove the container manually 78 | $> docker container rm roger_rabbit 79 | ``` 80 | 81 | ## Supervision hierarchy 82 | 83 | ![supervisor diagram](https://user-images.githubusercontent.com/1157892/52127565-681b8400-2600-11e9-8c37-34287e4c9b2c.png) 84 | 85 | ## Setting Up Multiple Connection pools 86 | 87 | It’s a good practice to not have consumers and producers on the same connection 88 | (since if something goes to flow mode the connection will be blocked and 89 | consumers won’t be able to help RabbitMQ to offload all the messages), that's 90 | why we support setting up multiple queues thanks to poolboy 91 | 92 | ```elixir 93 | rabbitmq_config = [ 94 | channels: 1, 95 | ] 96 | 97 | # Connection Pool Configuration 98 | producers_conn_pool = [ 99 | name: {:local, :producers_pool}, 100 | worker_module: ExRabbitPool.Worker.RabbitConnection, 101 | size: 1, 102 | max_overflow: 0 103 | ] 104 | 105 | consumers_conn_pool = [ 106 | name: {:local, :consumers_pool}, 107 | worker_module: ExRabbitPool.Worker.RabbitConnection, 108 | size: 1, 109 | max_overflow: 0 110 | ] 111 | 112 | ExRabbitPool.PoolSupervisor.start_link( 113 | rabbitmq_config: rabbitmq_config, 114 | connection_pools: [producers_conn_pool, consumers_conn_pool] 115 | ) 116 | 117 | producers_conn = ExRabbitPool.get_connection(:producers_pool) 118 | consumers_conn = ExRabbitPool.get_connection(:consumers_pool) 119 | 120 | ExRabbitPool.with_channel(:producers_pool, fn {:ok, channel} -> 121 | ... 122 | end) 123 | 124 | ExRabbitPool.with_channel(:consumers_pool, fn {:ok, channel} -> 125 | ... 126 | end) 127 | ``` 128 | 129 | ## Setting Up Queues on Start Up 130 | 131 | We support setting up queues when starting up the supervision tree via 132 | `ExRabbitPool.Worker.SetupQueue`, right now it doesn't handle reconnect logic 133 | for you, so if you have a reconnection and you are working with `auto_delete: true` 134 | queues, you need to handle this case by your self (re-create those queues because 135 | if connectivity drops, `auto_delete: true` queues are going to be de deleted 136 | automatically and if you try to use one of them you would have an error as the 137 | queue no longer exist). 138 | 139 | Images are taken from [RabbitMQ Tutorials](https://www.rabbitmq.com/tutorials/tutorial-four-python.html) 140 | 141 | ## Setting up a direct exchange with bindings 142 | 143 | ![Direct Exchange Multiple](https://www.rabbitmq.com/img/tutorials/direct-exchange.png) 144 | 145 | ```elixir 146 | rabbitmq_config = [ 147 | ..., # Basic Rabbit Connection Configuration 148 | ] 149 | 150 | queues_config = [ 151 | queues: [ 152 | [ 153 | queue_name: "Q1", 154 | exchange: "X", 155 | queue_options: [], 156 | exchange_options: [], 157 | bind_options: [routing_key: "orange"] 158 | ], 159 | [ 160 | queue_name: "Q2", 161 | exchange: "X", 162 | queue_options: [], 163 | exchange_options: [], 164 | bind_options: [routing_key: "black"] 165 | ], 166 | [ 167 | queue_name: "Q2", 168 | exchange: "X", 169 | queue_options: [], 170 | exchange_options: [], 171 | bind_options: [routing_key: "green"] 172 | ] 173 | ] 174 | ] 175 | 176 | # Basic Connection Pool Configuration 177 | rabbitmq_conn_pool = [...] 178 | 179 | ExRabbitPool.PoolSupervisor.start_link( 180 | rabbitmq_config: rabbitmq_config, 181 | connection_pools: [rabbitmq_conn_pool] 182 | ) 183 | 184 | ExRabbitPool.Worker.SetupQueue.start_link({pool_id, queues_config}) 185 | ``` 186 | 187 | ## Setting up a direct exchange with multiple bindings 188 | 189 | ![Direct Exchange Multiple](https://www.rabbitmq.com/img/tutorials/direct-exchange-multiple.png) 190 | 191 | ```elixir 192 | rabbitmq_config = [ 193 | ..., # Basic Rabbit Connection Configuration 194 | ] 195 | 196 | queues_config = [ 197 | queues: [ 198 | [ 199 | queue_name: "Q1", 200 | exchange: "X", 201 | queue_options: [], 202 | exchange_options: [], 203 | bind_options: [routing_key: "black"] 204 | ], 205 | [ 206 | queue_name: "Q2", 207 | exchange: "X", 208 | queue_options: [], 209 | exchange_options: [], 210 | bind_options: [routing_key: "black"] 211 | ] 212 | ] 213 | ] 214 | 215 | # Basic Connection Pool Configuration 216 | rabbitmq_conn_pool = [...] 217 | 218 | ExRabbitPool.PoolSupervisor.start_link( 219 | rabbitmq_config: rabbitmq_config, 220 | connection_pools: [rabbitmq_conn_pool] 221 | ) 222 | 223 | ExRabbitPool.Worker.SetupQueue.start_link({pool_id, queues_config}) 224 | ``` 225 | 226 | ## EchoConsumer - Example 227 | 228 | In the `examples` directory you are going to find an implementation of a RabbitMQ 229 | consumer using the library, all you need to do is, starting RabbitMQ 230 | [with docker](#setup-rabbitmq-with-docker), and copy/paste the following code into the `iex` console. 231 | What it does is, setup the connection pool, setup the queues, exchanges and 232 | bindings to use, start the consumer and finally publish some messages to the 233 | exchange so the consumer can echo it. 234 | 235 | ```elixir 236 | rabbitmq_config = [channels: 2] 237 | 238 | rabbitmq_conn_pool = [ 239 | name: {:local, :connection_pool}, 240 | worker_module: ExRabbitPool.Worker.RabbitConnection, 241 | size: 1, 242 | max_overflow: 0 243 | ] 244 | 245 | {:ok, pid} = 246 | ExRabbitPool.PoolSupervisor.start_link( 247 | rabbitmq_config: rabbitmq_config, 248 | connection_pools: [rabbitmq_conn_pool] 249 | ) 250 | 251 | queue = "ex_rabbit_pool" 252 | exchange = "my_exchange" 253 | routing_key = "example" 254 | 255 | ExRabbitPool.with_channel(:connection_pool, fn {:ok, channel} -> 256 | {:ok, _} = AMQP.Queue.declare(channel, queue, auto_delete: true, exclusive: true) 257 | :ok = AMQP.Exchange.declare(channel, exchange, :direct, auto_delete: true, exclusive: true) 258 | :ok = AMQP.Queue.bind(channel, queue, exchange, routing_key: routing_key) 259 | end) 260 | 261 | {:ok, consumer_pid} = Example.EchoConsumer.start_link(pool_id: :connection_pool, queue: queue) 262 | 263 | ExRabbitPool.with_channel(:connection_pool, fn {:ok, channel} -> 264 | :ok = AMQP.Basic.publish(channel, exchange, routing_key, "Hello World!") 265 | :ok = AMQP.Basic.publish(channel, exchange, routing_key, "Hell Yeah!") 266 | end) 267 | ``` 268 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, 4 | handle_otp_reports: true, 5 | handle_sasl_reports: true, 6 | level: :warn 7 | 8 | config :sasl, :sasl_error_logger, false 9 | # https://github.com/pma/amqp/issues/90 10 | # https://github.com/PSPDFKit-labs/lager_logger/blob/master/config/config.exs 11 | # Stop lager redirecting :error_logger messages 12 | config :lager, :error_logger_redirect, false 13 | 14 | # Stop lager removing Logger's :error_logger handler 15 | config :lager, :error_logger_whitelist, [Logger.ErrorHandler] 16 | 17 | # Stop lager writing a crash log 18 | config :lager, :crash_log, false 19 | 20 | # Use LagerLogger as lager's only handler. 21 | config :lager, :handlers, [{LagerLogger, [level: :debug]}] 22 | 23 | # https://github.com/pma/amqp/wiki/Upgrade-from-0.X-to-1.0#lager 24 | config :lager, handlers: [level: :critical] 25 | -------------------------------------------------------------------------------- /examples/echo_consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule Example.EchoConsumer do 2 | use ExRabbitPool.Consumer 3 | 4 | require Logger 5 | 6 | ################################ 7 | # AMQP Basic.Consume Callbacks # 8 | ################################ 9 | 10 | # Confirmation sent by the broker after registering this process as a consumer 11 | def basic_consume_ok(_state, _consumer_tag) do 12 | Logger.info("[consumer] successfully registered as a consumer (basic_consume_ok)") 13 | :ok 14 | end 15 | 16 | # This is sent for each message consumed, where `payload` contains the message 17 | # content and `meta` contains all the metadata set when sending with 18 | # Basic.publish or additional info set by the broker; 19 | def basic_deliver(%{adapter: adapter, channel: channel}, payload, %{delivery_tag: delivery_tag}) do 20 | Logger.info("[consumer] consuming payload (#{inspect payload})") 21 | :ok = adapter.ack(channel, delivery_tag, requeue: false) 22 | :ok 23 | end 24 | 25 | # Sent by the broker when the consumer is unexpectedly cancelled (such as after a queue deletion) 26 | def basic_cancel(_state, _consumer_tag, _no_wait) do 27 | Logger.error("[consumer] consumer was cancelled by the broker (basic_cancel)") 28 | :ok 29 | end 30 | 31 | # Confirmation sent by the broker to the consumer process after a Basic.cancel 32 | def basic_cancel_ok(_state, _consumer_tag) do 33 | Logger.error("[consumer] consumer was cancelled by the broker (basic_cancel_ok)") 34 | :ok 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/clients/adapter.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Clients.Adapter do 2 | @callback open_connection(keyword() | String.t()) :: {:ok, AMQP.Connection.t()} | {:error, any} 3 | @callback open_channel(AMQP.Connection.t()) :: {:ok, AMQP.Channel.t()} | {:error, any()} | any() 4 | @callback close_channel(AMQP.Channel.t() | pid()) :: :ok | {:error, AMQP.Basic.error()} 5 | @callback close_connection(AMQP.Connection.t()) :: :ok | {:error, any} 6 | @callback publish(AMQP.Channel.t(), String.t(), String.t(), String.t(), keyword) :: 7 | :ok | AMQP.Basic.error() 8 | @callback consume(AMQP.Channel.t(), String.t(), pid() | nil, keyword) :: 9 | {:ok, String.t()} | AMQP.Basic.error() 10 | @callback cancel_consume(AMQP.Channel.t(), String.t(), keyword) :: 11 | {:ok, String.t()} | {:error, AMQP.Basic.error()} 12 | @callback ack(AMQP.Channel.t(), String.t(), keyword()) :: :ok | AMQP.Basic.error() 13 | @callback reject(AMQP.Channel.t(), String.t(), keyword()) :: :ok | AMQP.Basic.error() 14 | @callback declare_queue(AMQP.Channel.t(), AMQP.Basic.queue(), keyword()) :: 15 | {:ok, map()} | AMQP.Basic.error() 16 | @callback queue_bind(AMQP.Channel.t(), AMQP.Basic.queue(), AMQP.Basic.exchange(), keyword()) :: 17 | :ok | AMQP.Basic.error() 18 | @callback declare_exchange(AMQP.Channel.t(), AMQP.Basic.exchange(), keyword()) :: 19 | :ok | AMQP.Basic.error() 20 | @callback qos(AMQP.Channel.t(), keyword()) :: 21 | :ok | AMQP.Basic.error() 22 | end 23 | -------------------------------------------------------------------------------- /lib/clients/fake_rabbitmq.ex: -------------------------------------------------------------------------------- 1 | # TODOL: change this fake adapter to not depend on RabbitMQ 2 | # based on this: http://tech.adroll.com/blog/dev/2018/03/28/elixir-stubs-for-tests.html 3 | defmodule ExRabbitPool.FakeRabbitMQ do 4 | @behaviour ExRabbitPool.Clients.Adapter 5 | use AMQP 6 | 7 | @impl true 8 | def publish(_channel, _exchange, _routing_key, payload, _options \\ []) do 9 | if String.contains?(payload, "\"owner\":\"error\"") do 10 | {:error, :kaboom} 11 | else 12 | :ok 13 | end 14 | end 15 | 16 | @impl true 17 | def consume(_channel, _queue, _consumer_pid \\ nil, _options \\ []) do 18 | {:ok, "tag"} 19 | end 20 | 21 | @impl true 22 | def cancel_consume(_channel, consumer_tag, _options \\ []) do 23 | {:ok, consumer_tag} 24 | end 25 | 26 | @impl true 27 | def ack(_channel, _tag, _options \\ []) do 28 | :ok 29 | end 30 | 31 | @impl true 32 | def reject(_channel, _tag, _options \\ []) do 33 | :ok 34 | end 35 | 36 | @impl true 37 | def open_connection(config) do 38 | if Keyword.get(config, :queue) == "error.queue" do 39 | {:error, :invalid} 40 | else 41 | {:ok, %Connection{pid: self()}} 42 | end 43 | end 44 | 45 | @impl true 46 | def open_channel(conn) do 47 | {:ok, %Channel{conn: conn, pid: self()}} 48 | end 49 | 50 | @impl true 51 | def close_channel(_channel) do 52 | :ok 53 | end 54 | 55 | @impl true 56 | def close_connection(_conn) do 57 | :ok 58 | end 59 | 60 | @impl true 61 | def declare_queue(_channel, _queue, _options \\ []) do 62 | {:ok, %{}} 63 | end 64 | 65 | @impl true 66 | def declare_exchange(_channel, _exchange, _type \\ :direct, _options \\ []) do 67 | :ok 68 | end 69 | 70 | @impl true 71 | def queue_bind(_channel, _queue, _exchange, _options \\ []) do 72 | :ok 73 | end 74 | 75 | @impl true 76 | def qos(_channel, _options \\ []) do 77 | :ok 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/clients/rabbitmq.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.RabbitMQ do 2 | require Logger 3 | 4 | @behaviour ExRabbitPool.Clients.Adapter 5 | 6 | use AMQP 7 | 8 | @impl true 9 | def publish(channel, exchange, routing_key, payload, options \\ []) do 10 | Basic.publish(channel, exchange, routing_key, payload, options) 11 | end 12 | 13 | @impl true 14 | def consume(%Channel{} = channel, queue, consumer_pid \\ nil, options \\ []) do 15 | Basic.consume(channel, queue, consumer_pid, options) 16 | end 17 | 18 | @impl true 19 | def cancel_consume(%Channel{} = channel, consumer_tag, options \\ []) do 20 | Basic.cancel(channel, consumer_tag, options) 21 | end 22 | 23 | @impl true 24 | def ack(%Channel{} = channel, tag, options \\ []) do 25 | Basic.ack(channel, tag, options) 26 | end 27 | 28 | @impl true 29 | def reject(%Channel{} = channel, tag, options \\ []) do 30 | Basic.reject(channel, tag, options) 31 | end 32 | 33 | @impl true 34 | def open_connection(config) do 35 | Connection.open(config) 36 | end 37 | 38 | @impl true 39 | def open_channel(conn) do 40 | Channel.open(conn) 41 | end 42 | 43 | @impl true 44 | def close_channel(pid) when is_pid(pid) do 45 | case :amqp_channel.close(pid) do 46 | :ok -> :ok 47 | error -> {:error, error} 48 | end 49 | end 50 | 51 | @impl true 52 | def close_channel(channel) do 53 | Channel.close(channel) 54 | end 55 | 56 | @impl true 57 | def close_connection(conn) do 58 | Connection.close(conn) 59 | end 60 | 61 | @impl true 62 | def declare_queue(channel, queue \\ "", options \\ []) do 63 | case Queue.declare(channel, queue, options) do 64 | {:ok, res} -> 65 | Logger.debug("queue: #{queue} successfully declared") 66 | {:ok, res} 67 | 68 | {:error, reason} -> 69 | {:error, reason} 70 | end 71 | end 72 | 73 | @impl true 74 | def declare_exchange(channel, exchange, options \\ []) 75 | 76 | def declare_exchange(_channel, "", _options), do: :ok 77 | 78 | def declare_exchange(channel, exchange, options) do 79 | type = Keyword.get(options, :type, :direct) 80 | 81 | case Exchange.declare(channel, exchange, type, options) do 82 | :ok -> 83 | Logger.debug("exchange #{exchange} successfully declared") 84 | :ok 85 | 86 | {:error, error} -> 87 | {:error, error} 88 | end 89 | end 90 | 91 | @impl true 92 | def queue_bind(channel, queue, exchange, options \\ []) 93 | 94 | def queue_bind(_channel, _queue, "", _options), do: :ok 95 | 96 | def queue_bind(channel, queue, exchange, options) do 97 | case Queue.bind(channel, queue, exchange, options) do 98 | :ok -> 99 | Logger.debug("#{queue} successfully bound to #{exchange}") 100 | :ok 101 | 102 | {:error, error} -> 103 | {:error, error} 104 | end 105 | end 106 | 107 | @impl true 108 | def qos(channel, options \\ []) do 109 | Basic.qos(channel, options) 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /lib/consumer.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Consumer do 2 | defmodule State do 3 | @moduledoc """ 4 | RabbitMQ Consumer Worker State. 5 | 6 | State attributes: 7 | 8 | * `:pool_id` - the name of the connection pool to RabbitMQ 9 | * `:channel` - the RabbitMQ channel for consuming new messages 10 | * `:monitor` - a monitor for handling channel crashes 11 | * `:queue` - the name of the queue to consume 12 | * `:consumer_tag` - the consumer tag assigned by RabbitMQ 13 | * `:config` - the consumer configuration attributes 14 | * `:adapter` - the RabbitMQ client to use 15 | """ 16 | @enforce_keys [:pool_id, :queue] 17 | 18 | @typedoc "Consumer State Type" 19 | @type t :: %__MODULE__{ 20 | pool_id: atom(), 21 | channel: AMQP.Channel.t(), 22 | monitor: reference(), 23 | queue: AMQP.Basic.queue(), 24 | consumer_tag: AMQP.Basic.consumer_tag(), 25 | config: keyword(), 26 | adapter: module() 27 | } 28 | defstruct pool_id: nil, 29 | caller: nil, 30 | channel: nil, 31 | monitor: nil, 32 | queue: nil, 33 | consumer_tag: nil, 34 | config: [], 35 | adapter: nil 36 | end 37 | 38 | @type meta :: map() 39 | @type no_wait :: boolean() 40 | @type reason :: any() 41 | 42 | @callback basic_consume_ok(State.t(), AMQP.Basic.consumer_tag()) :: :ok | {:stop, reason} 43 | @callback basic_deliver(State.t(), AMQP.Basic.payload(), meta()) :: :ok | {:stop, reason} 44 | @callback basic_cancel(State.t(), AMQP.Basic.consumer_tag(), no_wait()) :: :ok | {:stop, reason} 45 | @callback basic_cancel_ok(State.t(), AMQP.Basic.consumer_tag()) :: :ok | {:stop, reason} 46 | 47 | defmacro __using__(_opts) do 48 | quote do 49 | use GenServer 50 | 51 | def start_link(config) do 52 | GenServer.start_link(__MODULE__, config) 53 | end 54 | 55 | #################### 56 | # Server Callbacks # 57 | #################### 58 | 59 | @impl true 60 | def init(config) do 61 | {opts, consumer_config} = Keyword.split(config, [:adapter, :pool_id, :queue]) 62 | adapter = Keyword.get(opts, :adapter, ExRabbitPool.RabbitMQ) 63 | pool_id = Keyword.fetch!(opts, :pool_id) 64 | queue = Keyword.fetch!(opts, :queue) 65 | 66 | state = %State{ 67 | pool_id: pool_id, 68 | queue: queue, 69 | adapter: adapter, 70 | config: consumer_config 71 | } 72 | 73 | {:ok, state, {:continue, :connect}} 74 | end 75 | 76 | @impl true 77 | def handle_continue(:connect, state), do: do_connect(state) 78 | 79 | @impl true 80 | def handle_info(:connect, state), do: do_connect(state) 81 | 82 | # Gets a connection worker out of the connection pool, if there is one available 83 | # takes a channel out of it channel pool, if there is one available subscribe 84 | # itself as a consumer process. 85 | def do_connect(state) do 86 | %{pool_id: pool_id} = state 87 | 88 | pool_id 89 | |> ExRabbitPool.get_connection_worker() 90 | |> ExRabbitPool.checkout_channel() 91 | |> handle_channel_checkout(state) 92 | end 93 | 94 | @impl true 95 | def handle_info( 96 | {:DOWN, monitor, :process, chan_pid, reason}, 97 | %{monitor: monitor, channel: %{pid: chan_pid}, config: config} = state 98 | ) do 99 | schedule_connect(config) 100 | {:noreply, %State{state | monitor: nil, consumer_tag: nil, channel: nil}} 101 | end 102 | 103 | ################################ 104 | # AMQP Basic.Consume Callbacks # 105 | ################################ 106 | 107 | # Confirmation sent by the broker after registering this process as a consumer 108 | @impl true 109 | def handle_info( 110 | {:basic_consume_ok, %{consumer_tag: consumer_tag}}, 111 | %{consumer_tag: consumer_tag} = state 112 | ) do 113 | case basic_consume_ok(state, consumer_tag) do 114 | :ok -> 115 | {:noreply, state} 116 | 117 | {:stop, reason} -> 118 | {:stop, reason, state} 119 | 120 | _ -> 121 | {:noreply, state} 122 | end 123 | end 124 | 125 | # This is sent for each message consumed, where `payload` contains the message 126 | # content and `meta` contains all the metadata set when sending with 127 | # Basic.publish or additional info set by the broker; 128 | @impl true 129 | def handle_info({:basic_deliver, payload, meta}, state) do 130 | case basic_deliver(state, payload, meta) do 131 | :ok -> 132 | {:noreply, state} 133 | 134 | {:stop, reason} -> 135 | {:stop, reason, state} 136 | 137 | _ -> 138 | {:noreply, state} 139 | end 140 | end 141 | 142 | # Sent by the broker when the consumer is unexpectedly cancelled (such as after a queue deletion) 143 | @impl true 144 | def handle_info( 145 | {:basic_cancel, %{consumer_tag: consumer_tag, no_wait: no_wait}}, 146 | %{consumer_tag: consumer_tag} = state 147 | ) do 148 | case basic_cancel(state, consumer_tag, no_wait) do 149 | :ok -> 150 | {:stop, :shutdown, state} 151 | 152 | {:stop, reason} -> 153 | {:stop, reason, state} 154 | end 155 | end 156 | 157 | # Confirmation sent by the broker to the consumer process after a Basic.cancel 158 | @impl true 159 | def handle_info( 160 | {:basic_cancel_ok, %{consumer_tag: consumer_tag}}, 161 | %{consumer_tag: consumer_tag} = state 162 | ) do 163 | case basic_cancel_ok(state, consumer_tag) do 164 | :ok -> 165 | {:stop, :normal, state} 166 | 167 | {:stop, reason} -> 168 | {:stop, reason, state} 169 | end 170 | end 171 | 172 | # When successfully checks out a channel, subscribe itself as a consumer 173 | # process and monitors it handle crashes and reconnections 174 | defp handle_channel_checkout( 175 | {:ok, %{pid: channel_pid} = channel}, 176 | %{config: config, queue: queue, adapter: adapter} = state 177 | ) do 178 | with :ok <- setup_channel(state, channel), 179 | {:ok, consumer_tag} <- adapter.consume(channel, queue, self(), config) do 180 | ref = Process.monitor(channel_pid) 181 | {:noreply, %State{state | channel: channel, monitor: ref, consumer_tag: consumer_tag}} 182 | else 183 | {:error, reason} -> 184 | schedule_connect(config) 185 | {:noreply, %State{state | channel: nil, consumer_tag: nil}} 186 | end 187 | end 188 | 189 | # When there was an error checking out a channel, retry in a configured interval 190 | defp handle_channel_checkout({:error, reason}, %{config: config} = state) do 191 | schedule_connect(config) 192 | {:noreply, state} 193 | end 194 | 195 | defp schedule_connect(config) do 196 | reconnect_interval = Keyword.get(config, :reconnect_interval, 1_000) 197 | Process.send_after(self(), :connect, reconnect_interval) 198 | end 199 | 200 | def basic_deliver(%{adapter: adapter, channel: channel}, payload, %{delivery_tag: tag}) do 201 | :ok = adapter.ack(channel, tag) 202 | end 203 | 204 | def setup_channel(_state, _channel), do: :ok 205 | def basic_consume_ok(_state, _consumer_tag), do: :ok 206 | def basic_cancel(_state, _consumer_tag, _no_wait), do: :ok 207 | def basic_cancel_ok(_state, _consumer_tag), do: :ok 208 | 209 | defoverridable setup_channel: 2 210 | defoverridable basic_deliver: 3 211 | defoverridable basic_consume_ok: 2 212 | defoverridable basic_cancel: 3 213 | defoverridable basic_cancel_ok: 2 214 | end 215 | end 216 | end 217 | -------------------------------------------------------------------------------- /lib/ex_rabbit_pool.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool do 2 | alias ExRabbitPool.Worker.RabbitConnection, as: Conn 3 | 4 | @type f :: ({:ok, AMQP.Channel.t()} | {:error, :disconnected | :out_of_channels} -> any()) 5 | 6 | @doc """ 7 | Gets a connection from a connection worker so any client can exec commands 8 | manually 9 | """ 10 | @spec get_connection(atom()) :: {:ok, AMQP.Connection.t()} | {:error, :disconnected} 11 | def get_connection(pool_id) do 12 | :poolboy.transaction(pool_id, &Conn.get_connection/1) 13 | end 14 | 15 | @doc """ 16 | Retrieve a connection worker - we don't care about isolating access to each 17 | worker - we use a pool purely in order to spread load (pool config strategy :fifo) 18 | """ 19 | @spec get_connection_worker(atom()) :: pid() 20 | def get_connection_worker(pool_id) do 21 | conn_worker = :poolboy.checkout(pool_id) 22 | :ok = :poolboy.checkin(pool_id, conn_worker) 23 | conn_worker 24 | end 25 | 26 | @doc """ 27 | Executes function f in the context of a channel, takes a connection worker 28 | out of the pool, put that connection worker back into the pool so any 29 | other concurrent client can have access to it, checks out a channel out of 30 | the worker's channel pool, executes the function with the result of the 31 | checkout and finally puts the channel back into the worker's pool. 32 | """ 33 | @spec with_channel(atom(), f()) :: any() 34 | def with_channel(pool_id, fun) do 35 | pool_id 36 | |> get_connection_worker() 37 | |> do_with_conn(fun) 38 | end 39 | 40 | @doc """ 41 | Gets a RabbitMQ channel out of a connection worker 42 | """ 43 | @spec checkout_channel(pid()) :: 44 | {:ok, AMQP.Channel.t()} | {:error, :disconnected | :out_of_channels} 45 | def checkout_channel(conn_worker) do 46 | Conn.checkout_channel(conn_worker) 47 | end 48 | 49 | @doc """ 50 | Puts back a RabbitMQ channel into its corresponding connection worker 51 | """ 52 | @spec checkin_channel(pid(), AMQP.Channel.t()) :: :ok 53 | def checkin_channel(conn_worker, channel) do 54 | Conn.checkin_channel(conn_worker, channel) 55 | end 56 | 57 | # Gets a channel out of a connection worker and performs a function with it 58 | # then it puts it back to the same connection worker, mimicking a transaction. 59 | @spec do_with_conn(pid(), f()) :: any() 60 | defp do_with_conn(conn_worker, fun) do 61 | case checkout_channel(conn_worker) do 62 | {:ok, channel} = ok_chan -> 63 | try do 64 | fun.(ok_chan) 65 | after 66 | :ok = checkin_channel(conn_worker, channel) 67 | end 68 | 69 | {:error, _} = error -> 70 | fun.(error) 71 | end 72 | end 73 | 74 | @spec create_queue_with_bind( 75 | module(), 76 | atom(), 77 | AMQP.Basic.queue(), 78 | AMQP.Basic.exchange(), 79 | keyword() 80 | ) :: :ok | AMQP.Basic.error() | {:error, any()} 81 | def create_queue_with_bind(adapter, pool_id, queue, exchange, options \\ []) do 82 | queue_options = Keyword.get(options, :queue_options, []) 83 | exchange_options = Keyword.get(options, :exchange_options, []) 84 | bind_options = Keyword.get(options, :bind_options, []) 85 | conn_worker = get_connection_worker(pool_id) 86 | 87 | do_with_conn(conn_worker, fn 88 | {:ok, channel} -> 89 | with {:ok, _} <- adapter.declare_queue(channel, queue, queue_options), 90 | :ok <- adapter.declare_exchange(channel, exchange, exchange_options), 91 | :ok <- adapter.queue_bind(channel, queue, exchange, bind_options) do 92 | :ok 93 | else 94 | {:error, _} = error -> error 95 | end 96 | 97 | {:error, _} = error -> 98 | error 99 | end) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/pool_supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.PoolSupervisor do 2 | use Supervisor 3 | 4 | @type config :: [rabbitmq_config: keyword(), connection_pools: list()] 5 | 6 | @spec start_link(config()) :: Supervisor.on_start() 7 | def start_link(config) do 8 | Supervisor.start_link(__MODULE__, config) 9 | end 10 | 11 | @spec start_link(config(), atom()) :: Supervisor.on_start() 12 | def start_link(config, name) do 13 | Supervisor.start_link(__MODULE__, config, name: name) 14 | end 15 | 16 | @impl true 17 | def init(config) do 18 | children = 19 | for pool_config <- Keyword.get(config, :connection_pools, []) do 20 | rabbitmq_config = Keyword.get(config, :rabbitmq_config, []) 21 | {_, pool_id} = Keyword.fetch!(pool_config, :name) 22 | # We are using poolboy's pool as a fifo queue so we can distribute the 23 | # load between workers 24 | pool_config = Keyword.merge(pool_config, strategy: :fifo) 25 | :poolboy.child_spec(pool_id, pool_config, rabbitmq_config) 26 | end 27 | 28 | opts = [strategy: :one_for_one] 29 | Supervisor.init(children, opts) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/worker/rabbit_connection.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Worker.RabbitConnection do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | @reconnect_interval 1_000 7 | @default_channels 10 8 | 9 | defmodule State do 10 | @type config :: keyword() | String.t() 11 | 12 | @enforce_keys [:config] 13 | @type t :: %__MODULE__{ 14 | adapter: module(), 15 | connection: AMQP.Connection.t(), 16 | channels: list(AMQP.Channel.t()), 17 | monitors: %{}, 18 | config: config() 19 | } 20 | 21 | defstruct adapter: ExRabbitPool.RabbitMQ, 22 | connection: nil, 23 | channels: [], 24 | config: nil, 25 | monitors: %{} 26 | end 27 | 28 | ############## 29 | # Client API # 30 | ############## 31 | 32 | @spec start_link(State.config()) :: GenServer.on_start() 33 | def start_link(config) do 34 | GenServer.start_link(__MODULE__, config, []) 35 | end 36 | 37 | @spec get_connection(pid()) :: {:ok, AMQP.Connection.t()} | {:error, :disconnected} 38 | def get_connection(pid) do 39 | GenServer.call(pid, :conn) 40 | end 41 | 42 | @spec checkout_channel(pid()) :: 43 | {:ok, AMQP.Channel.t()} 44 | | {:error, :disconnected} 45 | | {:error, :out_of_channels} 46 | def checkout_channel(pid) do 47 | GenServer.call(pid, :checkout_channel) 48 | end 49 | 50 | @spec checkin_channel(pid(), AMQP.Channel.t()) :: :ok 51 | def checkin_channel(pid, channel) do 52 | GenServer.cast(pid, {:checkin_channel, channel}) 53 | end 54 | 55 | @spec create_channel(pid()) :: {:ok, AMQP.Channel.t()} | {:error, any()} 56 | def create_channel(pid) do 57 | GenServer.call(pid, :create_channel) 58 | end 59 | 60 | @doc false 61 | def state(pid) do 62 | GenServer.call(pid, :state) 63 | end 64 | 65 | #################### 66 | # Server Callbacks # 67 | #################### 68 | 69 | @doc """ 70 | Traps exits so all the linked connection and multiplexed channels can be 71 | restarted by this worker. 72 | Triggers an async connection but making sure future calls need to wait 73 | for the connection to happen before them. 74 | 75 | * `config` is the rabbitmq config settings 76 | """ 77 | @impl true 78 | def init(config) do 79 | Process.flag(:trap_exit, true) 80 | # split our opts from the ones passed to the amqp client 81 | {opts, amqp_config} = Keyword.split(config, [:adapter]) 82 | adapter = Keyword.get(opts, :adapter, ExRabbitPool.RabbitMQ) 83 | send(self(), :connect) 84 | {:ok, %State{adapter: adapter, config: amqp_config}} 85 | end 86 | 87 | @impl true 88 | def handle_call(:conn, _from, %State{connection: nil} = state) do 89 | {:reply, {:error, :disconnected}, state} 90 | end 91 | 92 | @impl true 93 | def handle_call(:conn, _from, %State{connection: connection} = state) do 94 | if Process.alive?(connection.pid) do 95 | {:reply, {:ok, connection}, state} 96 | else 97 | {:reply, {:ok, :disconnected}, state} 98 | end 99 | end 100 | 101 | # TODO: improve better pooling of channels 102 | # TODO: add overflow support 103 | # TODO: maybe make the checkout_channel call async/sync with GenServer.reply/2 104 | @impl true 105 | def handle_call(:checkout_channel, _from, %State{connection: nil} = state) do 106 | {:reply, {:error, :disconnected}, state} 107 | end 108 | 109 | @impl true 110 | def handle_call(:checkout_channel, _from, %{channels: []} = state) do 111 | {:reply, {:error, :out_of_channels}, state} 112 | end 113 | 114 | # Checkout a channel out of a channel pool and monitors the client requesting 115 | # it so we can handle client crashes returning the monitor back to the pool 116 | @impl true 117 | def handle_call( 118 | :checkout_channel, 119 | {from_pid, _ref}, 120 | %{channels: [channel | rest], monitors: monitors} = state 121 | ) do 122 | monitor_ref = Process.monitor(from_pid) 123 | new_monitors = Map.put_new(monitors, channel.pid, monitor_ref) 124 | {:reply, {:ok, channel}, %State{state | channels: rest, monitors: new_monitors}} 125 | end 126 | 127 | # Create a channel without linking the worker process to the channel pid, this 128 | # way clients can create channels on demand without the need of a pool, but 129 | # they are now in charge of handling channel crashes, connection closing, 130 | # channel closing, etc. 131 | @impl true 132 | def handle_call(:create_channel, _from, %{connection: conn, adapter: adapter} = state) do 133 | result = start_channel(adapter, conn) 134 | {:reply, result, state} 135 | end 136 | 137 | @impl true 138 | def handle_call(:state, _from, state) do 139 | {:reply, state, state} 140 | end 141 | 142 | # When checkin back a channel to the pool is a good practice to not re-use 143 | # channels, so, we need to remove it from the channel list, unlink it and 144 | # start a new one 145 | @impl true 146 | def handle_cast( 147 | {:checkin_channel, %{pid: pid}}, 148 | %{connection: conn, adapter: adapter, channels: channels, monitors: monitors} = state 149 | ) do 150 | # only start a new channel when checkin back a channel that isn't removed yet 151 | # this can happen when a channel crashed or is closed when a client holds it 152 | # so we get an `:EXIT` message and a `:checkin_channel` message in no given 153 | # order 154 | if find_channel(channels, pid, monitors) do 155 | new_channels = remove_channel(channels, pid) 156 | new_monitors = remove_monitor(monitors, pid) 157 | 158 | case replace_channel(pid, adapter, conn) do 159 | {:ok, channel} -> 160 | {:noreply, %State{state | channels: [channel | new_channels], monitors: new_monitors}} 161 | 162 | {:error, :closing} -> 163 | # RabbitMQ Connection is closed. nothing to do, wait for reconnection 164 | {:noreply, %State{state | channels: new_channels, monitors: new_monitors}} 165 | end 166 | else 167 | {:noreply, state} 168 | end 169 | end 170 | 171 | @impl true 172 | def handle_info(:connect, %{adapter: adapter, config: config} = state) do 173 | # TODO: add exponential backoff for reconnects 174 | # TODO: stop the worker when we couldn't reconnect several times 175 | case adapter.open_connection(config) do 176 | {:error, reason} -> 177 | Logger.error("[Rabbit] error opening a connection reason: #{inspect(reason)}") 178 | # TODO: use exponential backoff to reconnect 179 | # TODO: use circuit breaker to fail fast 180 | schedule_connect(config) 181 | {:noreply, state} 182 | 183 | {:ok, connection} -> 184 | Logger.debug("[Rabbit] connected") 185 | %{pid: pid} = connection 186 | # link itself to the connection `pid` to handle connection 187 | # errors and spawn as many channels as needed based on config 188 | # defaults to `1_000` 189 | true = Process.link(pid) 190 | 191 | num_channels = Keyword.get(config, :channels, @default_channels) 192 | 193 | channels = 194 | do_times(num_channels, 0, fn -> 195 | {:ok, channel} = start_channel(adapter, connection) 196 | true = Process.link(channel.pid) 197 | 198 | channel 199 | end) 200 | 201 | {:noreply, %State{state | connection: connection, channels: channels}} 202 | end 203 | end 204 | 205 | # Connection crashed/closed 206 | @impl true 207 | def handle_info({:EXIT, pid, reason}, %{connection: %{pid: pid}, config: config} = state) do 208 | Logger.error("[Rabbit] connection lost, attempting to reconnect reason: #{inspect(reason)}") 209 | # TODO: use exponential backoff to reconnect 210 | # TODO: use circuit breaker to fail fast 211 | schedule_connect(config) 212 | {:noreply, %State{state | connection: nil, channels: [], monitors: %{}}} 213 | end 214 | 215 | # Connection crashed so channels are going to crash too 216 | @impl true 217 | def handle_info( 218 | {:EXIT, pid, reason}, 219 | %{connection: nil, channels: channels, monitors: monitors} = state 220 | ) do 221 | Logger.error("[Rabbit] connection lost, removing channel reason: #{inspect(reason)}") 222 | new_channels = remove_channel(channels, pid) 223 | new_monitors = remove_monitor(monitors, pid) 224 | {:noreply, %State{state | channels: new_channels, monitors: new_monitors}} 225 | end 226 | 227 | # Channel crashed/closed, Connection crashed/closed 228 | @impl true 229 | def handle_info( 230 | {:EXIT, pid, reason}, 231 | %{channels: channels, connection: conn, adapter: adapter, monitors: monitors} = state 232 | ) do 233 | Logger.warn("[Rabbit] channel lost reason: #{inspect(reason)}") 234 | # don't start a new channel if crashed channel doesn't belongs to the pool 235 | # anymore, this can happen when a channel crashed or is closed when a client holds it 236 | # so we get an `:EXIT` message and a `:checkin_channel` message in no given 237 | # order 238 | if find_channel(channels, pid, monitors) do 239 | new_channels = remove_channel(channels, pid) 240 | new_monitors = remove_monitor(monitors, pid) 241 | 242 | case start_channel(adapter, conn) do 243 | {:ok, channel} -> 244 | true = Process.link(channel.pid) 245 | {:noreply, %State{state | channels: [channel | new_channels], monitors: new_monitors}} 246 | 247 | {:error, :closing} -> 248 | # RabbitMQ Connection is closed. nothing to do, wait for reconnections 249 | {:noreply, %State{state | channels: new_channels, monitors: new_monitors}} 250 | end 251 | else 252 | {:noreply, state} 253 | end 254 | end 255 | 256 | # if client holding a channel fails, then we need to take its channel back 257 | @impl true 258 | def handle_info( 259 | {:DOWN, down_ref, :process, _, _}, 260 | %{channels: channels, monitors: monitors, adapter: adapter, connection: conn} = state 261 | ) do 262 | find_monitor(monitors, down_ref) 263 | |> case do 264 | nil -> 265 | {:noreply, state} 266 | 267 | {pid, _ref} -> 268 | new_monitors = Map.delete(monitors, pid) 269 | 270 | case replace_channel(pid, adapter, conn) do 271 | {:ok, channel} -> 272 | {:noreply, %State{state | channels: [channel | channels], monitors: new_monitors}} 273 | 274 | {:error, :closing} -> 275 | # RabbitMQ Connection is closed. nothing to do, wait for reconnection 276 | {:noreply, %State{state | channels: channels, monitors: new_monitors}} 277 | end 278 | end 279 | end 280 | 281 | @impl true 282 | def terminate(_reason, %{connection: connection, adapter: adapter}) do 283 | if connection && Process.alive?(connection.pid) do 284 | adapter.close_connection(connection) 285 | end 286 | end 287 | 288 | ############# 289 | # Internals # 290 | ############# 291 | 292 | defp schedule_connect(config) do 293 | interval = get_reconnect_interval(config) 294 | Process.send_after(self(), :connect, interval) 295 | end 296 | 297 | # Opens a channel using the specified client, each channel is backed by a 298 | # GenServer process, so we need to link the worker to all those processes 299 | # to be able to restart them when closed or when they crash e.g by a 300 | # connection error 301 | # TODO: maybe start channels on demand as needed and store them in the state for re-use 302 | @spec start_channel(module(), AMQP.Connection.t()) :: {:ok, AMQP.Channel.t()} | {:error, any()} 303 | defp start_channel(client, connection) do 304 | if Process.alive?(connection.pid) do 305 | case client.open_channel(connection) do 306 | {:ok, _channel} = result -> 307 | Logger.debug("[Rabbit] channel connected") 308 | result 309 | 310 | {:error, reason} = error -> 311 | Logger.error("[Rabbit] error starting channel reason: #{inspect(reason)}") 312 | error 313 | 314 | error -> 315 | Logger.error("[Rabbit] error starting channel reason: #{inspect(error)}") 316 | {:error, error} 317 | end 318 | else 319 | {:error, :closing} 320 | end 321 | end 322 | 323 | defp get_reconnect_interval(config) do 324 | Keyword.get(config, :reconnect_interval, @reconnect_interval) 325 | end 326 | 327 | @spec do_times(non_neg_integer(), non_neg_integer(), (() -> any())) :: [any()] 328 | defp do_times(limit, counter, _function) when counter >= limit, do: [] 329 | 330 | defp do_times(limit, counter, function) do 331 | [function.() | do_times(limit, 1 + counter, function)] 332 | end 333 | 334 | defp remove_channel(channels, channel_pid) do 335 | Enum.filter(channels, fn %{pid: pid} -> 336 | channel_pid != pid 337 | end) 338 | end 339 | 340 | defp remove_monitor(monitors, pid) when is_pid(pid) do 341 | case Map.get(monitors, pid) do 342 | nil -> 343 | monitors 344 | 345 | ref -> 346 | true = Process.demonitor(ref) 347 | Map.delete(monitors, pid) 348 | end 349 | end 350 | 351 | defp remove_monitor(monitors, monitor_ref) when is_reference(monitor_ref) do 352 | find_monitor(monitors, monitor_ref) 353 | |> case do 354 | nil -> 355 | monitors 356 | 357 | {pid, _} -> 358 | true = Process.demonitor(monitor_ref) 359 | Map.delete(monitors, pid) 360 | end 361 | end 362 | 363 | defp find_channel(channels, channel_pid, monitors) do 364 | Enum.find(channels, &(&1.pid == channel_pid)) || Map.get(monitors, channel_pid) 365 | end 366 | 367 | defp replace_channel(old_channel_pid, adapter, conn) do 368 | true = Process.unlink(old_channel_pid) 369 | # omit the result 370 | adapter.close_channel(old_channel_pid) 371 | 372 | case start_channel(adapter, conn) do 373 | {:ok, channel} = result -> 374 | true = Process.link(channel.pid) 375 | result 376 | 377 | {:error, _reason} = error -> 378 | error 379 | end 380 | end 381 | 382 | defp find_monitor(monitors, ref) do 383 | Enum.find(monitors, fn {_pid, monitor_ref} -> monitor_ref == ref end) 384 | end 385 | end 386 | -------------------------------------------------------------------------------- /lib/worker/setup_queue.ex: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Worker.SetupQueue do 2 | use GenServer, restart: :transient 3 | 4 | def start_link(args) do 5 | GenServer.start_link(__MODULE__, args, []) 6 | end 7 | 8 | def init({pool_id, rabbitmq_config}) do 9 | setup_queues(pool_id, rabbitmq_config) 10 | :ignore 11 | end 12 | 13 | defp setup_queues(pool_id, rabbitmq_config) do 14 | adapter = rabbitmq_config |> Keyword.get(:adapter, ExRabbitPool.RabbitMQ) 15 | queues = rabbitmq_config |> Keyword.get(:queues, []) 16 | 17 | for queue_config <- queues do 18 | queue_name = queue_config |> Keyword.fetch!(:queue_name) 19 | exchange = queue_config |> Keyword.fetch!(:exchange) 20 | # Fail if couldn't create queue 21 | ExRabbitPool.create_queue_with_bind(adapter, pool_id, queue_name, exchange, queue_config) 22 | |> case do 23 | :ok -> 24 | :ok 25 | 26 | error -> 27 | raise error 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule BugsBunny.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ex_rabbit_pool, 7 | version: "1.0.3", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | test_coverage: [tool: ExCoveralls], 12 | description: "RabbitMQ connection pool library", 13 | package: package(), 14 | source_url: "https://github.com/esl/ex_rabbit_pool" 15 | ] 16 | end 17 | 18 | defp package() do 19 | [ 20 | files: ~w(lib .formatter.exs mix.exs README.md LICENSE), 21 | licenses: ["Apache 2"], 22 | links: %{ 23 | "GitHub" => "https://github.com/esl/ex_rabbit_pool", 24 | "Blog Post" => 25 | "https://www.erlang-solutions.com/blog/ex_rabbit_pool-open-source-amqp-connection-pool.html" 26 | } 27 | ] 28 | end 29 | 30 | def application do 31 | [ 32 | # https://github.com/pma/amqp/issues/90 33 | extra_applications: [:lager, :logger, :amqp] 34 | ] 35 | end 36 | 37 | defp deps do 38 | [ 39 | {:amqp, "~> 1.1"}, 40 | {:poolboy, "~> 1.5"}, 41 | {:credo, "~> 1.0.0", only: [:dev, :test], runtime: false}, 42 | {:ex_doc, "~> 0.18", only: :dev, runtime: false}, 43 | {:excoveralls, "~> 0.10.4", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "amqp": {:hex, :amqp, "1.2.0", "d086308524fc296e09d12bdeb5e7a63f92fc51b7a3a40c08042efa6121fb483b", [:mix], [{:amqp_client, "~> 3.7.11", [hex: :amqp_client, repo: "hexpm", optional: false]}, {:goldrush, "~> 0.1.0", [hex: :goldrush, repo: "hexpm", optional: false]}, {:jsx, "~> 2.9", [hex: :jsx, repo: "hexpm", optional: false]}, {:lager, "~> 3.6.5", [hex: :lager, repo: "hexpm", optional: false]}, {:rabbit_common, "~> 3.7.11", [hex: :rabbit_common, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7", [hex: :ranch, repo: "hexpm", optional: false]}, {:recon, "~> 2.3", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm"}, 3 | "amqp_client": {:hex, :amqp_client, "3.7.14", "bf8fd391a3ff02ae6fb8006f07900e22958c50a0a05b8b4856bbaf42f90fdaa2", [:make, :rebar3], [{:rabbit_common, "3.7.14", [hex: :rabbit_common, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 5 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "credo": {:hex, :credo, "1.0.5", "fdea745579f8845315fe6a3b43e2f9f8866839cfbc8562bb72778e9fdaa94214", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, 8 | "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 9 | "excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, 10 | "goldrush": {:hex, :goldrush, "0.1.9", "f06e5d5f1277da5c413e84d5a2924174182fb108dabb39d5ec548b27424cd106", [:rebar3], [], "hexpm"}, 11 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, 12 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, 13 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 14 | "jsx": {:hex, :jsx, "2.9.0", "d2f6e5f069c00266cad52fb15d87c428579ea4d7d73a33669e12679e203329dd", [:mix, :rebar3], [], "hexpm"}, 15 | "lager": {:hex, :lager, "3.6.9", "387bcd836dc0c8ad9c6d90a0e0ce5b29676847950cbc527bccc194a02028de8e", [:rebar3], [{:goldrush, "0.1.9", [hex: :goldrush, repo: "hexpm", optional: false]}], "hexpm"}, 16 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 17 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, 20 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, 21 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, 22 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 23 | "rabbit_common": {:hex, :rabbit_common, "3.7.14", "607741eff927ec9feb5d190c4624816c6dfcd6f49b1f8bab1f753c9417d74141", [:make, :rebar3], [{:jsx, "2.9.0", [hex: :jsx, repo: "hexpm", optional: false]}, {:lager, "3.6.9", [hex: :lager, repo: "hexpm", optional: false]}, {:ranch, "1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}, {:recon, "2.4.0", [hex: :recon, repo: "hexpm", optional: false]}], "hexpm"}, 24 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, 25 | "recon": {:hex, :recon, "2.4.0", "901ff78b39c754fb4d6fd72dcf0dbd398967bbd2e4d59c08d4d7aa44a73de91d", [:rebar3], [], "hexpm"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, 28 | } 29 | -------------------------------------------------------------------------------- /test/integration/api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Integration.ApiTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureLog 5 | alias ExRabbitPool.RabbitMQ 6 | alias ExRabbitPool.Worker.{RabbitConnection, SetupQueue} 7 | 8 | @moduletag :integration 9 | 10 | setup do 11 | n = :rand.uniform(100) 12 | pool_id = String.to_atom("test_pool#{n}") 13 | 14 | rabbitmq_config = [ 15 | channels: 1, 16 | port: String.to_integer(System.get_env("EX_RABBIT_POOL_PORT") || "5672") 17 | ] 18 | 19 | rabbitmq_conn_pool = [ 20 | name: {:local, pool_id}, 21 | worker_module: RabbitConnection, 22 | size: 1, 23 | max_overflow: 0 24 | ] 25 | 26 | start_supervised!(%{ 27 | id: ExRabbitPool.PoolSupervisorTest, 28 | start: 29 | {ExRabbitPool.PoolSupervisor, :start_link, 30 | [ 31 | [rabbitmq_config: rabbitmq_config, connection_pools: [rabbitmq_conn_pool]], 32 | ExRabbitPool.PoolSupervisorTest 33 | ]}, 34 | type: :supervisor 35 | }) 36 | 37 | start_supervised!( 38 | {SetupQueue, 39 | {pool_id, 40 | [ 41 | queues: [ 42 | [ 43 | queue_name: "", 44 | exchange: "", 45 | queue_options: [auto_delete: true, exclusive: true], 46 | exchange_options: [auto_delete: true, exclusive: true] 47 | ] 48 | ] 49 | ]}} 50 | ) 51 | 52 | {:ok, pool_id: pool_id} 53 | end 54 | 55 | test "executes command with a channel", %{pool_id: pool_id} do 56 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 57 | assert :ok = RabbitMQ.publish(channel, "", "", "hello") 58 | end) 59 | end 60 | 61 | test "returns :out_of_channels when there aren't more channels", %{pool_id: pool_id} do 62 | ExRabbitPool.with_channel(pool_id, fn {:ok, _channel} -> 63 | ExRabbitPool.with_channel(pool_id, fn {:error, error} -> 64 | assert error == :out_of_channels 65 | end) 66 | end) 67 | end 68 | 69 | test "returns channel to pool after client actions", %{pool_id: pool_id} do 70 | conn_worker = :poolboy.checkout(pool_id) 71 | :ok = :poolboy.checkin(pool_id, conn_worker) 72 | %{channels: [channel1]} = RabbitConnection.state(conn_worker) 73 | 74 | ExRabbitPool.with_channel(pool_id, fn {:ok, _channel} -> 75 | assert %{channels: []} = RabbitConnection.state(conn_worker) 76 | end) 77 | 78 | assert %{channels: [channel2]} = RabbitConnection.state(conn_worker) 79 | refute channel1 == channel2 80 | end 81 | 82 | test "gets connection to open channel manually", %{pool_id: pool_id} do 83 | assert {:ok, conn} = ExRabbitPool.get_connection(pool_id) 84 | assert {:ok, channel} = RabbitMQ.open_channel(conn) 85 | assert :ok = AMQP.Channel.close(channel) 86 | end 87 | 88 | test "returns channel to the pool only once when there is a crash in a client using with_channel", 89 | %{pool_id: pool_id} do 90 | # TODO: capture [error] Process #PID raised an exception 91 | capture_log(fn -> 92 | conn_worker = :poolboy.checkout(pool_id) 93 | :ok = :poolboy.checkin(pool_id, conn_worker) 94 | :erlang.trace(conn_worker, true, [:receive]) 95 | 96 | {:ok, client_pid} = 97 | Task.start(fn -> 98 | ExRabbitPool.with_channel(pool_id, fn {:ok, _channel} -> 99 | raise "die" 100 | end) 101 | end) 102 | 103 | ref = Process.monitor(client_pid) 104 | # wait for client to die 105 | assert_receive {:DOWN, ^ref, :process, ^client_pid, {%{message: "die"}, _stacktrace}}, 1000 106 | # wait for channel to be put it back into the pool 107 | assert_receive {:trace, ^conn_worker, :receive, 108 | {:"$gen_cast", {:checkin_channel, _channel}}}, 109 | 1000 110 | 111 | # wait for the connection worker to receive a :DOWN message from the client 112 | # FLAKY assertion: sometimes the message was already received so this function fails 113 | # assert_receive {:trace, ^conn_worker, :receive, 114 | # {:DOWN, _ref, :process, ^client_pid, {%{message: "die"}, _stacktrace}}}, 1000 115 | 116 | assert %{channels: channels} = RabbitConnection.state(conn_worker) 117 | assert length(channels) == 1 118 | end) 119 | end 120 | 121 | test "returns channel to the pool only once when the channel closes using with_channel", 122 | %{pool_id: pool_id} do 123 | conn_worker = :poolboy.checkout(pool_id) 124 | :ok = :poolboy.checkin(pool_id, conn_worker) 125 | :erlang.trace(conn_worker, true, [:receive]) 126 | 127 | logs = 128 | capture_log(fn -> 129 | client_pid = 130 | spawn(fn -> 131 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 132 | :ok = AMQP.Channel.close(channel) 133 | :timer.sleep(500) 134 | end) 135 | end) 136 | 137 | ref = Process.monitor(client_pid) 138 | assert_receive {:DOWN, ^ref, :process, ^client_pid, :normal}, 1000 139 | 140 | assert_receive {:trace, ^conn_worker, :receive, 141 | {:"$gen_cast", {:checkin_channel, _channel}}} 142 | 143 | assert_receive {:trace, ^conn_worker, :receive, {:EXIT, _channel_pid, :normal}} 144 | assert %{channels: channels} = RabbitConnection.state(conn_worker) 145 | assert length(channels) == 1 146 | end) 147 | 148 | assert logs =~ "[Rabbit] channel lost reason: :normal" 149 | end 150 | 151 | test "creates queue with exchange and bindings", %{pool_id: pool_id} do 152 | assert :ok = 153 | ExRabbitPool.create_queue_with_bind( 154 | RabbitMQ, 155 | pool_id, 156 | "test_queue", 157 | "test_exchange", 158 | queue_options: [auto_delete: true], 159 | exchange_options: [auto_delete: true] 160 | ) 161 | 162 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 163 | assert :ok = AMQP.Basic.publish(channel, "test_exchange", "", "Hello, World!") 164 | assert {:ok, "Hello, World!", _meta} = AMQP.Basic.get(channel, "test_queue") 165 | assert {:ok, _} = AMQP.Queue.delete(channel, "test_queue") 166 | end) 167 | end 168 | 169 | test "should not fail when binding and declaring default exchange", %{pool_id: pool_id} do 170 | assert :ok = 171 | ExRabbitPool.create_queue_with_bind( 172 | RabbitMQ, 173 | pool_id, 174 | "test2_queue", 175 | "", 176 | queue_options: [auto_delete: true], 177 | exchange_options: [auto_delete: true] 178 | ) 179 | 180 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 181 | assert :ok = AMQP.Basic.publish(channel, "", "test2_queue", "Hello, World!") 182 | assert {:ok, "Hello, World!", _meta} = AMQP.Basic.get(channel, "test2_queue") 183 | assert {:ok, _} = AMQP.Queue.delete(channel, "test2_queue") 184 | end) 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /test/integration/consumer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.ConsumerTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureLog 5 | alias ExRabbitPool.Worker.SetupQueue 6 | alias ExRabbitPool.RabbitMQ 7 | alias AMQP.Queue 8 | require Logger 9 | 10 | @moduletag :integration 11 | 12 | defmodule TestConsumer do 13 | use ExRabbitPool.Consumer 14 | 15 | def setup_channel(%{adapter: adapter, config: config}, channel) do 16 | config = Keyword.get(config, :options, []) 17 | Logger.warn("Setting up channel with options: #{inspect(config)}") 18 | adapter.qos(channel, config) 19 | end 20 | 21 | def basic_deliver(%{adapter: adapter, channel: channel}, _payload, %{delivery_tag: tag}) do 22 | :ok = adapter.ack(channel, tag) 23 | end 24 | end 25 | 26 | defmodule TestConsumerNoAck do 27 | use ExRabbitPool.Consumer 28 | 29 | def setup_channel(%{adapter: adapter, config: config}, channel) do 30 | config = Keyword.get(config, :options, []) 31 | Logger.warn("Setting up channel with options: #{inspect(config)}") 32 | adapter.qos(channel, config) 33 | end 34 | 35 | def basic_deliver(_state, _payload, _meta) do 36 | :ok 37 | end 38 | end 39 | 40 | defmodule TestDefaultConsumer do 41 | use ExRabbitPool.Consumer 42 | end 43 | 44 | defp random_queue_name() do 45 | rnd = 46 | 8 47 | |> :crypto.strong_rand_bytes() 48 | |> Base.url_encode64() 49 | |> binary_part(0, 8) 50 | 51 | "test.queue-" <> rnd 52 | end 53 | 54 | def wait_for(timeout \\ 1000, f) 55 | def wait_for(0, _), do: {:error, "Error - Timeout"} 56 | 57 | def wait_for(timeout, f) do 58 | if f.() do 59 | :ok 60 | else 61 | :timer.sleep(10) 62 | wait_for(timeout - 10, f) 63 | end 64 | end 65 | 66 | setup do 67 | queue = random_queue_name() 68 | 69 | rabbitmq_config = [ 70 | channels: 2, 71 | port: String.to_integer(System.get_env("EX_RABBIT_POOL_PORT") || "5672") 72 | ] 73 | 74 | rabbitmq_conn_pool = [ 75 | name: {:local, :setup_queue_pool}, 76 | worker_module: ExRabbitPool.Worker.RabbitConnection, 77 | size: 1, 78 | max_overflow: 0 79 | ] 80 | 81 | start_supervised!(%{ 82 | id: ExRabbitPool.PoolSupervisorTest, 83 | start: 84 | {ExRabbitPool.PoolSupervisor, :start_link, 85 | [ 86 | [rabbitmq_config: rabbitmq_config, connection_pools: [rabbitmq_conn_pool]], 87 | ExRabbitPool.PoolSupervisorTest 88 | ]}, 89 | type: :supervisor 90 | }) 91 | 92 | start_supervised!( 93 | {SetupQueue, 94 | {:setup_queue_pool, 95 | [ 96 | queues: [ 97 | [ 98 | queue_name: queue, 99 | exchange: "#{queue}_exchange", 100 | queue_options: [auto_delete: true], 101 | exchange_options: [auto_delete: true] 102 | ] 103 | ] 104 | ]}} 105 | ) 106 | 107 | {:ok, pool_id: :setup_queue_pool, queue: queue} 108 | end 109 | 110 | test "should be able to consume messages out of rabbitmq", %{pool_id: pool_id, queue: queue} do 111 | logs = 112 | capture_log(fn -> 113 | pid = 114 | start_supervised!( 115 | {TestConsumer, pool_id: pool_id, queue: queue, options: [prefetch_count: 19]} 116 | ) 117 | 118 | :erlang.trace(pid, true, [:receive]) 119 | 120 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 121 | assert :ok = RabbitMQ.publish(channel, "#{queue}_exchange", "", "Hello Consumer!") 122 | assert_receive {:trace, ^pid, :receive, {:basic_deliver, "Hello Consumer!", _}}, 1000 123 | {:ok, result} = Queue.status(channel, queue) 124 | assert result == %{consumer_count: 1, message_count: 0, queue: queue} 125 | end) 126 | end) 127 | 128 | assert logs =~ "Setting up channel with options: [prefetch_count: 19]" 129 | end 130 | 131 | test "consumable messages should not exceed prefetch_count", %{pool_id: pool_id, queue: queue} do 132 | logs = 133 | capture_log(fn -> 134 | pid = 135 | start_supervised!( 136 | {TestConsumerNoAck, pool_id: pool_id, queue: queue, options: [prefetch_count: 2]} 137 | ) 138 | 139 | :erlang.trace(pid, true, [:receive]) 140 | 141 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 142 | assert :ok = RabbitMQ.publish(channel, "#{queue}_exchange", "", "Hello Consumer 1!") 143 | assert :ok = RabbitMQ.publish(channel, "#{queue}_exchange", "", "Hello Consumer 2!") 144 | assert :ok = RabbitMQ.publish(channel, "#{queue}_exchange", "", "Hello Consumer 3!") 145 | assert_receive {:trace, ^pid, :receive, {:basic_deliver, "Hello Consumer 1!", _}}, 1000 146 | assert_receive {:trace, ^pid, :receive, {:basic_deliver, "Hello Consumer 2!", _}}, 1000 147 | refute_receive {:trace, ^pid, :receive, {:basic_deliver, "Hello Consumer 3!", _}}, 1000 148 | 149 | {:ok, result} = Queue.status(channel, queue) 150 | assert result == %{consumer_count: 1, message_count: 1, queue: queue} 151 | end) 152 | end) 153 | 154 | assert logs =~ "Setting up channel with options: [prefetch_count: 2]" 155 | end 156 | 157 | test "should be able to consume messages out of rabbitmq with default consumer", %{ 158 | pool_id: pool_id, 159 | queue: queue 160 | } do 161 | pid = start_supervised!({TestDefaultConsumer, pool_id: pool_id, queue: queue}) 162 | :erlang.trace(pid, true, [:receive]) 163 | 164 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 165 | assert :ok = RabbitMQ.publish(channel, "#{queue}_exchange", "", "Hello Consumer!") 166 | assert_receive {:trace, ^pid, :receive, {:basic_deliver, "Hello Consumer!", _}}, 1000 167 | 168 | {:ok, result} = Queue.status(channel, queue) 169 | assert result == %{consumer_count: 1, message_count: 0, queue: queue} 170 | end) 171 | end 172 | 173 | @tag capture_log: true 174 | test "should terminate consumer after Basic.cancel (basic_cancel_ok)", %{ 175 | pool_id: pool_id, 176 | queue: queue 177 | } do 178 | consumer_pid = 179 | start_supervised!({TestConsumer, pool_id: pool_id, queue: queue}, restart: :temporary) 180 | 181 | %{channel: channel, consumer_tag: consumer_tag} = :sys.get_state(consumer_pid) 182 | {:ok, ^consumer_tag} = RabbitMQ.cancel_consume(channel, consumer_tag) 183 | assert :ok = wait_for(fn -> !Process.alive?(consumer_pid) end) 184 | end 185 | 186 | @tag capture_log: true 187 | test "should terminate consumer after queue deletion (basic_cancel)", %{ 188 | pool_id: pool_id, 189 | queue: queue 190 | } do 191 | consumer_pid = 192 | start_supervised!({TestConsumer, pool_id: pool_id, queue: queue}, restart: :temporary) 193 | 194 | %{consumer_tag: consumer_tag} = :sys.get_state(consumer_pid) 195 | 196 | :erlang.trace(consumer_pid, true, [:receive]) 197 | 198 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 199 | {:ok, _} = Queue.delete(channel, queue) 200 | end) 201 | 202 | assert_receive {:trace, ^consumer_pid, :receive, 203 | {:basic_cancel, %{consumer_tag: ^consumer_tag, no_wait: true}}}, 204 | 1000 205 | 206 | assert :ok = wait_for(fn -> !Process.alive?(consumer_pid) end) 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /test/integration/rabbit_connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Integration.RabbitConnectionTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias ExRabbitPool.Worker.RabbitConnection, as: ConnWorker 7 | alias AMQP.Connection 8 | 9 | @moduletag :integration 10 | 11 | setup do 12 | rabbitmq_config = [ 13 | channels: 1, 14 | port: String.to_integer(System.get_env("EX_RABBIT_POOL_PORT") || "5672") 15 | ] 16 | 17 | {:ok, config: rabbitmq_config} 18 | end 19 | 20 | @tag capture_log: true 21 | test "reconnects to rabbitmq when a connection crashes", %{config: config} do 22 | pid = start_supervised!({ConnWorker, [{:reconnect_interval, 10} | config]}) 23 | :erlang.trace(pid, true, [:receive]) 24 | 25 | logs = 26 | capture_log(fn -> 27 | assert {:ok, %{pid: conn_pid}} = ConnWorker.get_connection(pid) 28 | true = Process.exit(conn_pid, :kill) 29 | assert_receive {:trace, ^pid, :receive, {:EXIT, ^conn_pid, :killed}} 30 | assert_receive {:trace, ^pid, :receive, {:EXIT, _channel_pid, :shutdown}} 31 | assert_receive {:trace, ^pid, :receive, :connect}, 200 32 | assert {:ok, _conn} = ConnWorker.get_connection(pid) 33 | end) 34 | 35 | assert logs =~ "[Rabbit] connection lost, attempting to reconnect reason: :killed" 36 | end 37 | 38 | test "reconnects to rabbitmq when a connection is closed", %{config: config} do 39 | pid = start_supervised!({ConnWorker, [{:reconnect_interval, 10} | config]}) 40 | :erlang.trace(pid, true, [:receive]) 41 | 42 | logs = 43 | capture_log(fn -> 44 | assert {:ok, %{pid: conn_pid} = conn} = ConnWorker.get_connection(pid) 45 | :ok = Connection.close(conn) 46 | assert_receive {:trace, ^pid, :receive, {:EXIT, _channel_pid, :normal}} 47 | assert_receive {:trace, ^pid, :receive, {:EXIT, ^conn_pid, {:shutdown, :normal}}} 48 | assert_receive {:trace, ^pid, :receive, :connect}, 200 49 | refute Process.alive?(conn_pid) 50 | assert {:ok, _conn} = ConnWorker.get_connection(pid) 51 | end) 52 | 53 | assert logs =~ "[Rabbit] channel lost reason: :normal" 54 | assert logs =~ "[Rabbit] error starting channel reason: :closing" 55 | 56 | assert logs =~ 57 | "[Rabbit] connection lost, attempting to reconnect reason: {:shutdown, :normal}" 58 | end 59 | 60 | test "creates a new channel when a channel crashes", %{config: config} do 61 | pid = start_supervised!({ConnWorker, [{:reconnect_interval, 10} | config]}) 62 | :erlang.trace(pid, true, [:receive]) 63 | 64 | logs = 65 | capture_log(fn -> 66 | assert {:ok, channel} = ConnWorker.checkout_channel(pid) 67 | %{pid: channel_pid} = channel 68 | 69 | client_pid = 70 | spawn(fn -> 71 | :ok = AMQP.Channel.close(channel) 72 | end) 73 | 74 | ref = Process.monitor(client_pid) 75 | assert_receive {:DOWN, ^ref, :process, ^client_pid, :normal} 76 | assert_receive {:trace, ^pid, :receive, {:EXIT, ^channel_pid, :normal}} 77 | %{channels: channels, monitors: monitors} = ConnWorker.state(pid) 78 | assert length(channels) == 1 79 | assert Enum.empty?(monitors) 80 | end) 81 | 82 | assert logs =~ "[Rabbit] channel lost reason: :normal" 83 | end 84 | 85 | @tag capture_log: true 86 | test "creates a new channel on demand", %{config: config} do 87 | config = Keyword.merge(config, [{:reconnect_interval, 10}, {:channels, 0}]) 88 | pid = start_supervised!({ConnWorker, config}) 89 | assert {:ok, channel} = ConnWorker.create_channel(pid) 90 | :ok = AMQP.Channel.close(channel) 91 | %{channels: channels} = ConnWorker.state(pid) 92 | assert Enum.empty?(channels) 93 | end 94 | 95 | test "creates a new channel when checkin back a channel", %{config: config} do 96 | pid = start_supervised!({ConnWorker, config}) 97 | assert {:ok, channel} = ConnWorker.checkout_channel(pid) 98 | assert %{channels: []} = ConnWorker.state(pid) 99 | assert :ok = ConnWorker.checkin_channel(pid, channel) 100 | assert %{channels: [channel2]} = ConnWorker.state(pid) 101 | refute channel == channel2 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/integration/setup_queue_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Integration.SetupQueueTest do 2 | use ExUnit.Case 3 | 4 | alias ExRabbitPool.Worker.SetupQueue 5 | alias ExRabbitPool.RabbitMQ 6 | alias AMQP.{Basic, Queue} 7 | 8 | @moduletag :integration 9 | 10 | defp random_queue_name() do 11 | rnd = 12 | 8 13 | |> :crypto.strong_rand_bytes() 14 | |> Base.url_encode64() 15 | |> binary_part(0, 8) 16 | 17 | "test.queue-" <> rnd 18 | end 19 | 20 | setup do 21 | caller = self() 22 | 23 | rabbitmq_config = [ 24 | channels: 1, 25 | port: String.to_integer(System.get_env("EX_RABBIT_POOL_PORT") || "5672"), 26 | caller: caller 27 | ] 28 | 29 | rabbitmq_conn_pool = [ 30 | name: {:local, :setup_queue_pool}, 31 | worker_module: ExRabbitPool.Worker.RabbitConnection, 32 | size: 1, 33 | max_overflow: 0 34 | ] 35 | 36 | start_supervised!(%{ 37 | id: ExRabbitPool.PoolSupervisorTest, 38 | start: 39 | {ExRabbitPool.PoolSupervisor, :start_link, 40 | [ 41 | [rabbitmq_config: rabbitmq_config, connection_pools: [rabbitmq_conn_pool]], 42 | ExRabbitPool.PoolSupervisorTest 43 | ]}, 44 | type: :supervisor 45 | }) 46 | 47 | {:ok, pool_id: :setup_queue_pool, queue1: random_queue_name(), queue2: random_queue_name()} 48 | end 49 | 50 | test "declare queue on startup", %{pool_id: pool_id, queue1: queue1, queue2: queue2} do 51 | start_supervised!( 52 | {SetupQueue, 53 | {pool_id, 54 | [ 55 | queues: [ 56 | [ 57 | queue_name: queue1, 58 | exchange: "#{queue1}_exchange", 59 | queue_options: [auto_delete: true], 60 | exchange_options: [auto_delete: true] 61 | ], 62 | [ 63 | queue_name: queue2, 64 | exchange: "#{queue2}_exchange", 65 | queue_options: [auto_delete: true], 66 | exchange_options: [auto_delete: true] 67 | ] 68 | ] 69 | ]}} 70 | ) 71 | 72 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 73 | assert :ok = RabbitMQ.publish(channel, "#{queue1}_exchange", "", "Hello, World!") 74 | assert {:ok, "Hello, World!", _meta} = Basic.get(channel, queue1, no_ack: true) 75 | 76 | assert :ok = RabbitMQ.publish(channel, "#{queue2}_exchange", "", "Hell Yeah!") 77 | assert {:ok, "Hell Yeah!", _meta} = Basic.get(channel, queue2, no_ack: true) 78 | 79 | assert {:ok, _} = Queue.delete(channel, queue1) 80 | assert {:ok, _} = Queue.delete(channel, queue2) 81 | end) 82 | end 83 | 84 | test "declare queues with multiple bindings on startup", %{ 85 | pool_id: pool_id, 86 | queue1: queue1, 87 | queue2: queue2 88 | } do 89 | start_supervised!( 90 | {SetupQueue, 91 | {pool_id, 92 | [ 93 | queues: [ 94 | [ 95 | queue_name: queue1, 96 | exchange: "X", 97 | queue_options: [auto_delete: true], 98 | exchange_options: [auto_delete: true], 99 | bind_options: [routing_key: "orange"] 100 | ], 101 | [ 102 | queue_name: queue2, 103 | exchange: "X", 104 | queue_options: [auto_delete: true], 105 | exchange_options: [auto_delete: true], 106 | bind_options: [routing_key: "black"] 107 | ], 108 | [ 109 | queue_name: queue2, 110 | exchange: "X", 111 | queue_options: [auto_delete: true], 112 | exchange_options: [auto_delete: true], 113 | bind_options: [routing_key: "green"] 114 | ] 115 | ] 116 | ]}} 117 | ) 118 | 119 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 120 | assert :ok = RabbitMQ.publish(channel, "X", "orange", "Hello, World!") 121 | assert {:ok, "Hello, World!", _meta} = Basic.get(channel, queue1, no_ack: true) 122 | 123 | assert :ok = RabbitMQ.publish(channel, "X", "black", "Hola Mundo!") 124 | assert {:ok, "Hola Mundo!", _meta} = Basic.get(channel, queue2, no_ack: true) 125 | 126 | assert :ok = RabbitMQ.publish(channel, "X", "green", "Olá Mundo!") 127 | assert {:ok, "Olá Mundo!", _meta} = Basic.get(channel, queue2, no_ack: true) 128 | 129 | assert {:ok, _} = Queue.delete(channel, queue1) 130 | assert {:ok, _} = Queue.delete(channel, queue2) 131 | end) 132 | end 133 | 134 | test "declare queue with fanout exchange", %{pool_id: pool_id, queue1: queue1, queue2: queue2} do 135 | start_supervised!( 136 | {SetupQueue, 137 | {pool_id, 138 | [ 139 | queues: [ 140 | [ 141 | queue_name: queue1, 142 | exchange: "#{queue1}_exchange", 143 | queue_options: [auto_delete: true], 144 | exchange_options: [auto_delete: true, type: :fanout] 145 | ], 146 | [ 147 | queue_name: queue2, 148 | exchange: "#{queue1}_exchange", 149 | queue_options: [auto_delete: true], 150 | exchange_options: [auto_delete: true, type: :fanout] 151 | ] 152 | ] 153 | ]}} 154 | ) 155 | 156 | ExRabbitPool.with_channel(pool_id, fn {:ok, channel} -> 157 | assert :ok = RabbitMQ.publish(channel, "#{queue1}_exchange", "", "Hello, World!") 158 | assert {:ok, "Hello, World!", _meta} = Basic.get(channel, queue1, no_ack: true) 159 | assert {:ok, "Hello, World!", _meta} = Basic.get(channel, queue2, no_ack: true) 160 | assert {:ok, _} = Queue.delete(channel, queue1) 161 | assert {:ok, _} = Queue.delete(channel, queue2) 162 | end) 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /test/worker/rabbit_connection_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ExRabbitPool.Worker.RabbitConnectionTest do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureLog 5 | 6 | alias ExRabbitPool.FakeRabbitMQ 7 | alias ExRabbitPool.Worker.RabbitConnection, as: ConnWorker 8 | 9 | setup do 10 | rabbitmq_config = [ 11 | channels: 5, 12 | port: String.to_integer(System.get_env("EX_RABBIT_POOL_PORT") || "5672"), 13 | queue: "test.queue", 14 | adapter: FakeRabbitMQ 15 | ] 16 | 17 | {:ok, config: rabbitmq_config} 18 | end 19 | 20 | test "creates a pool of channels based on config", %{config: config} do 21 | pid = start_supervised!({ConnWorker, config}) 22 | %{channels: channels, connection: connection} = ConnWorker.state(pid) 23 | refute is_nil(connection) 24 | assert length(channels) == 5 25 | end 26 | 27 | test "creates a pool of channels by default", %{config: config} do 28 | pid = start_supervised!({ConnWorker, Keyword.delete(config, :channels)}) 29 | %{channels: channels} = ConnWorker.state(pid) 30 | assert length(channels) == 10 31 | end 32 | 33 | test "adds record to monitors table when checking out a channel", %{config: config} do 34 | new_config = Keyword.update!(config, :channels, fn _ -> 1 end) 35 | pid = start_supervised!({ConnWorker, new_config}) 36 | assert {:ok, %{pid: pid} = channel} = ConnWorker.checkout_channel(pid) 37 | %{monitors: monitors} = ConnWorker.state(pid) 38 | assert Map.get(monitors, pid) |> is_reference() 39 | end 40 | 41 | test "return :out_of_channels when all channels are holded by clients", %{config: config} do 42 | new_config = Keyword.update!(config, :channels, fn _ -> 1 end) 43 | pid = start_supervised!({ConnWorker, new_config}) 44 | assert {:ok, channel} = ConnWorker.checkout_channel(pid) 45 | assert {:error, :out_of_channels} = ConnWorker.checkout_channel(pid) 46 | %{channels: channels, monitors: monitors} = ConnWorker.state(pid) 47 | assert Enum.empty?(channels) 48 | assert Kernel.map_size(monitors) == 1 49 | assert :ok = ConnWorker.checkin_channel(pid, channel) 50 | end 51 | 52 | test "creates a monitor when getting a channel and deletes the monitor when putting it back", %{ 53 | config: config 54 | } do 55 | pid = start_supervised!({ConnWorker, config}) 56 | assert {:ok, channel} = ConnWorker.checkout_channel(pid) 57 | %{monitors: monitors} = ConnWorker.state(pid) 58 | assert Kernel.map_size(monitors) == 1 59 | assert :ok = ConnWorker.checkin_channel(pid, channel) 60 | %{monitors: monitors} = ConnWorker.state(pid) 61 | assert Enum.empty?(monitors) 62 | end 63 | 64 | test "creates a new channel when a client holding it crashes", %{config: config} do 65 | new_config = Keyword.update!(config, :channels, fn _ -> 1 end) 66 | pid = start_supervised!({ConnWorker, new_config}) 67 | %{channels: [channel]} = ConnWorker.state(pid) 68 | 69 | client_pid = 70 | spawn(fn -> 71 | assert {:ok, ^channel} = ConnWorker.checkout_channel(pid) 72 | end) 73 | 74 | ref = Process.monitor(client_pid) 75 | assert_receive {:DOWN, ^ref, :process, ^client_pid, :normal} 76 | assert %{channels: channels, monitors: monitors} = ConnWorker.state(pid) 77 | assert length(channels) == 1 78 | assert Enum.empty?(monitors) 79 | end 80 | 81 | test "returns error when disconnected", %{config: config} do 82 | new_config = Keyword.update!(config, :queue, fn _ -> "error.queue" end) 83 | 84 | capture_log(fn -> 85 | pid = start_supervised!({ConnWorker, new_config}) 86 | assert {:error, :disconnected} = ConnWorker.get_connection(pid) 87 | assert {:error, :disconnected} = ConnWorker.checkout_channel(pid) 88 | end) =~ "[Rabbit] error reason: :invalid" 89 | end 90 | end 91 | --------------------------------------------------------------------------------